From d5bd4979076b3540cbefcdb9b167bb0f3e23ffbb Mon Sep 17 00:00:00 2001 From: coji Date: Wed, 4 Mar 2026 21:30:38 +0900 Subject: [PATCH 1/6] feat: add Kubernetes-style labels for run filtering Add `labels: Record` to runs for multi-tenant filtering. Labels are set at trigger time, stored as JSON in SQLite, and included in all run-scoped events. Filtering uses AND semantics via json_extract. - DB migration v2: add labels column to durably_runs - Storage: labels on Run, CreateRunInput, RunFilter with json_extract filtering - Events: labels on all run/step event types - Job handle: labels in TriggerOptions, passed through trigger/batchTrigger - Worker/Durably: labels in all event emissions - HTTP server: label.* query params for runs and SSE filtering - React hooks: labels option for useRuns (browser + client modes) - React hooks: stabilize labels object ref with useMemo to prevent infinite re-render loops when callers pass inline objects Co-Authored-By: Claude Opus 4.6 --- packages/durably-react/src/client/use-runs.ts | 32 ++++++- packages/durably-react/src/hooks/use-runs.ts | 17 +++- packages/durably/src/context.ts | 4 + packages/durably/src/durably.ts | 2 + packages/durably/src/events.ts | 10 ++ packages/durably/src/job.ts | 6 ++ packages/durably/src/migrations.ts | 10 ++ packages/durably/src/schema.ts | 1 + packages/durably/src/server.ts | 83 +++++++++++++---- packages/durably/src/storage.ts | 17 +++- packages/durably/src/worker.ts | 7 +- .../durably/tests/shared/events.shared.ts | 10 ++ .../durably/tests/shared/migrate.shared.ts | 6 +- .../durably/tests/shared/storage.shared.ts | 91 +++++++++++++++++++ 14 files changed, 268 insertions(+), 28 deletions(-) diff --git a/packages/durably-react/src/client/use-runs.ts b/packages/durably-react/src/client/use-runs.ts index 9afd619c..73d38c8d 100644 --- a/packages/durably-react/src/client/use-runs.ts +++ b/packages/durably-react/src/client/use-runs.ts @@ -1,5 +1,5 @@ import type { JobDefinition } from '@coji/durably' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { type Progress, type RunStatus, @@ -63,6 +63,10 @@ export interface UseRunsClientOptions { * Filter by status */ status?: RunStatus + /** + * Filter by labels (all specified labels must match) + */ + labels?: Record /** * Number of runs per page * @default 10 @@ -196,7 +200,15 @@ export function useRuns< ? (optionsArg as Omit) : (jobDefinitionOrOptions as UseRunsClientOptions) - const { api, status, pageSize = 10 } = options + const { api, status, labels, pageSize = 10 } = options + + // Stabilize labels reference to prevent infinite re-renders + const labelsKey = labels ? JSON.stringify(labels) : undefined + const stableLabels = useMemo( + () => + labelsKey ? (JSON.parse(labelsKey) as Record) : undefined, + [labelsKey], + ) const [runs, setRuns] = useState[]>([]) const [page, setPage] = useState(0) @@ -215,6 +227,7 @@ export function useRuns< const params = new URLSearchParams() if (jobName) params.set('jobName', jobName) if (status) params.set('status', status) + appendLabelsToParams(params, stableLabels) params.set('limit', String(pageSize + 1)) params.set('offset', String(page * pageSize)) @@ -240,7 +253,7 @@ export function useRuns< setIsLoading(false) } } - }, [api, jobName, status, pageSize, page]) + }, [api, jobName, status, stableLabels, pageSize, page]) // Initial fetch useEffect(() => { @@ -267,6 +280,7 @@ export function useRuns< // Build SSE URL const params = new URLSearchParams() if (jobName) params.set('jobName', jobName) + appendLabelsToParams(params, stableLabels) const sseUrl = `${api}/runs/subscribe${params.toString() ? `?${params.toString()}` : ''}` const eventSource = new EventSource(sseUrl) @@ -322,7 +336,7 @@ export function useRuns< eventSource.close() eventSourceRef.current = null } - }, [api, jobName, page, refresh]) + }, [api, jobName, stableLabels, page, refresh]) const nextPage = useCallback(() => { if (hasMore) { @@ -350,3 +364,13 @@ export function useRuns< refresh, } } + +function appendLabelsToParams( + params: URLSearchParams, + labels: Record | undefined, +) { + if (!labels) return + for (const [key, value] of Object.entries(labels)) { + params.set(`label.${key}`, value) + } +} diff --git a/packages/durably-react/src/hooks/use-runs.ts b/packages/durably-react/src/hooks/use-runs.ts index 343798f7..35d004fe 100644 --- a/packages/durably-react/src/hooks/use-runs.ts +++ b/packages/durably-react/src/hooks/use-runs.ts @@ -1,5 +1,5 @@ import type { JobDefinition } from '@coji/durably' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useDurably } from '../context' import { type TypedRun, isJobDefinition } from '../types' @@ -15,6 +15,10 @@ export interface UseRunsOptions { * Filter by status */ status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + /** + * Filter by labels (all specified labels must match) + */ + labels?: Record /** * Number of runs per page * @default 10 @@ -153,6 +157,14 @@ export function useRuns< const realtime = options?.realtime ?? true const status = options?.status + // Stabilize labels reference to prevent infinite re-renders + const labelsKey = options?.labels ? JSON.stringify(options.labels) : undefined + const labels = useMemo( + () => + labelsKey ? (JSON.parse(labelsKey) as Record) : undefined, + [labelsKey], + ) + const [runs, setRuns] = useState[]>([]) const [page, setPage] = useState(0) const [hasMore, setHasMore] = useState(false) @@ -166,6 +178,7 @@ export function useRuns< const data = await durably.getRuns({ jobName, status, + labels, limit: pageSize + 1, offset: page * pageSize, }) @@ -174,7 +187,7 @@ export function useRuns< } finally { setIsLoading(false) } - }, [durably, jobName, status, pageSize, page]) + }, [durably, jobName, status, labels, pageSize, page]) // Initial fetch and subscribe to events useEffect(() => { diff --git a/packages/durably/src/context.ts b/packages/durably/src/context.ts index e43af435..0a2d51ec 100644 --- a/packages/durably/src/context.ts +++ b/packages/durably/src/context.ts @@ -48,6 +48,7 @@ export function createStepContext( jobName, stepName: name, stepIndex, + labels: run.labels, }) try { @@ -77,6 +78,7 @@ export function createStepContext( stepIndex: stepIndex - 1, output: result, duration: Date.now() - startTime, + labels: run.labels, }) return result @@ -102,6 +104,7 @@ export function createStepContext( stepName: name, stepIndex, error: errorMessage, + labels: run.labels, }) throw error @@ -121,6 +124,7 @@ export function createStepContext( runId: run.id, jobName, progress: progressData, + labels: run.labels, }) }, diff --git a/packages/durably/src/durably.ts b/packages/durably/src/durably.ts index 8aab0db1..9ace846c 100644 --- a/packages/durably/src/durably.ts +++ b/packages/durably/src/durably.ts @@ -430,6 +430,7 @@ function createDurablyInstance< type: 'run:retry', runId, jobName: run.jobName, + labels: run.labels, }) }, @@ -456,6 +457,7 @@ function createDurablyInstance< type: 'run:cancel', runId, jobName: run.jobName, + labels: run.labels, }) }, diff --git a/packages/durably/src/events.ts b/packages/durably/src/events.ts index fb53eea0..feeafcd8 100644 --- a/packages/durably/src/events.ts +++ b/packages/durably/src/events.ts @@ -15,6 +15,7 @@ export interface RunTriggerEvent extends BaseEvent { runId: string jobName: string payload: unknown + labels: Record } /** @@ -25,6 +26,7 @@ export interface RunStartEvent extends BaseEvent { runId: string jobName: string payload: unknown + labels: Record } /** @@ -36,6 +38,7 @@ export interface RunCompleteEvent extends BaseEvent { jobName: string output: unknown duration: number + labels: Record } /** @@ -47,6 +50,7 @@ export interface RunFailEvent extends BaseEvent { jobName: string error: string failedStepName: string + labels: Record } /** @@ -56,6 +60,7 @@ export interface RunCancelEvent extends BaseEvent { type: 'run:cancel' runId: string jobName: string + labels: Record } /** @@ -65,6 +70,7 @@ export interface RunRetryEvent extends BaseEvent { type: 'run:retry' runId: string jobName: string + labels: Record } /** @@ -75,6 +81,7 @@ export interface RunProgressEvent extends BaseEvent { runId: string jobName: string progress: { current: number; total?: number; message?: string } + labels: Record } /** @@ -86,6 +93,7 @@ export interface StepStartEvent extends BaseEvent { jobName: string stepName: string stepIndex: number + labels: Record } /** @@ -99,6 +107,7 @@ export interface StepCompleteEvent extends BaseEvent { stepIndex: number output: unknown duration: number + labels: Record } /** @@ -111,6 +120,7 @@ export interface StepFailEvent extends BaseEvent { stepName: string stepIndex: number error: string + labels: Record } /** diff --git a/packages/durably/src/job.ts b/packages/durably/src/job.ts index efad177d..542438ad 100644 --- a/packages/durably/src/job.ts +++ b/packages/durably/src/job.ts @@ -62,6 +62,7 @@ export type JobFunction = ( export interface TriggerOptions { idempotencyKey?: string concurrencyKey?: string + labels?: Record /** Timeout in milliseconds for triggerAndWait() */ timeout?: number } @@ -72,6 +73,7 @@ export interface TriggerOptions { export interface RunFilter { status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' jobName?: string + labels?: Record /** Maximum number of runs to return */ limit?: number /** Number of runs to skip (for pagination) */ @@ -233,6 +235,7 @@ export function createJobHandle( payload: validatedInput, idempotencyKey: options?.idempotencyKey, concurrencyKey: options?.concurrencyKey, + labels: options?.labels, }) // Emit run:trigger event @@ -241,6 +244,7 @@ export function createJobHandle( runId: run.id, jobName: jobDef.name, payload: validatedInput, + labels: run.labels, }) return run as TypedRun @@ -351,6 +355,7 @@ export function createJobHandle( payload: v.payload, idempotencyKey: v.options?.idempotencyKey, concurrencyKey: v.options?.concurrencyKey, + labels: v.options?.labels, })), ) @@ -361,6 +366,7 @@ export function createJobHandle( runId: runs[i].id, jobName: jobDef.name, payload: validated[i].payload, + labels: runs[i].labels, }) } diff --git a/packages/durably/src/migrations.ts b/packages/durably/src/migrations.ts index 9426f225..5ba5df69 100644 --- a/packages/durably/src/migrations.ts +++ b/packages/durably/src/migrations.ts @@ -110,6 +110,16 @@ const migrations: Migration[] = [ .execute() }, }, + { + version: 2, + up: async (db) => { + // Add labels column to runs table + await db.schema + .alterTable('durably_runs') + .addColumn('labels', 'text', (col) => col.notNull().defaultTo('{}')) + .execute() + }, + }, ] /** diff --git a/packages/durably/src/schema.ts b/packages/durably/src/schema.ts index 10b08745..091abad3 100644 --- a/packages/durably/src/schema.ts +++ b/packages/durably/src/schema.ts @@ -13,6 +13,7 @@ export interface RunsTable { progress: string | null // JSON: { current, total, message } output: string | null // JSON error: string | null + labels: string // JSON: Record heartbeat_at: string // ISO8601 created_at: string // ISO8601 updated_at: string // ISO8601 diff --git a/packages/durably/src/server.ts b/packages/durably/src/server.ts index d0214fc9..e28cba29 100644 --- a/packages/durably/src/server.ts +++ b/packages/durably/src/server.ts @@ -22,6 +22,7 @@ export interface TriggerRequest { input: Record idempotencyKey?: string concurrencyKey?: string + labels?: Record } /** @@ -37,6 +38,7 @@ export interface TriggerResponse { export interface RunsRequest { jobName?: string status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + labels?: Record limit?: number offset?: number } @@ -158,6 +160,34 @@ export interface CreateDurablyHandlerOptions { onRequest?: () => Promise | void } +/** + * Parse label.* query params into a Record + */ +function parseLabelsFromParams( + searchParams: URLSearchParams, +): Record | undefined { + const labels: Record = {} + for (const [key, value] of searchParams.entries()) { + if (key.startsWith('label.')) { + labels[key.slice(6)] = value + } + } + return Object.keys(labels).length > 0 ? labels : undefined +} + +/** + * Check if event labels match filter labels (all filter labels must match) + */ +function matchesLabels( + eventLabels: Record, + filterLabels: Record, +): boolean { + for (const [key, value] of Object.entries(filterLabels)) { + if (eventLabels[key] !== value) return false + } + return true +} + /** * Create HTTP handlers for Durably * Uses Web Standard Request/Response for framework-agnostic usage @@ -217,6 +247,7 @@ export function createDurablyHandler( const run = await job.trigger(body.input ?? {}, { idempotencyKey: body.idempotencyKey, concurrencyKey: body.concurrencyKey, + labels: body.labels, }) const response: TriggerResponse = { runId: run.id } @@ -246,10 +277,12 @@ export function createDurablyHandler( const status = url.searchParams.get('status') as RunsRequest['status'] const limit = url.searchParams.get('limit') const offset = url.searchParams.get('offset') + const labels = parseLabelsFromParams(url.searchParams) const runs = await durably.getRuns({ jobName, status, + labels, limit: limit ? Number.parseInt(limit, 10) : undefined, offset: offset ? Number.parseInt(offset, 10) : undefined, }) @@ -337,110 +370,127 @@ export function createDurablyHandler( runsSubscribe(request: Request): Response { const url = new URL(request.url) const jobNameFilter = url.searchParams.get('jobName') - - // Helper to check job name filter - const matchesFilter = (jobName: string) => - !jobNameFilter || jobName === jobNameFilter + const labelsFilter = parseLabelsFromParams(url.searchParams) + + // Helper to check job name and labels filter + const matchesFilter = ( + jobName: string, + labels?: Record, + ) => { + if (jobNameFilter && jobName !== jobNameFilter) return false + if (labelsFilter && (!labels || !matchesLabels(labels, labelsFilter))) + return false + return true + } const sseStream = createSSEStreamFromSubscriptions( (ctrl: SSEStreamController) => [ durably.on('run:trigger', (event) => { - if (matchesFilter(event.jobName)) { + if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ type: 'run:trigger', runId: event.runId, jobName: event.jobName, + labels: event.labels, }) } }), durably.on('run:start', (event) => { - if (matchesFilter(event.jobName)) { + if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ type: 'run:start', runId: event.runId, jobName: event.jobName, + labels: event.labels, }) } }), durably.on('run:complete', (event) => { - if (matchesFilter(event.jobName)) { + if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ type: 'run:complete', runId: event.runId, jobName: event.jobName, + labels: event.labels, }) } }), durably.on('run:fail', (event) => { - if (matchesFilter(event.jobName)) { + if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ type: 'run:fail', runId: event.runId, jobName: event.jobName, + labels: event.labels, }) } }), durably.on('run:cancel', (event) => { - if (matchesFilter(event.jobName)) { + if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ type: 'run:cancel', runId: event.runId, jobName: event.jobName, + labels: event.labels, }) } }), durably.on('run:retry', (event) => { - if (matchesFilter(event.jobName)) { + if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ type: 'run:retry', runId: event.runId, jobName: event.jobName, + labels: event.labels, }) } }), durably.on('run:progress', (event) => { - if (matchesFilter(event.jobName)) { + if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ type: 'run:progress', runId: event.runId, jobName: event.jobName, progress: event.progress, + labels: event.labels, }) } }), durably.on('step:start', (event) => { - if (matchesFilter(event.jobName)) { + if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ type: 'step:start', runId: event.runId, jobName: event.jobName, stepName: event.stepName, stepIndex: event.stepIndex, + labels: event.labels, }) } }), durably.on('step:complete', (event) => { - if (matchesFilter(event.jobName)) { + if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ type: 'step:complete', runId: event.runId, jobName: event.jobName, stepName: event.stepName, stepIndex: event.stepIndex, + labels: event.labels, }) } }), durably.on('step:fail', (event) => { - if (matchesFilter(event.jobName)) { + if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ type: 'step:fail', runId: event.runId, @@ -448,14 +498,15 @@ export function createDurablyHandler( stepName: event.stepName, stepIndex: event.stepIndex, error: event.error, + labels: event.labels, }) } }), durably.on('log:write', (event) => { - // log:write doesn't have jobName, so we can't filter by it + // log:write doesn't have jobName or labels, so we can't filter by it // Send all logs when no filter, or skip if filter is set - if (!jobNameFilter) { + if (!jobNameFilter && !labelsFilter) { ctrl.enqueue({ type: 'log:write', runId: event.runId, diff --git a/packages/durably/src/storage.ts b/packages/durably/src/storage.ts index 5f98e4d5..088622ed 100644 --- a/packages/durably/src/storage.ts +++ b/packages/durably/src/storage.ts @@ -1,4 +1,4 @@ -import type { Kysely } from 'kysely' +import { type Kysely, sql } from 'kysely' import { ulid } from 'ulidx' import type { Database } from './schema' @@ -10,6 +10,7 @@ export interface CreateRunInput { payload: unknown idempotencyKey?: string concurrencyKey?: string + labels?: Record } /** @@ -27,6 +28,7 @@ export interface Run { progress: { current: number; total?: number; message?: string } | null output: unknown | null error: string | null + labels: Record heartbeatAt: string createdAt: string updatedAt: string @@ -50,6 +52,7 @@ export interface UpdateRunInput { export interface RunFilter { status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' jobName?: string + labels?: Record /** Maximum number of runs to return */ limit?: number /** Number of runs to skip (for pagination) */ @@ -149,6 +152,7 @@ function rowToRun( progress: row.progress ? JSON.parse(row.progress) : null, output: row.output ? JSON.parse(row.output) : null, error: row.error, + labels: JSON.parse(row.labels), heartbeatAt: row.heartbeat_at, createdAt: row.created_at, updatedAt: row.updated_at, @@ -221,6 +225,7 @@ export function createKyselyStorage(db: Kysely): Storage { progress: null, output: null, error: null, + labels: JSON.stringify(input.labels ?? {}), heartbeat_at: now, created_at: now, updated_at: now, @@ -270,6 +275,7 @@ export function createKyselyStorage(db: Kysely): Storage { progress: null, output: null, error: null, + labels: JSON.stringify(input.labels ?? {}), heartbeat_at: now, created_at: now, updated_at: now, @@ -348,6 +354,15 @@ export function createKyselyStorage(db: Kysely): Storage { if (filter?.jobName) { query = query.where('durably_runs.job_name', '=', filter.jobName) } + if (filter?.labels) { + for (const [key, value] of Object.entries(filter.labels)) { + query = query.where( + sql`json_extract(durably_runs.labels, ${`$.${key}`})`, + '=', + value, + ) + } + } query = query.orderBy('durably_runs.created_at', 'desc') diff --git a/packages/durably/src/worker.ts b/packages/durably/src/worker.ts index 7c0bf474..69f22819 100644 --- a/packages/durably/src/worker.ts +++ b/packages/durably/src/worker.ts @@ -91,7 +91,7 @@ export function createWorker( ): Promise { // Check if run was cancelled during execution - don't overwrite cancelled status const currentRun = await storage.getRun(runId) - if (currentRun?.status === 'cancelled') { + if (!currentRun || currentRun.status === 'cancelled') { return } @@ -106,6 +106,7 @@ export function createWorker( jobName, output, duration: Date.now() - startTime, + labels: currentRun.labels, }) } @@ -125,7 +126,7 @@ export function createWorker( // Check if run was cancelled during execution - don't overwrite cancelled status const currentRun = await storage.getRun(runId) - if (currentRun?.status === 'cancelled') { + if (!currentRun || currentRun.status === 'cancelled') { return } @@ -146,6 +147,7 @@ export function createWorker( jobName, error: errorMessage, failedStepName: failedStep?.name ?? 'unknown', + labels: currentRun.labels, }) } @@ -178,6 +180,7 @@ export function createWorker( runId: run.id, jobName: run.jobName, payload: run.payload, + labels: run.labels, }) const startTime = Date.now() diff --git a/packages/durably/tests/shared/events.shared.ts b/packages/durably/tests/shared/events.shared.ts index 3be95ace..2f9c7bee 100644 --- a/packages/durably/tests/shared/events.shared.ts +++ b/packages/durably/tests/shared/events.shared.ts @@ -24,6 +24,7 @@ export function createEventsTests(createDialect: () => Dialect) { runId: 'run_1', jobName: 'test-job', payload: { foo: 'bar' }, + labels: {}, }) expect(listener).toHaveBeenCalledTimes(1) @@ -47,6 +48,7 @@ export function createEventsTests(createDialect: () => Dialect) { runId: 'run_1', jobName: 'test-job', payload: {}, + labels: {}, }) durably.emit({ @@ -55,6 +57,7 @@ export function createEventsTests(createDialect: () => Dialect) { jobName: 'test-job', output: { result: 42 }, duration: 100, + labels: {}, }) expect(events[0].sequence).toBe(1) @@ -70,6 +73,7 @@ export function createEventsTests(createDialect: () => Dialect) { runId: 'run_1', jobName: 'test-job', payload: {}, + labels: {}, }) expect(events[0].timestamp).toBeDefined() @@ -91,6 +95,7 @@ export function createEventsTests(createDialect: () => Dialect) { runId: 'run_1', jobName: 'test-job', payload: {}, + labels: {}, }) expect(listener1).toHaveBeenCalledTimes(1) @@ -113,6 +118,7 @@ export function createEventsTests(createDialect: () => Dialect) { runId: 'run_1', jobName: 'test-job', payload: {}, + labels: {}, }) }).not.toThrow() @@ -129,6 +135,7 @@ export function createEventsTests(createDialect: () => Dialect) { runId: 'run_1', jobName: 'test-job', payload: {}, + labels: {}, }) expect(listener).toHaveBeenCalledTimes(1) @@ -140,6 +147,7 @@ export function createEventsTests(createDialect: () => Dialect) { runId: 'run_2', jobName: 'test-job', payload: {}, + labels: {}, }) expect(listener).toHaveBeenCalledTimes(1) // Still 1, not called again @@ -157,6 +165,7 @@ export function createEventsTests(createDialect: () => Dialect) { runId: 'run_1', jobName: 'test-job', payload: {}, + labels: {}, }) expect(startListener).toHaveBeenCalledTimes(1) @@ -177,6 +186,7 @@ export function createEventsTests(createDialect: () => Dialect) { runId: 'run_1', jobName: 'test-job', payload: {}, + labels: {}, }) expect(failingListener).toHaveBeenCalledTimes(1) diff --git a/packages/durably/tests/shared/migrate.shared.ts b/packages/durably/tests/shared/migrate.shared.ts index b31aded4..42903d24 100644 --- a/packages/durably/tests/shared/migrate.shared.ts +++ b/packages/durably/tests/shared/migrate.shared.ts @@ -68,7 +68,7 @@ export function createMigrateTests(createDialect: () => Dialect) { `.execute(durably.db) expect(result.rows).toHaveLength(1) - expect(result.rows[0].version).toBe(1) + expect(result.rows[0].version).toBe(2) }) it('is idempotent (can be called multiple times safely)', async () => { @@ -82,8 +82,8 @@ export function createMigrateTests(createDialect: () => Dialect) { SELECT version FROM durably_schema_versions `.execute(durably.db) - // Should only have one version record - expect(result.rows).toHaveLength(1) + // Should have version records for each migration + expect(result.rows).toHaveLength(2) }) }) } diff --git a/packages/durably/tests/shared/storage.shared.ts b/packages/durably/tests/shared/storage.shared.ts index fcf47c89..d7abf77f 100644 --- a/packages/durably/tests/shared/storage.shared.ts +++ b/packages/durably/tests/shared/storage.shared.ts @@ -191,6 +191,97 @@ export function createStorageTests(createDialect: () => Dialect) { expect(completedRuns).toHaveLength(1) }) + it('creates a run with labels', async () => { + const run = await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + labels: { organizationId: 'org_123', env: 'prod' }, + }) + + expect(run.labels).toEqual({ organizationId: 'org_123', env: 'prod' }) + + // Verify labels persist on getRun + const fetched = await durably.storage.getRun(run.id) + expect(fetched!.labels).toEqual({ + organizationId: 'org_123', + env: 'prod', + }) + }) + + it('defaults labels to empty object', async () => { + const run = await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + }) + + expect(run.labels).toEqual({}) + }) + + it('filters runs by single label', async () => { + await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + labels: { organizationId: 'org_1' }, + }) + await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + labels: { organizationId: 'org_2' }, + }) + await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + }) + + const runs = await durably.storage.getRuns({ + labels: { organizationId: 'org_1' }, + }) + expect(runs).toHaveLength(1) + expect(runs[0].labels).toEqual({ organizationId: 'org_1' }) + }) + + it('filters runs by multiple labels (AND)', async () => { + await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + labels: { organizationId: 'org_1', env: 'prod' }, + }) + await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + labels: { organizationId: 'org_1', env: 'staging' }, + }) + await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + labels: { organizationId: 'org_2', env: 'prod' }, + }) + + const runs = await durably.storage.getRuns({ + labels: { organizationId: 'org_1', env: 'prod' }, + }) + expect(runs).toHaveLength(1) + expect(runs[0].labels).toEqual({ + organizationId: 'org_1', + env: 'prod', + }) + }) + + it('returns all runs when labels filter is not specified', async () => { + await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + labels: { organizationId: 'org_1' }, + }) + await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + }) + + const runs = await durably.storage.getRuns() + expect(runs).toHaveLength(2) + }) + it('gets next pending run respecting concurrency keys', async () => { // Create runs with different concurrency keys await durably.storage.createRun({ From f02e5434f00632bc36ecaf568d62ead63f7e5213 Mon Sep 17 00:00:00 2001 From: coji Date: Wed, 4 Mar 2026 21:30:53 +0900 Subject: [PATCH 2/6] docs: update all documentation for labels feature - packages/durably/docs/llms.md: labels in trigger, query, types - website/api/: labels in index, create-durably, events, http-handler - website/api/durably-react/: labels in browser.md and client.md - website/guide/concepts.md: new Labels section - website/public/llms.txt: regenerated Co-Authored-By: Claude Opus 4.6 --- packages/durably/docs/llms.md | 29 ++++++ website/api/create-durably.md | 23 +++-- website/api/durably-react/browser.md | 97 ++++++++++--------- website/api/durably-react/client.md | 133 ++++++++++++++------------- website/api/events.md | 10 ++ website/api/http-handler.md | 27 +++--- website/api/index.md | 95 +++++++++++-------- website/guide/concepts.md | 72 ++++++++++----- website/public/llms.txt | 29 ++++++ 9 files changed, 328 insertions(+), 187 deletions(-) diff --git a/packages/durably/docs/llms.md b/packages/durably/docs/llms.md index 1db1e25a..46333382 100644 --- a/packages/durably/docs/llms.md +++ b/packages/durably/docs/llms.md @@ -100,6 +100,12 @@ await syncUsers.trigger( // With concurrency key (serializes execution) await syncUsers.trigger({ orgId: 'org_123' }, { concurrencyKey: 'org_123' }) + +// With labels (for filtering/multi-tenancy) +await syncUsers.trigger( + { orgId: 'org_123' }, + { labels: { organizationId: 'org_123', env: 'prod' } }, +) ``` ## Step Context API @@ -170,6 +176,11 @@ const runs = await durably.getRuns({ offset: 0, }) +// Filter by labels (multi-tenancy) +const orgRuns = await durably.getRuns({ + labels: { organizationId: 'org_123' }, +}) + // Typed getRuns with generic parameter type MyRun = Run & { payload: { userId: string } @@ -292,6 +303,13 @@ app.post('/api/durably/cancel', (req) => handler.cancel(req)) app.delete('/api/durably/run', (req) => handler.delete(req)) ``` +**Label filtering via query params:** + +``` +GET /runs?label.organizationId=org_123 +GET /runs/subscribe?label.organizationId=org_123&label.env=prod +``` + **Handler Interface:** ```ts @@ -316,6 +334,7 @@ interface TriggerRequest { input: Record idempotencyKey?: string concurrencyKey?: string + labels?: Record } interface TriggerResponse { @@ -414,6 +433,7 @@ interface Run { jobName: string status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' payload: unknown + labels: Record output?: TOutput error?: string progress?: { current: number; total?: number; message?: string } @@ -436,8 +456,17 @@ interface JobHandle { interface TriggerOptions { idempotencyKey?: string concurrencyKey?: string + labels?: Record timeout?: number } + +interface RunFilter { + status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + jobName?: string + labels?: Record + limit?: number + offset?: number +} ``` ## License diff --git a/website/api/create-durably.md b/website/api/create-durably.md index 14c3aae8..a90272a5 100644 --- a/website/api/create-durably.md +++ b/website/api/create-durably.md @@ -19,12 +19,12 @@ interface DurablyOptions { } ``` -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `dialect` | `Dialect` | required | Kysely SQLite dialect | -| `pollingInterval` | `number` | `1000` | How often to check for pending jobs (ms) | -| `heartbeatInterval` | `number` | `5000` | How often to update heartbeat (ms) | -| `staleThreshold` | `number` | `30000` | Time until a job is considered stale (ms) | +| Option | Type | Default | Description | +| ------------------- | --------- | -------- | ----------------------------------------- | +| `dialect` | `Dialect` | required | Kysely SQLite dialect | +| `pollingInterval` | `number` | `1000` | How often to check for pending jobs (ms) | +| `heartbeatInterval` | `number` | `5000` | How often to update heartbeat (ms) | +| `staleThreshold` | `number` | `30000` | Time until a job is considered stale (ms) | ## Returns @@ -132,7 +132,10 @@ Gets a single run by ID. Supports generic type parameter for type-safe access. const run = await durably.getRun(runId) // Typed (returns custom type) -type MyRun = Run & { payload: { userId: string }; output: { count: number } | null } +type MyRun = Run & { + payload: { userId: string } + output: { count: number } | null +} const typedRun = await durably.getRun(runId) ``` @@ -144,6 +147,7 @@ await durably.getRuns(filter?: RunFilter): Promise interface RunFilter { jobName?: string status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + labels?: Record limit?: number offset?: number } @@ -153,7 +157,10 @@ Gets runs with optional filtering and pagination. Supports generic type paramete ```ts // Typed getRuns -type MyRun = Run & { payload: { userId: string }; output: { count: number } | null } +type MyRun = Run & { + payload: { userId: string } + output: { count: number } | null +} const runs = await durably.getRuns({ jobName: 'my-job' }) ``` diff --git a/website/api/durably-react/browser.md b/website/api/durably-react/browser.md index 7d7f89c0..bc726758 100644 --- a/website/api/durably-react/browser.md +++ b/website/api/durably-react/browser.md @@ -3,7 +3,14 @@ Run Durably entirely in the browser using SQLite WASM with OPFS persistence. Jobs execute client-side with data stored in the browser's Origin Private File System. ```tsx -import { DurablyProvider, useDurably, useJob, useJobRun, useJobLogs, useRuns } from '@coji/durably-react' +import { + DurablyProvider, + useDurably, + useJob, + useJobRun, + useJobLogs, + useRuns, +} from '@coji/durably-react' ``` ## DurablyProvider @@ -37,12 +44,12 @@ function App() { ### Props -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `durably` | `Durably \| Promise` | required | Durably instance or Promise | -| `autoStart` | `boolean` | `true` | Auto-start worker on mount | -| `onReady` | `(durably: Durably) => void` | - | Callback when ready | -| `fallback` | `ReactNode` | - | Loading fallback (wraps in Suspense) | +| Prop | Type | Default | Description | +| ----------- | ----------------------------- | -------- | ------------------------------------ | +| `durably` | `Durably \| Promise` | required | Durably instance or Promise | +| `autoStart` | `boolean` | `true` | Auto-start worker on mount | +| `onReady` | `(durably: Durably) => void` | - | Callback when ready | +| `fallback` | `ReactNode` | - | Loading fallback (wraps in Suspense) | --- @@ -66,11 +73,11 @@ function Component() { ### Return Type -| Property | Type | Description | -|----------|------|-------------| -| `durably` | `Durably \| null` | The Durably instance | -| `isReady` | `boolean` | Whether Durably is initialized | -| `error` | `Error \| null` | Initialization error | +| Property | Type | Description | +| --------- | ----------------- | ------------------------------ | +| `durably` | `Durably \| null` | The Durably instance | +| `isReady` | `boolean` | Whether Durably is initialized | +| `error` | `Error \| null` | Initialization error | --- @@ -123,7 +130,11 @@ function Component() { Run

Status: {status}

- {progress &&

Progress: {progress.current}/{progress.total}

} + {progress && ( +

+ Progress: {progress.current}/{progress.total} +

+ )} {isCompleted &&

Result: {output?.result}

} {isFailed &&

Error: {error}

} @@ -134,8 +145,8 @@ function Component() { ### Options -| Option | Type | Description | -|--------|------|-------------| +| Option | Type | Description | +| -------------- | -------- | -------------------------------------- | | `initialRunId` | `string` | Resume subscription to an existing run | ### Return Type @@ -196,8 +207,8 @@ function RunMonitor({ runId }: { runId: string | null }) { ### Options -| Option | Type | Description | -|--------|------|-------------| +| Option | Type | Description | +| ------- | ---------------- | -------------------------- | | `runId` | `string \| null` | The run ID to subscribe to | --- @@ -233,10 +244,10 @@ function LogViewer({ runId }: { runId: string | null }) { ### Options -| Option | Type | Description | -|--------|------|-------------| -| `runId` | `string \| null` | The run ID to subscribe to | -| `maxLogs` | `number` | Maximum number of logs to keep | +| Option | Type | Description | +| --------- | ---------------- | ------------------------------ | +| `runId` | `string \| null` | The run ID to subscribe to | +| `maxLogs` | `number` | Maximum number of logs to keep | --- @@ -245,6 +256,7 @@ function LogViewer({ runId }: { runId: string | null }) { List runs with optional filtering, pagination, and real-time updates. The hook automatically subscribes to Durably events and refreshes the list when runs change. It listens to: + - `run:trigger`, `run:start`, `run:complete`, `run:fail`, `run:cancel`, `run:retry` - refresh list - `run:progress` - update progress in place - `step:start`, `step:complete` - refresh for step count updates @@ -266,7 +278,7 @@ function Dashboard() { return (
    - {runs.map(run => ( + {runs.map((run) => (
  • {run.jobName}: {run.status} {/* Use jobName to narrow the type */} @@ -290,7 +302,9 @@ const myJob = defineJob({ name: 'my-job', input: z.object({ value: z.string() }), output: z.object({ result: z.number() }), - run: async (step, payload) => { /* ... */ }, + run: async (step, payload) => { + /* ... */ + }, }) function RunList() { @@ -298,7 +312,7 @@ function RunList() { return (
      - {runs.map(run => ( + {runs.map((run) => (
    • {/* run.output is typed as { result: number } | null */} Result: {run.output?.result} @@ -319,7 +333,7 @@ function RunList() { return (
        - {runs.map(run => ( + {runs.map((run) => (
      • {/* run.output is unknown */} {run.jobName}: {run.status} @@ -345,22 +359,23 @@ useRuns(options?) ### Options -| Option | Type | Description | -|--------|------|-------------| -| `jobName` | `string` | Filter by job name (only for untyped usage) | -| `status` | `RunStatus` | Filter by status | -| `pageSize` | `number` | Number of runs per page (default: 10) | -| `realtime` | `boolean` | Subscribe to real-time updates (default: true) | +| Option | Type | Description | +| ---------- | ------------------------ | ---------------------------------------------- | +| `jobName` | `string` | Filter by job name (only for untyped usage) | +| `status` | `RunStatus` | Filter by status | +| `labels` | `Record` | Filter by labels | +| `pageSize` | `number` | Number of runs per page (default: 10) | +| `realtime` | `boolean` | Subscribe to real-time updates (default: true) | ### Return Type -| Property | Type | Description | -|----------|------|-------------| -| `runs` | `TypedRun[]` | List of runs (typed when using JobDefinition) | -| `page` | `number` | Current page (0-indexed) | -| `hasMore` | `boolean` | Whether more pages exist | -| `isLoading` | `boolean` | Loading state | -| `nextPage` | `() => void` | Go to next page | -| `prevPage` | `() => void` | Go to previous page | -| `goToPage` | `(page: number) => void` | Go to specific page | -| `refresh` | `() => Promise` | Manually refresh the list | +| Property | Type | Description | +| ----------- | ----------------------------- | --------------------------------------------- | +| `runs` | `TypedRun[]` | List of runs (typed when using JobDefinition) | +| `page` | `number` | Current page (0-indexed) | +| `hasMore` | `boolean` | Whether more pages exist | +| `isLoading` | `boolean` | Loading state | +| `nextPage` | `() => void` | Go to next page | +| `prevPage` | `() => void` | Go to previous page | +| `goToPage` | `(page: number) => void` | Go to specific page | +| `refresh` | `() => Promise` | Manually refresh the list | diff --git a/website/api/durably-react/client.md b/website/api/durably-react/client.md index 051aaee4..af0ad50e 100644 --- a/website/api/durably-react/client.md +++ b/website/api/durably-react/client.md @@ -118,14 +118,14 @@ function Component() { currentRunId, reset, } = useJob< - { userId: string }, // Input type - { count: number } // Output type + { userId: string }, // Input type + { count: number } // Output type >({ api: '/api/durably', jobName: 'sync-data', - initialRunId: undefined, // Optional: resume existing run - autoResume: true, // Auto-resume running/pending jobs on mount - followLatest: true, // Switch to tracking new runs via SSE + initialRunId: undefined, // Optional: resume existing run + autoResume: true, // Auto-resume running/pending jobs on mount + followLatest: true, // Switch to tracking new runs via SSE }) const handleClick = async () => { @@ -139,13 +139,13 @@ function Component() { ### Options -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `api` | `string` | - | API base path (e.g., `/api/durably`) | -| `jobName` | `string` | - | Name of the job to trigger | -| `initialRunId` | `string` | - | Resume subscription to an existing run | -| `autoResume` | `boolean` | `true` | Auto-resume running/pending jobs on mount | -| `followLatest` | `boolean` | `true` | Switch to tracking new runs via SSE | +| Option | Type | Default | Description | +| -------------- | --------- | ------- | ----------------------------------------- | +| `api` | `string` | - | API base path (e.g., `/api/durably`) | +| `jobName` | `string` | - | Name of the job to trigger | +| `initialRunId` | `string` | - | Resume subscription to an existing run | +| `autoResume` | `boolean` | `true` | Auto-resume running/pending jobs on mount | +| `followLatest` | `boolean` | `true` | Switch to tracking new runs via SSE | --- @@ -157,7 +157,9 @@ Subscribe to an existing run via SSE. import { useJobRun } from '@coji/durably-react/client' function Component({ runId }: { runId: string }) { - const { status, output, error, progress, logs } = useJobRun<{ count: number }>({ + const { status, output, error, progress, logs } = useJobRun<{ + count: number + }>({ api: '/api/durably', runId, }) @@ -168,9 +170,9 @@ function Component({ runId }: { runId: string }) { ### Options -| Option | Type | Description | -|--------|------|-------------| -| `api` | `string` | API base path | +| Option | Type | Description | +| ------- | -------- | -------------------------- | +| `api` | `string` | API base path | | `runId` | `string` | The run ID to subscribe to | --- @@ -201,10 +203,10 @@ function Component({ runId }: { runId: string }) { ### Options -| Option | Type | Description | -|--------|------|-------------| -| `api` | `string` | API base path | -| `runId` | `string` | The run ID to subscribe to | +| Option | Type | Description | +| --------- | -------- | ------------------------------ | +| `api` | `string` | API base path | +| `runId` | `string` | The run ID to subscribe to | | `maxLogs` | `number` | Maximum number of logs to keep | --- @@ -214,6 +216,7 @@ function Component({ runId }: { runId: string }) { List and paginate job runs with real-time updates on the first page. The first page (page 0) automatically subscribes to SSE for real-time updates. It listens to: + - `run:trigger`, `run:start`, `run:complete`, `run:fail`, `run:cancel`, `run:retry` - refresh list - `run:progress` - update progress in place - `step:start`, `step:complete`, `step:fail` - refresh for step updates @@ -237,7 +240,7 @@ function Dashboard() { return (
          - {runs.map(run => ( + {runs.map((run) => (
        • {run.jobName}: {run.status} {/* Use jobName to narrow the type */} @@ -261,7 +264,9 @@ const myJob = defineJob({ name: 'my-job', input: z.object({ value: z.string() }), output: z.object({ result: z.number() }), - run: async (step, payload) => { /* ... */ }, + run: async (step, payload) => { + /* ... */ + }, }) function RunList() { @@ -269,7 +274,7 @@ function RunList() { return (
            - {runs.map(run => ( + {runs.map((run) => (
          • {/* run.output is typed as { result: number } | null */} Result: {run.output?.result} @@ -286,11 +291,15 @@ function RunList() { import { useRuns } from '@coji/durably-react/client' function RunList() { - const { runs } = useRuns({ api: '/api/durably', jobName: 'my-job', pageSize: 10 }) + const { runs } = useRuns({ + api: '/api/durably', + jobName: 'my-job', + pageSize: 10, + }) return (
              - {runs.map(run => ( + {runs.map((run) => (
            • {/* run.output is unknown */} {run.jobName}: {run.status} @@ -316,26 +325,27 @@ useRuns(options) ### Options -| Option | Type | Description | -|--------|------|-------------| -| `api` | `string` | API base path | -| `jobName` | `string` | Filter by job name (only for untyped usage) | -| `status` | `RunStatus` | Filter by status | -| `pageSize` | `number` | Number of runs per page | +| Option | Type | Description | +| ---------- | ------------------------ | ------------------------------------------- | +| `api` | `string` | API base path | +| `jobName` | `string` | Filter by job name (only for untyped usage) | +| `status` | `RunStatus` | Filter by status | +| `labels` | `Record` | Filter by labels | +| `pageSize` | `number` | Number of runs per page | ### Return Type -| Property | Type | Description | -|----------|------|-------------| -| `runs` | `TypedClientRun[]` | List of runs (typed when using JobDefinition) | -| `isLoading` | `boolean` | Loading state | -| `error` | `string \| null` | Error message | -| `page` | `number` | Current page (0-indexed) | -| `hasMore` | `boolean` | Whether more pages exist | -| `nextPage` | `() => void` | Go to next page | -| `prevPage` | `() => void` | Go to previous page | -| `goToPage` | `(page: number) => void` | Go to specific page | -| `refresh` | `() => void` | Refresh current page | +| Property | Type | Description | +| ----------- | ----------------------------------- | --------------------------------------------- | +| `runs` | `TypedClientRun[]` | List of runs (typed when using JobDefinition) | +| `isLoading` | `boolean` | Loading state | +| `error` | `string \| null` | Error message | +| `page` | `number` | Current page (0-indexed) | +| `hasMore` | `boolean` | Whether more pages exist | +| `nextPage` | `() => void` | Go to next page | +| `prevPage` | `() => void` | Go to previous page | +| `goToPage` | `(page: number) => void` | Go to specific page | +| `refresh` | `() => void` | Refresh current page | --- @@ -347,15 +357,8 @@ Perform actions on runs (retry, cancel, delete). import { useRunActions } from '@coji/durably-react/client' function RunActions({ runId, status }: { runId: string; status: string }) { - const { - retry, - cancel, - deleteRun, - getRun, - getSteps, - isLoading, - error, - } = useRunActions({ api: '/api/durably' }) + const { retry, cancel, deleteRun, getRun, getSteps, isLoading, error } = + useRunActions({ api: '/api/durably' }) return (
              @@ -369,7 +372,9 @@ function RunActions({ runId, status }: { runId: string; status: string }) { Cancel )} - {(status === 'completed' || status === 'failed' || status === 'cancelled') && ( + {(status === 'completed' || + status === 'failed' || + status === 'cancelled') && ( @@ -382,18 +387,18 @@ function RunActions({ runId, status }: { runId: string; status: string }) { ### Options -| Option | Type | Description | -|--------|------|-------------| -| `api` | `string` | API base path | +| Option | Type | Description | +| ------ | -------- | ------------- | +| `api` | `string` | API base path | ### Return Type -| Property | Type | Description | -|----------|------|-------------| -| `retry` | `(runId: string) => Promise` | Retry a failed run | -| `cancel` | `(runId: string) => Promise` | Cancel a running job | -| `deleteRun` | `(runId: string) => Promise` | Delete a run | -| `getRun` | `(runId: string) => Promise` | Get run details | -| `getSteps` | `(runId: string) => Promise` | Get step details | -| `isLoading` | `boolean` | Loading state | -| `error` | `string \| null` | Error message | +| Property | Type | Description | +| ----------- | ------------------------------------------ | -------------------- | +| `retry` | `(runId: string) => Promise` | Retry a failed run | +| `cancel` | `(runId: string) => Promise` | Cancel a running job | +| `deleteRun` | `(runId: string) => Promise` | Delete a run | +| `getRun` | `(runId: string) => Promise` | Get run details | +| `getSteps` | `(runId: string) => Promise` | Get step details | +| `isLoading` | `boolean` | Loading state | +| `error` | `string \| null` | Error message | diff --git a/website/api/events.md b/website/api/events.md index eb121a0b..cce5fdf9 100644 --- a/website/api/events.md +++ b/website/api/events.md @@ -23,6 +23,7 @@ durably.on('run:trigger', (event) => { // runId: string, // jobName: string, // payload: unknown, + // labels: Record, // timestamp: string, // sequence: number // } @@ -40,6 +41,7 @@ durably.on('run:start', (event) => { // runId: string, // jobName: string, // payload: unknown, + // labels: Record, // timestamp: string, // sequence: number // } @@ -58,6 +60,7 @@ durably.on('run:complete', (event) => { // jobName: string, // output: unknown, // duration: number, + // labels: Record, // timestamp: string, // sequence: number // } @@ -76,6 +79,7 @@ durably.on('run:fail', (event) => { // jobName: string, // error: string, // failedStepName: string, + // labels: Record, // timestamp: string, // sequence: number // } @@ -93,6 +97,7 @@ durably.on('run:progress', (event) => { // runId: string, // jobName: string, // progress: { current: number, total?: number, message?: string }, + // labels: Record, // timestamp: string, // sequence: number // } @@ -109,6 +114,7 @@ durably.on('run:cancel', (event) => { // type: 'run:cancel', // runId: string, // jobName: string, + // labels: Record, // timestamp: string, // sequence: number // } @@ -125,6 +131,7 @@ durably.on('run:retry', (event) => { // type: 'run:retry', // runId: string, // jobName: string, + // labels: Record, // timestamp: string, // sequence: number // } @@ -145,6 +152,7 @@ durably.on('step:start', (event) => { // jobName: string, // stepName: string, // stepIndex: number, + // labels: Record, // timestamp: string, // sequence: number // } @@ -165,6 +173,7 @@ durably.on('step:complete', (event) => { // stepIndex: number, // output: unknown, // duration: number, + // labels: Record, // timestamp: string, // sequence: number // } @@ -184,6 +193,7 @@ durably.on('step:fail', (event) => { // stepName: string, // stepIndex: number, // error: string, + // labels: Record, // timestamp: string, // sequence: number // } diff --git a/website/api/http-handler.md b/website/api/http-handler.md index 9b4b47a9..914ba815 100644 --- a/website/api/http-handler.md +++ b/website/api/http-handler.md @@ -89,17 +89,17 @@ app.all('/api/durably/*', (c) => handler.handle(c.req.raw, '/api/durably')) The handler provides these endpoints: -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/trigger` | Trigger a job | -| `GET` | `/subscribe?runId=xxx` | SSE stream for run events | -| `GET` | `/runs` | List runs with filtering | -| `GET` | `/run?runId=xxx` | Get single run | -| `GET` | `/steps?runId=xxx` | Get steps for a run | -| `GET` | `/runs/subscribe` | SSE stream for run list updates | -| `POST` | `/retry?runId=xxx` | Retry a failed run | -| `POST` | `/cancel?runId=xxx` | Cancel a running job | -| `DELETE` | `/run?runId=xxx` | Delete a run | +| Method | Path | Description | +| -------- | ---------------------- | ------------------------------- | +| `POST` | `/trigger` | Trigger a job | +| `GET` | `/subscribe?runId=xxx` | SSE stream for run events | +| `GET` | `/runs` | List runs with filtering | +| `GET` | `/run?runId=xxx` | Get single run | +| `GET` | `/steps?runId=xxx` | Get steps for a run | +| `GET` | `/runs/subscribe` | SSE stream for run list updates | +| `POST` | `/retry?runId=xxx` | Retry a failed run | +| `POST` | `/cancel?runId=xxx` | Cancel a running job | +| `DELETE` | `/run?runId=xxx` | Delete a run | ## Trigger Request @@ -109,7 +109,8 @@ The handler provides these endpoints: "jobName": "import-csv", "input": { "filename": "data.csv" }, "idempotencyKey": "unique-key", // optional - "concurrencyKey": "user-123" // optional + "concurrencyKey": "user-123", // optional + "labels": { "organizationId": "org_123" } // optional } // Response @@ -138,7 +139,7 @@ The stream closes automatically when the run completes or fails. ## List Runs ```ts -// GET /api/durably/runs?jobName=import-csv&status=completed&limit=10&offset=0 +// GET /api/durably/runs?jobName=import-csv&status=completed&label.organizationId=org_123&limit=10&offset=0 // Response { diff --git a/website/api/index.md b/website/api/index.md index 4d5ac289..e383469f 100644 --- a/website/api/index.md +++ b/website/api/index.md @@ -62,14 +62,14 @@ const dialect = new LibsqlDialect({ client }) const durably = createDurably({ dialect, - pollingInterval: 1000, // Check for jobs every 1s - heartbeatInterval: 5000, // Heartbeat every 5s - staleThreshold: 30000, // Stale after 30s + pollingInterval: 1000, // Check for jobs every 1s + heartbeatInterval: 5000, // Heartbeat every 5s + staleThreshold: 30000, // Stale after 30s }).register({ importCsv: importCsvJob, }) -await durably.init() // Migrate DB + start worker +await durably.init() // Migrate DB + start worker ``` **See:** [createDurably](/api/create-durably) @@ -83,7 +83,7 @@ console.log('Started:', run.id) // Wait for completion const { id, output } = await durably.jobs.importCsv.triggerAndWait({ - filename: 'data.csv' + filename: 'data.csv', }) console.log('Done:', output.count) @@ -91,9 +91,10 @@ console.log('Done:', output.count) await durably.jobs.importCsv.trigger( { filename: 'data.csv' }, { - idempotencyKey: 'import-2024-01-01', // Prevent duplicates - concurrencyKey: 'csv-imports', // Limit concurrency - } + idempotencyKey: 'import-2024-01-01', // Prevent duplicates + concurrencyKey: 'csv-imports', // Limit concurrency + labels: { organizationId: 'org_123' }, // For filtering + }, ) ``` @@ -103,7 +104,9 @@ await durably.jobs.importCsv.trigger( durably.on('run:start', (e) => console.log(`Started: ${e.jobName}`)) durably.on('run:complete', (e) => console.log(`Done in ${e.duration}ms`)) durably.on('run:fail', (e) => console.error(`Failed: ${e.error}`)) -durably.on('run:progress', (e) => console.log(`${e.progress.current}/${e.progress.total}`)) +durably.on('run:progress', (e) => + console.log(`${e.progress.current}/${e.progress.total}`), +) ``` **See:** [Events](/api/events) @@ -151,10 +154,17 @@ function ImportButton() { return (
              - - {progress &&

              {progress.current}/{progress.total}

              } + {progress && ( +

              + {progress.current}/{progress.total} +

              + )} {isCompleted &&

              Imported {output?.count} rows

              }
              ) @@ -190,49 +200,54 @@ function ImportButton() { ### Core (@coji/durably) -| Export | Description | -|--------|-------------| -| `createDurably(options)` | Create instance with SQLite dialect | -| `defineJob(config)` | Define a job with typed schema | -| `createDurablyHandler(durably)` | Create HTTP/SSE handler | +| Export | Description | +| ------------------------------- | ----------------------------------- | +| `createDurably(options)` | Create instance with SQLite dialect | +| `defineJob(config)` | Define a job with typed schema | +| `createDurablyHandler(durably)` | Create HTTP/SSE handler | ### Instance Methods -| Method | Description | -|--------|-------------| -| `init()` | Migrate database and start worker | -| `register(jobs)` | Register job definitions | -| `on(event, handler)` | Subscribe to events | -| `stop()` | Stop worker gracefully | -| `retry(runId)` | Retry failed run | -| `cancel(runId)` | Cancel running job | +| Method | Description | +| -------------------- | --------------------------------- | +| `init()` | Migrate database and start worker | +| `register(jobs)` | Register job definitions | +| `on(event, handler)` | Subscribe to events | +| `stop()` | Stop worker gracefully | +| `retry(runId)` | Retry failed run | +| `cancel(runId)` | Cancel running job | ### Step Context -| Method | Description | -|--------|-------------| -| `step.run(name, fn)` | Create resumable checkpoint | -| `step.progress(current, total, msg)` | Report progress | -| `step.log.info/warn/error(msg)` | Write structured logs | +| Method | Description | +| ------------------------------------ | --------------------------- | +| `step.run(name, fn)` | Create resumable checkpoint | +| `step.progress(current, total, msg)` | Report progress | +| `step.log.info/warn/error(msg)` | Write structured logs | ### React Hooks (@coji/durably-react) -| Hook | Mode | Description | -|------|------|-------------| -| `useJob` | Both | Trigger and monitor jobs | -| `useJobRun` | Both | Subscribe to existing run | -| `useRuns` | Both | List runs with pagination | -| `useRunActions` | Server | Retry, cancel, delete runs | -| `useDurably` | Browser | Access Durably instance | +| Hook | Mode | Description | +| --------------- | ------- | -------------------------- | +| `useJob` | Both | Trigger and monitor jobs | +| `useJobRun` | Both | Subscribe to existing run | +| `useRuns` | Both | List runs with pagination | +| `useRunActions` | Server | Retry, cancel, delete runs | +| `useDurably` | Browser | Access Durably instance | ## Type Exports ```ts import type { - Durably, DurablyOptions, - JobDefinition, JobHandle, - StepContext, Run, RunStatus, + Durably, + DurablyOptions, + JobDefinition, + JobHandle, + StepContext, + Run, + RunStatus, TriggerOptions, - DurablyEvent, EventType, + DurablyEvent, + EventType, } from '@coji/durably' ``` diff --git a/website/guide/concepts.md b/website/guide/concepts.md index d70d6297..5d3199d0 100644 --- a/website/guide/concepts.md +++ b/website/guide/concepts.md @@ -20,12 +20,12 @@ const myJob = defineJob({ const { myJob: job } = durably.register({ myJob }) ``` -| Option | Required | Description | -|--------|----------|-------------| -| `name` | Yes | Unique job identifier | -| `input` | Yes | Zod schema for payload | -| `output` | No | Zod schema for return value | -| `run` | Yes | The job function | +| Option | Required | Description | +| -------- | -------- | --------------------------- | +| `name` | Yes | Unique job identifier | +| `input` | Yes | Zod schema for payload | +| `output` | No | Zod schema for return value | +| `run` | Yes | The job function | ### Job Lifecycle @@ -37,7 +37,7 @@ Steps are checkpoints created with `step.run()`: ```ts const result = await step.run('step-name', async () => { - return someValue // Persisted to SQLite + return someValue // Persisted to SQLite }) ``` @@ -53,7 +53,7 @@ await step.run('update-profile', () => updateProfile()) // Bad - duplicate names await step.run('step', () => doA()) -await step.run('step', () => doB()) // Returns cached result from doA! +await step.run('step', () => doB()) // Returns cached result from doA! ``` ### Break Large Operations into Steps @@ -85,12 +85,12 @@ for (let i = 0; i < rows.length; i += 100) { ```ts // First run -const data = await step.run('fetch', () => api.fetch()) // Runs, saves -await step.run('process', () => process(data)) // Crashes! +const data = await step.run('fetch', () => api.fetch()) // Runs, saves +await step.run('process', () => process(data)) // Crashes! // After restart -const data = await step.run('fetch', () => api.fetch()) // Returns cached -await step.run('process', () => process(data)) // Runs +const data = await step.run('fetch', () => api.fetch()) // Returns cached +await step.run('process', () => process(data)) // Runs ``` ### Heartbeat Mechanism @@ -100,8 +100,8 @@ Running jobs send heartbeats to indicate they're alive: ```ts createDurably({ dialect, - heartbeatInterval: 5000, // Send heartbeat every 5s - staleThreshold: 30000, // Mark stale after 30s without heartbeat + heartbeatInterval: 5000, // Send heartbeat every 5s + staleThreshold: 30000, // Mark stale after 30s without heartbeat }) ``` @@ -120,7 +120,7 @@ await step.run('charge', () => stripe.charges.create({ amount: 1000, idempotency_key: `order_${orderId}`, - }) + }), ) ``` @@ -131,9 +131,12 @@ await step.run('charge', () => Prevent duplicate runs: ```ts -await job.trigger({ id: '123' }, { - idempotencyKey: 'request-abc' -}) +await job.trigger( + { id: '123' }, + { + idempotencyKey: 'request-abc', + }, +) // Same key returns existing run ``` @@ -142,12 +145,39 @@ await job.trigger({ id: '123' }, { Limit concurrent execution: ```ts -await job.trigger({ userId: '123' }, { - concurrencyKey: 'user_123' -}) +await job.trigger( + { userId: '123' }, + { + concurrencyKey: 'user_123', + }, +) // Only one job per key runs at a time ``` +### Labels + +Attach metadata for filtering (e.g., multi-tenancy): + +```ts +await job.trigger( + { userId: '123' }, + { + labels: { organizationId: 'org_123', env: 'prod' }, + }, +) + +// Filter runs by labels +const runs = await durably.getRuns({ + labels: { organizationId: 'org_123' }, +}) +``` + +Labels are immutable key-value pairs (`Record`) set at trigger time. All run-scoped events include labels, enabling SSE filtering: + +``` +GET /runs/subscribe?label.organizationId=org_123 +``` + ## Events Monitor job execution: diff --git a/website/public/llms.txt b/website/public/llms.txt index 0baa8a2b..33673dd3 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -100,6 +100,12 @@ await syncUsers.trigger( // With concurrency key (serializes execution) await syncUsers.trigger({ orgId: 'org_123' }, { concurrencyKey: 'org_123' }) + +// With labels (for filtering/multi-tenancy) +await syncUsers.trigger( + { orgId: 'org_123' }, + { labels: { organizationId: 'org_123', env: 'prod' } }, +) ``` ## Step Context API @@ -170,6 +176,11 @@ const runs = await durably.getRuns({ offset: 0, }) +// Filter by labels (multi-tenancy) +const orgRuns = await durably.getRuns({ + labels: { organizationId: 'org_123' }, +}) + // Typed getRuns with generic parameter type MyRun = Run & { payload: { userId: string } @@ -292,6 +303,13 @@ app.post('/api/durably/cancel', (req) => handler.cancel(req)) app.delete('/api/durably/run', (req) => handler.delete(req)) ``` +**Label filtering via query params:** + +``` +GET /runs?label.organizationId=org_123 +GET /runs/subscribe?label.organizationId=org_123&label.env=prod +``` + **Handler Interface:** ```ts @@ -316,6 +334,7 @@ interface TriggerRequest { input: Record idempotencyKey?: string concurrencyKey?: string + labels?: Record } interface TriggerResponse { @@ -414,6 +433,7 @@ interface Run { jobName: string status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' payload: unknown + labels: Record output?: TOutput error?: string progress?: { current: number; total?: number; message?: string } @@ -436,8 +456,17 @@ interface JobHandle { interface TriggerOptions { idempotencyKey?: string concurrencyKey?: string + labels?: Record timeout?: number } + +interface RunFilter { + status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + jobName?: string + labels?: Record + limit?: number + offset?: number +} ``` ## License From 70fa6eaad5224988b72bcaa4b5fb5a93d31590df Mon Sep 17 00:00:00 2001 From: coji Date: Wed, 4 Mar 2026 21:31:06 +0900 Subject: [PATCH 3/6] chore: remove openspec, add doc-check skill - Remove openspec/ directory and all references (AGENTS.md, CLAUDE.md, settings, release-check skill) - Add .claude/skills/doc-check/ for documentation update checklists - Remove stale docs/spec.md references from CLAUDE.md and release-check Co-Authored-By: Claude Opus 4.6 --- .claude/skills/doc-check/SKILL.md | 102 ++++ .claude/skills/release-check/SKILL.md | 13 +- AGENTS.md | 18 - CLAUDE.md | 22 +- openspec/AGENTS.md | 456 ------------------ .../changes/add-human-in-the-loop/proposal.md | 29 -- .../add-human-in-the-loop/specs/core/spec.md | 144 ------ .../add-human-in-the-loop/specs/react/spec.md | 81 ---- .../changes/add-human-in-the-loop/tasks.md | 57 --- .../changes/add-job-versioning/proposal.md | 22 - .../add-job-versioning/specs/core/spec.md | 66 --- openspec/changes/add-job-versioning/tasks.md | 38 -- openspec/changes/add-postgresql/proposal.md | 21 - .../changes/add-postgresql/specs/core/spec.md | 71 --- openspec/changes/add-postgresql/tasks.md | 38 -- openspec/changes/add-streaming-v2/proposal.md | 22 - .../add-streaming-v2/specs/core/spec.md | 90 ---- openspec/changes/add-streaming-v2/tasks.md | 23 - openspec/project.md | 126 ----- openspec/specs/core/spec.md | 363 -------------- openspec/specs/react/spec.md | 108 ----- 21 files changed, 110 insertions(+), 1800 deletions(-) create mode 100644 .claude/skills/doc-check/SKILL.md delete mode 100644 AGENTS.md delete mode 100644 openspec/AGENTS.md delete mode 100644 openspec/changes/add-human-in-the-loop/proposal.md delete mode 100644 openspec/changes/add-human-in-the-loop/specs/core/spec.md delete mode 100644 openspec/changes/add-human-in-the-loop/specs/react/spec.md delete mode 100644 openspec/changes/add-human-in-the-loop/tasks.md delete mode 100644 openspec/changes/add-job-versioning/proposal.md delete mode 100644 openspec/changes/add-job-versioning/specs/core/spec.md delete mode 100644 openspec/changes/add-job-versioning/tasks.md delete mode 100644 openspec/changes/add-postgresql/proposal.md delete mode 100644 openspec/changes/add-postgresql/specs/core/spec.md delete mode 100644 openspec/changes/add-postgresql/tasks.md delete mode 100644 openspec/changes/add-streaming-v2/proposal.md delete mode 100644 openspec/changes/add-streaming-v2/specs/core/spec.md delete mode 100644 openspec/changes/add-streaming-v2/tasks.md delete mode 100644 openspec/project.md delete mode 100644 openspec/specs/core/spec.md delete mode 100644 openspec/specs/react/spec.md diff --git a/.claude/skills/doc-check/SKILL.md b/.claude/skills/doc-check/SKILL.md new file mode 100644 index 00000000..bc35c5a7 --- /dev/null +++ b/.claude/skills/doc-check/SKILL.md @@ -0,0 +1,102 @@ +--- +name: doc-check +description: Documentation update checklist. Run after API changes to find documentation that needs updating. Use for doc check, documentation review, docs update, API change docs. +allowed-tools: + - Read + - Grep + - Glob + - Bash(pnpm:*) + - Bash(node:*) + - Bash(git:*) +--- + +# Documentation Update Checklist + +After any API change, verify all documentation is in sync. This checklist is ordered by priority. + +## How to Use + +1. Identify what changed (new field, new method, changed signature, etc.) +2. Walk through each section below +3. Check only items relevant to the change scope (core, react, or both) +4. Mark items as done or N/A + +## 1. Package LLM Docs (bundled in npm) + +These are the primary references for AI coding agents. + +- [ ] `packages/durably/docs/llms.md` — Core API docs +- [ ] `packages/durably-react/docs/llms.md` — React hooks docs + +## 2. Website API Reference + +### Core API + +| File | Content | +| ------------------------------- | ------------------------------------------------------ | +| `website/api/index.md` | Quick reference / cheat sheet (covers ALL APIs) | +| `website/api/create-durably.md` | `createDurably()`, instance methods, types | +| `website/api/define-job.md` | `defineJob()`, job config | +| `website/api/step.md` | Step context (`step.run`, `step.progress`, `step.log`) | +| `website/api/events.md` | Event types and their fields | +| `website/api/http-handler.md` | `createDurablyHandler()`, request/response types | + +### React API + +| File | Content | +| -------------------------------------- | ---------------------------------------------- | +| `website/api/durably-react/index.md` | React hooks overview | +| `website/api/durably-react/browser.md` | Browser-mode hooks (`useJob`, `useRuns`, etc.) | +| `website/api/durably-react/client.md` | Client-mode hooks (server-connected) | +| `website/api/durably-react/types.md` | Shared type definitions | + +### Guides (check if examples use changed API) + +| File | Content | +| ---------------------------------- | ------------------------- | +| `website/guide/concepts.md` | Core concepts explanation | +| `website/guide/getting-started.md` | Getting started tutorial | +| `website/guide/csv-import.md` | CSV import example | +| `website/guide/background-sync.md` | Background sync example | +| `website/guide/offline-app.md` | Offline app example | + +## 3. Generated Files + +These are derived from package docs and must be regenerated: + +```bash +pnpm --filter durably-website generate:llms +``` + +- [ ] `website/public/llms.txt` — Concatenation of core + react `llms.md` + +## 4. Scope Guide + +Use this table to quickly determine which docs to check based on what changed: + +| Change Type | Docs to Check | +| -------------------------------- | ------------------------------------------------------------------------------------------ | +| New field on `Run` / `RunFilter` | llms.md (core), create-durably.md, index.md, http-handler.md, react browser.md + client.md | +| New event field | llms.md (core), events.md, index.md | +| New step method | llms.md (core), step.md, index.md | +| New trigger option | llms.md (core), index.md, http-handler.md, create-durably.md | +| React hook change | llms.md (react), browser.md, client.md, index.md (react section) | +| HTTP handler change | llms.md (core), http-handler.md, client.md | +| New config option | llms.md (core), create-durably.md, index.md | + +## 5. Common Oversights + +- **`website/api/index.md`** is a cheat sheet — it duplicates key info from other pages and is easy to forget +- **Event field additions** must be added to every event type comment block in `events.md` +- **Browser and Client mode** hooks often have parallel options tables — update both +- **Type definitions** in `website/api/durably-react/types.md` may need new type exports +- **`website/public/llms.txt`** is generated — don't edit directly, regenerate instead +- **Code examples** in guides may use the changed API — grep for the symbol name in `website/guide/` + +## 6. Verification + +```bash +pnpm format:fix +pnpm --filter durably-website generate:llms +pnpm validate +``` diff --git a/.claude/skills/release-check/SKILL.md b/.claude/skills/release-check/SKILL.md index 396abeb8..e5d097fd 100644 --- a/.claude/skills/release-check/SKILL.md +++ b/.claude/skills/release-check/SKILL.md @@ -32,12 +32,10 @@ Verify package integrity for API changes and spec updates. ### Core - [ ] `packages/durably/docs/llms.md` - LLM docs (bundled in npm) -- [ ] `docs/spec.md` - Core specification ### React - [ ] `packages/durably-react/docs/llms.md` - LLM docs (bundled in npm) -- [ ] `docs/spec-react.md` - React specification - [ ] `website/api/durably-react/index.md` - Overview - [ ] `website/api/durably-react/browser.md` - Browser hooks - [ ] `website/api/durably-react/client.md` - Client hooks @@ -95,12 +93,13 @@ Check `git status` for uncommitted changes. When React hooks should provide the same API in both Browser and Client modes: -| File | Mode | -| --------------------- | ------------- | -| `hooks/use-job.ts` | Browser mode | -| `client/use-job.ts` | Client mode | +| File | Mode | +| ------------------- | ------------ | +| `hooks/use-job.ts` | Browser mode | +| `client/use-job.ts` | Client mode | Ensure consistency in: + - Interface definitions - Return values - Options @@ -108,6 +107,7 @@ Ensure consistency in: ### Code Examples in Documentation Verify code examples in docs match actual API: + - Return value properties - Option parameters - Type definitions @@ -125,5 +125,6 @@ pnpm typecheck # Includes all examples ``` Key components to check: + - `dashboard.tsx` - useRuns, useRunActions - `*-progress.tsx` - useJob return values (status booleans) diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 06696994..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,18 +0,0 @@ - -# OpenSpec Instructions - -These instructions are for AI assistants working in this project. - -Always open `@/openspec/AGENTS.md` when the request: -- Mentions planning or proposals (words like proposal, spec, change, plan) -- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work -- Sounds ambiguous and you need the authoritative spec before coding - -Use `@/openspec/AGENTS.md` to learn: -- How to create and apply change proposals -- Spec format and conventions -- Project structure and guidelines - -Keep this managed block so 'openspec update' can refresh the instructions. - - \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 8101f7fd..df8da428 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,22 +1,3 @@ - -# OpenSpec Instructions - -These instructions are for AI assistants working in this project. - -Always open `@/openspec/AGENTS.md` when the request: -- Mentions planning or proposals (words like proposal, spec, change, plan) -- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work -- Sounds ambiguous and you need the authoritative spec before coding - -Use `@/openspec/AGENTS.md` to learn: -- How to create and apply change proposals -- Spec format and conventions -- Project structure and guidelines - -Keep this managed block so 'openspec update' can refresh the instructions. - - - # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. @@ -27,13 +8,12 @@ Durably is a step-oriented batch execution framework for Node.js and browsers. I ## Documentation -- `docs/spec.md` - Core specification -- `docs/spec-streaming.md` - Streaming extension for AI Agent workflows - `packages/durably/docs/llms.md` - LLM/AI agent documentation (bundled in npm package) ### LLM Documentation Maintenance When API changes are made, update `packages/durably/docs/llms.md` to keep it in sync. This file is: + - Bundled in the npm package for coding agents to read from `node_modules` - Symlinked to `website/public/llms.txt` for web access diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md deleted file mode 100644 index 6c1703ee..00000000 --- a/openspec/AGENTS.md +++ /dev/null @@ -1,456 +0,0 @@ -# OpenSpec Instructions - -Instructions for AI coding assistants using OpenSpec for spec-driven development. - -## TL;DR Quick Checklist - -- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) -- Decide scope: new capability vs modify existing capability -- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) -- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability -- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement -- Validate: `openspec validate [change-id] --strict --no-interactive` and fix issues -- Request approval: Do not start implementation until proposal is approved - -## Three-Stage Workflow - -### Stage 1: Creating Changes -Create proposal when you need to: -- Add features or functionality -- Make breaking changes (API, schema) -- Change architecture or patterns -- Optimize performance (changes behavior) -- Update security patterns - -Triggers (examples): -- "Help me create a change proposal" -- "Help me plan a change" -- "Help me create a proposal" -- "I want to create a spec proposal" -- "I want to create a spec" - -Loose matching guidance: -- Contains one of: `proposal`, `change`, `spec` -- With one of: `create`, `plan`, `make`, `start`, `help` - -Skip proposal for: -- Bug fixes (restore intended behavior) -- Typos, formatting, comments -- Dependency updates (non-breaking) -- Configuration changes -- Tests for existing behavior - -**Workflow** -1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. -2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. -3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. -4. Run `openspec validate --strict --no-interactive` and resolve any issues before sharing the proposal. - -### Stage 2: Implementing Changes -Track these steps as TODOs and complete them one by one. -1. **Read proposal.md** - Understand what's being built -2. **Read design.md** (if exists) - Review technical decisions -3. **Read tasks.md** - Get implementation checklist -4. **Implement tasks sequentially** - Complete in order -5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses -6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality -7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved - -### Stage 3: Archiving Changes -After deployment, create separate PR to: -- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` -- Update `specs/` if capabilities changed -- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) -- Run `openspec validate --strict --no-interactive` to confirm the archived change passes checks - -## Before Any Task - -**Context Checklist:** -- [ ] Read relevant specs in `specs/[capability]/spec.md` -- [ ] Check pending changes in `changes/` for conflicts -- [ ] Read `openspec/project.md` for conventions -- [ ] Run `openspec list` to see active changes -- [ ] Run `openspec list --specs` to see existing capabilities - -**Before Creating Specs:** -- Always check if capability already exists -- Prefer modifying existing specs over creating duplicates -- Use `openspec show [spec]` to review current state -- If request is ambiguous, ask 1–2 clarifying questions before scaffolding - -### Search Guidance -- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) -- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) -- Show details: - - Spec: `openspec show --type spec` (use `--json` for filters) - - Change: `openspec show --json --deltas-only` -- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` - -## Quick Start - -### CLI Commands - -```bash -# Essential commands -openspec list # List active changes -openspec list --specs # List specifications -openspec show [item] # Display change or spec -openspec validate [item] # Validate changes or specs -openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) - -# Project management -openspec init [path] # Initialize OpenSpec -openspec update [path] # Update instruction files - -# Interactive mode -openspec show # Prompts for selection -openspec validate # Bulk validation mode - -# Debugging -openspec show [change] --json --deltas-only -openspec validate [change] --strict --no-interactive -``` - -### Command Flags - -- `--json` - Machine-readable output -- `--type change|spec` - Disambiguate items -- `--strict` - Comprehensive validation -- `--no-interactive` - Disable prompts -- `--skip-specs` - Archive without spec updates -- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) - -## Directory Structure - -``` -openspec/ -├── project.md # Project conventions -├── specs/ # Current truth - what IS built -│ └── [capability]/ # Single focused capability -│ ├── spec.md # Requirements and scenarios -│ └── design.md # Technical patterns -├── changes/ # Proposals - what SHOULD change -│ ├── [change-name]/ -│ │ ├── proposal.md # Why, what, impact -│ │ ├── tasks.md # Implementation checklist -│ │ ├── design.md # Technical decisions (optional; see criteria) -│ │ └── specs/ # Delta changes -│ │ └── [capability]/ -│ │ └── spec.md # ADDED/MODIFIED/REMOVED -│ └── archive/ # Completed changes -``` - -## Creating Change Proposals - -### Decision Tree - -``` -New request? -├─ Bug fix restoring spec behavior? → Fix directly -├─ Typo/format/comment? → Fix directly -├─ New feature/capability? → Create proposal -├─ Breaking change? → Create proposal -├─ Architecture change? → Create proposal -└─ Unclear? → Create proposal (safer) -``` - -### Proposal Structure - -1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) - -2. **Write proposal.md:** -```markdown -# Change: [Brief description of change] - -## Why -[1-2 sentences on problem/opportunity] - -## What Changes -- [Bullet list of changes] -- [Mark breaking changes with **BREAKING**] - -## Impact -- Affected specs: [list capabilities] -- Affected code: [key files/systems] -``` - -3. **Create spec deltas:** `specs/[capability]/spec.md` -```markdown -## ADDED Requirements -### Requirement: New Feature -The system SHALL provide... - -#### Scenario: Success case -- **WHEN** user performs action -- **THEN** expected result - -## MODIFIED Requirements -### Requirement: Existing Feature -[Complete modified requirement] - -## REMOVED Requirements -### Requirement: Old Feature -**Reason**: [Why removing] -**Migration**: [How to handle] -``` -If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. - -4. **Create tasks.md:** -```markdown -## 1. Implementation -- [ ] 1.1 Create database schema -- [ ] 1.2 Implement API endpoint -- [ ] 1.3 Add frontend component -- [ ] 1.4 Write tests -``` - -5. **Create design.md when needed:** -Create `design.md` if any of the following apply; otherwise omit it: -- Cross-cutting change (multiple services/modules) or a new architectural pattern -- New external dependency or significant data model changes -- Security, performance, or migration complexity -- Ambiguity that benefits from technical decisions before coding - -Minimal `design.md` skeleton: -```markdown -## Context -[Background, constraints, stakeholders] - -## Goals / Non-Goals -- Goals: [...] -- Non-Goals: [...] - -## Decisions -- Decision: [What and why] -- Alternatives considered: [Options + rationale] - -## Risks / Trade-offs -- [Risk] → Mitigation - -## Migration Plan -[Steps, rollback] - -## Open Questions -- [...] -``` - -## Spec File Format - -### Critical: Scenario Formatting - -**CORRECT** (use #### headers): -```markdown -#### Scenario: User login success -- **WHEN** valid credentials provided -- **THEN** return JWT token -``` - -**WRONG** (don't use bullets or bold): -```markdown -- **Scenario: User login** ❌ -**Scenario**: User login ❌ -### Scenario: User login ❌ -``` - -Every requirement MUST have at least one scenario. - -### Requirement Wording -- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) - -### Delta Operations - -- `## ADDED Requirements` - New capabilities -- `## MODIFIED Requirements` - Changed behavior -- `## REMOVED Requirements` - Deprecated features -- `## RENAMED Requirements` - Name changes - -Headers matched with `trim(header)` - whitespace ignored. - -#### When to use ADDED vs MODIFIED -- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. -- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. -- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. - -Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. - -Authoring a MODIFIED requirement correctly: -1) Locate the existing requirement in `openspec/specs//spec.md`. -2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). -3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. -4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. - -Example for RENAMED: -```markdown -## RENAMED Requirements -- FROM: `### Requirement: Login` -- TO: `### Requirement: User Authentication` -``` - -## Troubleshooting - -### Common Errors - -**"Change must have at least one delta"** -- Check `changes/[name]/specs/` exists with .md files -- Verify files have operation prefixes (## ADDED Requirements) - -**"Requirement must have at least one scenario"** -- Check scenarios use `#### Scenario:` format (4 hashtags) -- Don't use bullet points or bold for scenario headers - -**Silent scenario parsing failures** -- Exact format required: `#### Scenario: Name` -- Debug with: `openspec show [change] --json --deltas-only` - -### Validation Tips - -```bash -# Always use strict mode for comprehensive checks -openspec validate [change] --strict --no-interactive - -# Debug delta parsing -openspec show [change] --json | jq '.deltas' - -# Check specific requirement -openspec show [spec] --json -r 1 -``` - -## Happy Path Script - -```bash -# 1) Explore current state -openspec spec list --long -openspec list -# Optional full-text search: -# rg -n "Requirement:|Scenario:" openspec/specs -# rg -n "^#|Requirement:" openspec/changes - -# 2) Choose change id and scaffold -CHANGE=add-two-factor-auth -mkdir -p openspec/changes/$CHANGE/{specs/auth} -printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md -printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md - -# 3) Add deltas (example) -cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' -## ADDED Requirements -### Requirement: Two-Factor Authentication -Users MUST provide a second factor during login. - -#### Scenario: OTP required -- **WHEN** valid credentials are provided -- **THEN** an OTP challenge is required -EOF - -# 4) Validate -openspec validate $CHANGE --strict --no-interactive -``` - -## Multi-Capability Example - -``` -openspec/changes/add-2fa-notify/ -├── proposal.md -├── tasks.md -└── specs/ - ├── auth/ - │ └── spec.md # ADDED: Two-Factor Authentication - └── notifications/ - └── spec.md # ADDED: OTP email notification -``` - -auth/spec.md -```markdown -## ADDED Requirements -### Requirement: Two-Factor Authentication -... -``` - -notifications/spec.md -```markdown -## ADDED Requirements -### Requirement: OTP Email Notification -... -``` - -## Best Practices - -### Simplicity First -- Default to <100 lines of new code -- Single-file implementations until proven insufficient -- Avoid frameworks without clear justification -- Choose boring, proven patterns - -### Complexity Triggers -Only add complexity with: -- Performance data showing current solution too slow -- Concrete scale requirements (>1000 users, >100MB data) -- Multiple proven use cases requiring abstraction - -### Clear References -- Use `file.ts:42` format for code locations -- Reference specs as `specs/auth/spec.md` -- Link related changes and PRs - -### Capability Naming -- Use verb-noun: `user-auth`, `payment-capture` -- Single purpose per capability -- 10-minute understandability rule -- Split if description needs "AND" - -### Change ID Naming -- Use kebab-case, short and descriptive: `add-two-factor-auth` -- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` -- Ensure uniqueness; if taken, append `-2`, `-3`, etc. - -## Tool Selection Guide - -| Task | Tool | Why | -|------|------|-----| -| Find files by pattern | Glob | Fast pattern matching | -| Search code content | Grep | Optimized regex search | -| Read specific files | Read | Direct file access | -| Explore unknown scope | Task | Multi-step investigation | - -## Error Recovery - -### Change Conflicts -1. Run `openspec list` to see active changes -2. Check for overlapping specs -3. Coordinate with change owners -4. Consider combining proposals - -### Validation Failures -1. Run with `--strict` flag -2. Check JSON output for details -3. Verify spec file format -4. Ensure scenarios properly formatted - -### Missing Context -1. Read project.md first -2. Check related specs -3. Review recent archives -4. Ask for clarification - -## Quick Reference - -### Stage Indicators -- `changes/` - Proposed, not yet built -- `specs/` - Built and deployed -- `archive/` - Completed changes - -### File Purposes -- `proposal.md` - Why and what -- `tasks.md` - Implementation steps -- `design.md` - Technical decisions -- `spec.md` - Requirements and behavior - -### CLI Essentials -```bash -openspec list # What's in progress? -openspec show [item] # View details -openspec validate --strict --no-interactive # Is it correct? -openspec archive [--yes|-y] # Mark complete (add --yes for automation) -``` - -Remember: Specs are truth. Changes are proposals. Keep them in sync. diff --git a/openspec/changes/add-human-in-the-loop/proposal.md b/openspec/changes/add-human-in-the-loop/proposal.md deleted file mode 100644 index 8ce14f23..00000000 --- a/openspec/changes/add-human-in-the-loop/proposal.md +++ /dev/null @@ -1,29 +0,0 @@ -# Change: Human-in-the-Loop (HITL) 再設計(シンプル版) - -## Why - -AI エージェントワークフローにおいて、処理の途中で人間の入力を待つ機能は必須。現状案は token/権限/HTTP の説明が複雑で、React からの利用を最短にする設計へ整理し直す必要がある。 - -## What Changes - -- **BREAKING**: 再開に token を使わず、`runId` で再開する単純モデルに変更 -- `ctx.human({ message, schema?, timeoutMs? })` API を追加(`summary` ではなく `message`) -- `waiting_human` ステータスの追加: Run が人間の入力待ち状態を表現 -- `durably.resume(runId, payload)` API を追加: 外部から Run を再開 -- HTTP API 追加/変更: `POST /resume` を `runId` 形式に統一 -- DB スキーマ拡張: `wait_message`, `wait_schema`, `wait_deadline_at` を追加(tokenは不要) -- **Security**: 認証方式はアプリ側に委譲しつつ、`/runs` と `/resume` は必ず認可を通す前提を明記 - -## Impact - -- Affected specs: `core`, `react` -- Affected code: - - `packages/durably/src/context.ts` - `ctx.human()` 追加 - - `packages/durably/src/durably.ts` - `resume()` 追加 - - `packages/durably/src/schema.ts` - `waiting_human` ステータス追加 - - `packages/durably/src/storage.ts` - wait 関連フィールド追加 - - `packages/durably/src/server.ts` - `POST /resume` ハンドラ追加 - - `packages/durably/src/worker.ts` - `WaitHumanSignal` ハンドリング - - `packages/durably/src/migrations.ts` - version 2 マイグレーション - - `packages/durably-react/*` - HITL React フックと型の更新 - - OpenSpec `specs/` および `changes/` の仕様更新 diff --git a/openspec/changes/add-human-in-the-loop/specs/core/spec.md b/openspec/changes/add-human-in-the-loop/specs/core/spec.md deleted file mode 100644 index afd6b3e9..00000000 --- a/openspec/changes/add-human-in-the-loop/specs/core/spec.md +++ /dev/null @@ -1,144 +0,0 @@ -# Core: HITL Extension (Simple) - -## ADDED Requirements - -### Requirement: Human-in-the-Loop Wait - -The system SHALL allow Steps to wait for human input in the simplest possible form. - -- The system MUST provide `ctx.human(options)` to transition Run to `waiting_human` state -- The `message` parameter MUST specify the human-readable request text -- The `schema` parameter MAY specify the expected input format (optional) -- The `timeoutMs` parameter SHALL specify the wait deadline (default 24 hours) -- The system MUST persist `wait_message`, `wait_schema`, and `wait_deadline_at` on the Run - -#### Scenario: Wait for human input - -- **WHEN** `const decision = await ctx.human({ message: "Please confirm" })` is called -- **THEN** Run transitions to `waiting_human` state -- **AND** `wait_message` is stored on the Run -- **AND** `run:wait_human` event is emitted - -#### Scenario: Human wait with timeout - -- **GIVEN** waiting with `ctx.human({ timeoutMs: 3600000 })` -- **WHEN** 1 hour passes and deadline expires -- **THEN** Run transitions to `failed` state -- **AND** error reason is `human_timeout` - ---- - -### Requirement: Resume from Human Wait - -The system SHALL allow external callers to resume a `waiting_human` Run by `runId`. - -- The system MUST provide `durably.resume(runId, payload)` -- The `payload` SHALL include the human input -- The `payload` SHOULD include `decision` (`approved` | `rejected` | `edited`) as a common convention -- The system MUST reject resuming a Run that is not in `waiting_human` state - - The system MUST return `404` when `runId` does not exist - - The system MUST return `410` when `wait_deadline_at` has expired - -#### Scenario: Resume with input - -- **GIVEN** Run is in `waiting_human` state -- **WHEN** `durably.resume(runId, { decision: 'approved' })` is called -- **THEN** Run transitions back to `running` state -- **AND** `ctx.human()` returns the payload and continues -- **AND** `run:resume` event is emitted - -#### Scenario: Resume invalid state - -- **GIVEN** Run is not in `waiting_human` state -- **WHEN** `resume()` is called -- **THEN** error is raised (HTTP 409) - -#### Scenario: Resume invalid runId - -- **WHEN** `resume()` is called with non-existent `runId` -- **THEN** error is raised (HTTP 404) - -#### Scenario: Resume expired runId - -- **GIVEN** `wait_deadline_at` has passed -- **WHEN** `resume()` is called -- **THEN** error is raised (HTTP 410) - ---- - -### Requirement: Human Step Replay - -Completed human steps SHALL be skipped when Run is re-executed. - -- `ctx.human()` MUST return the saved result immediately if a completed human step exists -- If no completed human step exists, the system SHALL create a new `waiting_human` state - -#### Scenario: Replay completed human step - -- **GIVEN** `ctx.human()` was previously completed -- **WHEN** Run resumes and reaches the same position -- **THEN** saved `human_payload` is returned immediately -- **AND** Run does NOT enter `waiting_human` state - ---- - -### Requirement: HTTP Resume Endpoint - -The system SHALL provide HTTP API to resume `waiting_human` Runs. - -- The system MUST provide `POST /resume` endpoint -- On success, the system SHALL return `{ runId, success: true }` -- The system MUST support filtering `waiting_human` runs by job name via `GET /runs?status=waiting_human&jobName=...` - -#### Scenario: POST /resume success - -- **GIVEN** Run is in `waiting_human` state -- **WHEN** `POST /resume` is called with `{ runId, payload }` -- **THEN** `200 OK` with `{ runId, success: true }` is returned - -#### Scenario: GET /runs for waiting_human - -- **GIVEN** Run is in `waiting_human` state -- **WHEN** `GET /runs?status=waiting_human` is called -- **THEN** response includes `wait_message`, `wait_schema`, and `wait_deadline_at` - -#### Scenario: GET /runs filtered by job name - -- **GIVEN** multiple Runs in `waiting_human` state across different jobs -- **WHEN** `GET /runs?status=waiting_human&jobName=import-csv` is called -- **THEN** only Runs matching `jobName=import-csv` are returned - ---- - -## MODIFIED Requirements - -### Requirement: Run Status - -Run SHALL have `pending`, `running`, `completed`, `failed`, `cancelled`, and `waiting_human` states. - -- `pending`: waiting for execution -- `running`: currently executing -- `completed`: successfully completed -- `failed`: execution failed -- `cancelled`: manually cancelled -- `waiting_human`: waiting for human input - -#### Scenario: Normal run completion - -- **GIVEN** Run is in `pending` state -- **WHEN** Worker picks up and executes the Run -- **THEN** Run transitions `running` → `completed` - -#### Scenario: Run failure - -- **GIVEN** Run is in `running` state -- **WHEN** an exception occurs in a step -- **THEN** Run transitions to `failed` state -- **AND** error message is recorded - -#### Scenario: Run waits for human - -- **GIVEN** Run is in `running` state -- **WHEN** `ctx.human()` is called -- **THEN** Run transitions to `waiting_human` state -- **AND** Worker moves on to process next Run diff --git a/openspec/changes/add-human-in-the-loop/specs/react/spec.md b/openspec/changes/add-human-in-the-loop/specs/react/spec.md deleted file mode 100644 index 2fb337da..00000000 --- a/openspec/changes/add-human-in-the-loop/specs/react/spec.md +++ /dev/null @@ -1,81 +0,0 @@ -# React: HITL Hooks - -## ADDED Requirements - -### Requirement: Human Waits Hook - -The React client SHALL provide a single hook for human waits. - -- The system MUST provide `useHumanWaits()` in `@coji/durably-react` (browser mode) -- The system MUST provide `useHumanWaits({ api })` in `@coji/durably-react/client` (server mode) -- The hook SHOULD accept `jobName` to filter waits by job -- The hook SHOULD accept `payloadSchema` to enable type inference and client-side validation -- The hook MUST return `{ waits, isLoading, reload, respond }` -- `respond(runId, payload)` MUST resume the Run with the given payload - -#### Scenario: Client mode usage - -- **WHEN** `useHumanWaits({ api: '/api/durably' })` is called -- **THEN** the hook returns `waits`, `isLoading`, `reload`, and `respond` -- **AND** `respond(runId, payload)` resumes the Run via `POST /resume` - -#### Scenario: Browser mode usage - -- **WHEN** `useHumanWaits()` is called without `api` -- **THEN** the hook returns `waits`, `isLoading`, `reload`, and `respond` -- **AND** `respond(runId, payload)` resumes the Run via `durably.resume(runId, payload)` - -#### Scenario: Filter waits by job name - -- **GIVEN** multiple waits across different jobs -- **WHEN** `useHumanWaits({ api, jobName: 'import-csv' })` is called -- **THEN** only waits for `import-csv` are returned - ---- - -### Requirement: Typed Human Payload - -The React client SHALL support type-safe human payloads. - -- The hook MUST be generic: `useHumanWaits(options?)` -- When `payloadSchema` is provided, the hook SHOULD infer `TPayload` from the schema -- `respond(runId, payload)` MUST accept `TPayload` - -#### Scenario: Typed respond payload - -- **GIVEN** `useHumanWaits({ api, payloadSchema: z.object({ decision: z.enum(['approved', 'rejected']) }) })` -- **WHEN** `respond(runId, { decision: 'approved' })` is called -- **THEN** the payload is type-checked by TypeScript -- **AND** invalid payloads fail type-checking - ---- - -### Requirement: Human Waits Hook Factory - -The React client SHALL provide a factory for typed human waits hooks. - -- The system MUST provide `createHumanWaitsHook({ api?, jobName?, payloadSchema? })` -- The factory MUST return a hook that yields `{ waits, isLoading, reload, respond }` -- The returned hook MUST preserve the inferred `TPayload` from `payloadSchema` - -#### Scenario: Create typed waits hook - -- **GIVEN** `createHumanWaitsHook({ api, jobName: 'invoice', payloadSchema })` -- **WHEN** the returned hook is used -- **THEN** `respond(runId, payload)` is type-checked against `payloadSchema` - ---- - -### Requirement: WaitingRun Shape - -The React client SHALL expose a minimal WaitingRun shape for UI rendering. - -- A WaitingRun MUST include `id`, `wait_message`, and `wait_deadline_at` -- A WaitingRun SHOULD include `wait_schema` when available - -#### Scenario: Waiting run fields - -- **GIVEN** a Run in `waiting_human` state -- **WHEN** it is returned by `useHumanWaits()` -- **THEN** `wait_message` and `wait_deadline_at` are present -- **AND** `wait_schema` is present if stored on the Run diff --git a/openspec/changes/add-human-in-the-loop/tasks.md b/openspec/changes/add-human-in-the-loop/tasks.md deleted file mode 100644 index 92659539..00000000 --- a/openspec/changes/add-human-in-the-loop/tasks.md +++ /dev/null @@ -1,57 +0,0 @@ -# Tasks: Human-in-the-Loop (HITL) 実装 - -## 1. DB スキーマ拡張 - -- [ ] 1.1 `RunsTable` に `wait_message`, `wait_schema`, `wait_deadline_at` を追加 (`schema.ts`) -- [ ] 1.2 `StepsTable` に `step_type`, `human_payload` を追加 -- [ ] 1.3 マイグレーション version 2 を追加 (`migrations.ts`) -- [ ] 1.4 `Run` インターフェースに wait 関連フィールドを追加 (`storage.ts`) - -## 2. Core API 実装 - -- [ ] 2.1 `WaitHumanSignal` エラークラスを追加 (`errors.ts`) -- [ ] 2.2 `ctx.human()` メソッドを実装 (`context.ts`) -- [ ] 2.3 `durably.resume()` メソッドを実装 (`durably.ts`) -- [ ] 2.4 `waiting_human` ステータスを型定義に追加 - -## 3. Worker 変更 - -- [ ] 3.1 `WaitHumanSignal` を catch して `waiting_human` 状態を維持 -- [ ] 3.2 期限切れ Run の回収ロジックを追加 -- [ ] 3.3 replay 時の `ctx.human()` 挙動を実装(既存 human step があれば即解決) - -## 4. Storage 拡張 - -- [ ] 4.1 `getWaitingRun(runId)` メソッドを追加 -- [ ] 4.2 `getExpiredHumanWaitRuns()` メソッドを追加 -- [ ] 4.3 resume 時の楽観的更新クエリを実装 - -## 5. HTTP API 拡張 - -- [ ] 5.1 `POST /resume` ハンドラを追加 (`server.ts`) -- [ ] 5.2 `GET /runs?status=waiting_human` で `wait_*` を返す -- [ ] 5.3 エラーレスポンス (404, 409, 410) を実装 - -## 6. イベント追加 - -- [ ] 6.1 `run:wait_human` イベントを追加 (`events.ts`) -- [ ] 6.2 `run:resume` イベントを追加 - -## 7. React - -- [ ] 7.1 `useHumanWaits` フックを追加(browser/client 両対応) -- [ ] 7.2 WaitingRun の型と返却フィールドを追加 - -## 8. テスト - -- [ ] 8.1 `ctx.human()` の基本動作テスト -- [ ] 8.2 `resume()` の成功・失敗テスト -- [ ] 8.3 replay 時の挙動テスト -- [ ] 8.4 期限切れ Run の回収テスト -- [ ] 8.5 HTTP API テスト - -## 9. ドキュメント - -- [ ] 9.1 `packages/durably/docs/llms.md` を更新 -- [ ] 9.2 README に HITL セクションを追加 -- [ ] 9.3 HITL の Security Considerations を明記(認可・検証・監査) diff --git a/openspec/changes/add-job-versioning/proposal.md b/openspec/changes/add-job-versioning/proposal.md deleted file mode 100644 index a7b5296b..00000000 --- a/openspec/changes/add-job-versioning/proposal.md +++ /dev/null @@ -1,22 +0,0 @@ -# Change: Job Definition 自動バージョニング - -## Why - -Run 実行中に Job 定義が変更されると(特に HITL では数日待機することもある)、再開時に予期しない動作をする可能性がある。Job 定義の変更を検出し、互換性のない Run の継続を防ぐ仕組みが必要。 - -## What Changes - -- 登録時に Job 定義から `job_hash` を自動生成 -- trigger 時に `job_hash` を Run レコードに保存 -- resume/retry 時に `job_hash` の一致を検証 -- 検証をバイパスする `allowIncompatible` オプションを追加 - -## Impact - -- Affected specs: `core` -- Affected code: - - `packages/durably/src/job.ts` - hash 生成 - - `packages/durably/src/schema.ts` - `job_hash` カラム - - `packages/durably/src/storage.ts` - hash 保存/検証 - - `packages/durably/src/durably.ts` - retry/resume 時のチェック - - `packages/durably/src/migrations.ts` - job_hash マイグレーション diff --git a/openspec/changes/add-job-versioning/specs/core/spec.md b/openspec/changes/add-job-versioning/specs/core/spec.md deleted file mode 100644 index 7d620922..00000000 --- a/openspec/changes/add-job-versioning/specs/core/spec.md +++ /dev/null @@ -1,66 +0,0 @@ -# Core: Job Versioning Extension - -## ADDED Requirements - -### Requirement: Job Hash Generation - -The system SHALL auto-generate a hash from job definition for version tracking. - -- The hash MUST be generated from job name and input/output schemas -- The hash generation MUST be stable (order-independent) -- The hash MUST be computed at job registration time - -#### Scenario: Generate job hash - -- **WHEN** `durably.register({ myJob })` is called -- **THEN** `job_hash` is computed from job definition -- **AND** hash is stored in job registry - ---- - -### Requirement: Job Hash Storage - -The system SHALL store `job_hash` with each Run for compatibility tracking. - -- Run records MUST include `job_hash` from trigger time -- The `job_hash` MUST be immutable once stored - -#### Scenario: Store job hash at trigger - -- **WHEN** `job.trigger(payload)` is called -- **THEN** Run is created with current `job_hash` -- **AND** `job_hash` is persisted in database - ---- - -### Requirement: Job Hash Validation - -The system SHALL validate job hash compatibility when resuming Runs. - -- The system MUST compare current `job_hash` with Run's stored hash -- Mismatched hash SHALL cause `job_version_mismatch` error by default -- The `allowIncompatible` option MUST bypass validation - -#### Scenario: Retry with matching hash - -- **GIVEN** Run's `job_hash` matches current job definition -- **WHEN** `durably.retry(runId)` is called -- **THEN** Run is retried normally - -#### Scenario: Retry with mismatched hash - -- **GIVEN** Job definition has changed since Run was created -- **WHEN** `durably.retry(runId)` is called -- **THEN** error `job_version_mismatch` is raised - -#### Scenario: Retry with allowIncompatible - -- **GIVEN** Job definition has changed since Run was created -- **WHEN** `durably.retry(runId, { allowIncompatible: true })` is called -- **THEN** Run is retried despite hash mismatch - -#### Scenario: Resume HITL with mismatched hash - -- **GIVEN** Run is in `waiting_human` and job definition changed -- **WHEN** `durably.resume(token, payload)` is called -- **THEN** error `job_version_mismatch` is raised (HTTP 412) diff --git a/openspec/changes/add-job-versioning/tasks.md b/openspec/changes/add-job-versioning/tasks.md deleted file mode 100644 index e2d16e0f..00000000 --- a/openspec/changes/add-job-versioning/tasks.md +++ /dev/null @@ -1,38 +0,0 @@ -# Tasks: Job Definition 自動バージョニング実装 - -## 1. Hash 生成 - -- [ ] 1.1 ハッシュアルゴリズムを決定(SHA-256 推奨) -- [ ] 1.2 ハッシュに含める内容を定義(name + input/output JSON Schema) -- [ ] 1.3 `computeJobHash()` 関数を実装 -- [ ] 1.4 ハッシュの安定性を確保(順序非依存) - -## 2. スキーマ変更 - -- [ ] 2.1 `runs` テーブルに `job_hash` カラムを追加 -- [ ] 2.2 `job_hash` のマイグレーションを追加 -- [ ] 2.3 `Run` インターフェースを更新 - -## 3. Storage 統合 - -- [ ] 3.1 trigger 時に `job_hash` を保存 -- [ ] 3.2 Storage に `validateJobHash()` を追加 - -## 4. 検証ロジック - -- [ ] 4.1 `retry()` にハッシュチェックを追加 -- [ ] 4.2 `resume()` にハッシュチェックを追加(HITL 統合) -- [ ] 4.3 `allowIncompatible` オプションを実装 -- [ ] 4.4 適切なエラーコードを返す - -## 5. テスト - -- [ ] 5.1 再起動後のハッシュ安定性テスト -- [ ] 5.2 不一致検出テスト -- [ ] 5.3 `allowIncompatible` バイパステスト - -## Open Questions(実装前に解決が必要) - -- [ ] `run` 関数をどうハッシュする?(文字列シリアライズ?手動タグ?スキップ?) -- [ ] Zod スキーマを安定してシリアライズする方法は? -- [ ] スキーマのみハッシュする?関数本体も含める? diff --git a/openspec/changes/add-postgresql/proposal.md b/openspec/changes/add-postgresql/proposal.md deleted file mode 100644 index 0e72ee82..00000000 --- a/openspec/changes/add-postgresql/proposal.md +++ /dev/null @@ -1,21 +0,0 @@ -# Change: PostgreSQL サポート - -## Why - -SQLite は単一プロセスのデプロイには適しているが、本番環境ではマルチワーカースケーリングと運用ツーリングのために PostgreSQL が必要になることが多い。PostgreSQL dialect サポートを追加することで、適切な Run claiming と concurrency key 保護による水平スケーリングが可能になる。 - -## What Changes - -- Kysely 経由で PostgreSQL dialect サポートを追加 -- `FOR UPDATE SKIP LOCKED` でアトミックな Run claiming を実装 -- concurrency key 保護のための `durably_concurrency_locks` テーブルを追加 -- SQLite の動作は変更なし - -## Impact - -- Affected specs: `core` -- Affected code: - - `packages/durably/src/storage.ts` - PG 固有の claiming ロジック - - `packages/durably/src/schema.ts` - concurrency_locks テーブル - - `packages/durably/src/migrations.ts` - PG マイグレーション - - 新規: `packages/durably/src/pg-storage.ts`(オプション、別ファイル) diff --git a/openspec/changes/add-postgresql/specs/core/spec.md b/openspec/changes/add-postgresql/specs/core/spec.md deleted file mode 100644 index 0f0c2396..00000000 --- a/openspec/changes/add-postgresql/specs/core/spec.md +++ /dev/null @@ -1,71 +0,0 @@ -# Core: PostgreSQL Support Extension - -## ADDED Requirements - -### Requirement: PostgreSQL Dialect Support - -The system SHALL support PostgreSQL as an alternative database dialect. - -- PostgreSQL dialect MUST be usable via Kysely pg dialect -- SQLite behavior MUST remain unchanged -- PostgreSQL support SHALL be marked as experimental initially - -#### Scenario: Create durably with PostgreSQL - -- **GIVEN** PostgreSQL Kysely dialect is configured -- **WHEN** `createDurably({ dialect: pgDialect })` is called -- **THEN** durably instance works with PostgreSQL backend - ---- - -### Requirement: Atomic Run Claiming - -The system SHALL atomically claim Runs to prevent double-execution in multi-worker deployments. - -- PostgreSQL MUST use `FOR UPDATE SKIP LOCKED` for atomic claiming -- SQLite SHALL continue using current claiming logic -- Only one worker MUST be able to claim a specific Run - -#### Scenario: Two workers claim simultaneously - -- **GIVEN** two workers polling for Runs -- **AND** one pending Run exists -- **WHEN** both workers attempt to claim the Run -- **THEN** exactly one worker succeeds -- **AND** the other worker claims nothing - ---- - -### Requirement: Concurrency Key Lock Table - -The system SHALL use a lock table to protect concurrency keys across workers. - -- The system MUST create `durably_concurrency_locks` table for PostgreSQL -- Lock MUST be acquired when Run with `concurrency_key` starts -- Lock MUST be released when Run completes, fails, or is cancelled -- Same `concurrency_key` SHALL NOT run simultaneously - -#### Scenario: Concurrency key mutual exclusion - -- **GIVEN** Run A with `concurrency_key: "org_123"` is running -- **WHEN** Run B with same `concurrency_key` tries to start -- **THEN** Run B remains in `pending` state -- **AND** Run B starts only after Run A completes - ---- - -### Requirement: Stale Run Recovery for PostgreSQL - -The system SHALL recover stale Runs in PostgreSQL environments. - -- Recovery MUST check `heartbeat_at` against threshold -- Recovery MUST release orphaned concurrency locks -- Recovery SHALL run before each claim attempt - -#### Scenario: Recover stale run in multi-worker - -- **GIVEN** Run is `running` but heartbeat expired -- **WHEN** any worker's polling cycle runs -- **THEN** Run is reset to `pending` -- **AND** concurrency lock is released -- **AND** Run becomes claimable by any worker diff --git a/openspec/changes/add-postgresql/tasks.md b/openspec/changes/add-postgresql/tasks.md deleted file mode 100644 index a05bd397..00000000 --- a/openspec/changes/add-postgresql/tasks.md +++ /dev/null @@ -1,38 +0,0 @@ -# Tasks: PostgreSQL サポート実装 - -## 1. スキーマ拡張 - -- [ ] 1.1 `durably_concurrency_locks` テーブルスキーマを追加 -- [ ] 1.2 PG 固有のマイグレーションパスを作成 -- [ ] 1.3 マイグレーションで dialect 検出を処理 - -## 2. アトミックな Run Claiming - -- [ ] 2.1 PG 用の `FOR UPDATE SKIP LOCKED` claiming を実装 -- [ ] 2.2 SQLite の claiming ロジックは変更なし -- [ ] 2.3 claiming を dialect 固有メソッドに抽象化 - -## 3. Concurrency Key 保護 - -- [ ] 3.1 claim 時に `durably_concurrency_locks` でロックを取得 -- [ ] 3.2 Run 完了/失敗/キャンセル時にロックを解放 -- [ ] 3.3 同じ `concurrency_key` の同時実行を防止 - -## 4. Stale Run リカバリ - -- [ ] 4.1 PG 用の `recoverStaleRuns()` を実装 -- [ ] 4.2 recover → claim の順序を保証 -- [ ] 4.3 孤立したロックを解放 - -## 5. テスト - -- [ ] 5.1 2 ワーカー同時 claim テスト -- [ ] 5.2 concurrency key 相互排他テスト -- [ ] 5.3 stale run リカバリテスト -- [ ] 5.4 SQLite テストが引き続きパスすることを確認 - -## 6. ドキュメント - -- [ ] 6.1 PG 接続セットアップを文書化 -- [ ] 6.2 examples/ に PG サンプルを追加 -- [ ] 6.3 「experimental」ステータスを記載 diff --git a/openspec/changes/add-streaming-v2/proposal.md b/openspec/changes/add-streaming-v2/proposal.md deleted file mode 100644 index 092169d8..00000000 --- a/openspec/changes/add-streaming-v2/proposal.md +++ /dev/null @@ -1,22 +0,0 @@ -# Change: Streaming v2 - AI Agent ワークフロー拡張 - -## Why - -AI Agent ワークフローでは、リアルタイムのトークンストリーミング、再接続のためのイベント永続化、長時間実行のチェックポイントが必要。現在の `subscribe()` はメモリ上のイベントストリーミングのみで永続化されない。 - -## What Changes - -- `step.stream()` API: トークンレベルのストリーミング出力 -- イベント永続化: 再接続サポートのため DB に保存 -- `resumeFrom` オプション: `subscribe()` で見逃したイベントを再生 -- `checkpoint()` API: 長時間ステップの復旧(Phase C) - -## Impact - -- Affected specs: `core` -- Affected code: - - `packages/durably/src/context.ts` - `step.stream()` 追加 - - `packages/durably/src/storage.ts` - `events` テーブル操作追加 - - `packages/durably/src/durably.ts` - `subscribe()` 拡張 - - `packages/durably/src/schema.ts` - `events` テーブル定義 - - `packages/durably/src/migrations.ts` - events テーブルマイグレーション diff --git a/openspec/changes/add-streaming-v2/specs/core/spec.md b/openspec/changes/add-streaming-v2/specs/core/spec.md deleted file mode 100644 index 903973bc..00000000 --- a/openspec/changes/add-streaming-v2/specs/core/spec.md +++ /dev/null @@ -1,90 +0,0 @@ -# Core: Streaming v2 Extension - -## ADDED Requirements - -### Requirement: Streaming Step - -The system SHALL provide `step.stream()` for token-level streaming output. - -- The system MUST accept an async function with `emit` callback -- The `emit` callback SHALL send intermediate data without persistence -- The step's return value MUST be persisted as with regular `step.run()` -- Completed streaming steps SHALL be skipped on replay - -#### Scenario: Stream tokens to subscriber - -- **WHEN** `step.stream('generate', async (emit) => { emit({ text: 'hello' }); return 'done' })` is called -- **THEN** `stream` events are emitted with `{ text: 'hello' }` -- **AND** step completes with output `'done'` - -#### Scenario: Streaming step replay - -- **GIVEN** `step.stream('generate', fn)` was previously completed -- **WHEN** Run resumes and reaches the same step -- **THEN** saved output is returned immediately -- **AND** `fn` is NOT re-executed - ---- - -### Requirement: Event Persistence - -The system SHALL persist coarse-grained events for reconnection support. - -- Events `run:*`, `step:*`, `run:progress`, `log:write` MUST be persisted -- `stream` events SHALL NOT be persisted (memory only) -- Each event MUST have a `sequence` number for ordering - -#### Scenario: Persist step events - -- **WHEN** a step completes -- **THEN** `step:complete` event is saved to `events` table -- **AND** event has `sequence` number - -#### Scenario: Stream events not persisted - -- **WHEN** `emit()` is called in `step.stream()` -- **THEN** `stream` event is delivered to subscribers -- **AND** event is NOT saved to database - ---- - -### Requirement: Subscribe with Resume - -The system SHALL support reconnection by replaying persisted events. - -- `subscribe(runId, { resumeFrom })` MUST return events after the given sequence -- Persisted events SHALL be fetched from database first -- Live events SHALL be streamed after replay completes - -#### Scenario: Reconnect and replay events - -- **GIVEN** Run has events with sequence 1-10 -- **WHEN** `subscribe(runId, { resumeFrom: 5 })` is called -- **THEN** events 6-10 are replayed from database -- **AND** new events are streamed in real-time - ---- - -## MODIFIED Requirements - -### Requirement: Run Subscription - -Run subscription SHALL support reconnection with `resumeFrom` option. - -- `subscribe` returns `ReadableStream` -- Stream auto-closes on `run:complete` or `run:fail` -- The `resumeFrom` option SHALL replay events after the given sequence - -#### Scenario: Subscribe to run events - -- **GIVEN** Run is executing -- **WHEN** `durably.subscribe(runId)` is called -- **THEN** event stream is returned -- **AND** `step:start`, `step:complete` events are delivered - -#### Scenario: Subscribe with resumeFrom - -- **GIVEN** Run has persisted events -- **WHEN** `durably.subscribe(runId, { resumeFrom: 5 })` is called -- **THEN** events after sequence 5 are replayed first -- **AND** live events follow diff --git a/openspec/changes/add-streaming-v2/tasks.md b/openspec/changes/add-streaming-v2/tasks.md deleted file mode 100644 index 0a78192e..00000000 --- a/openspec/changes/add-streaming-v2/tasks.md +++ /dev/null @@ -1,23 +0,0 @@ -# Tasks: Streaming v2 実装 - -## Phase A: step.stream() 基本実装 - -- [ ] A.1 `step.stream()` メソッドを実装 (`context.ts`) -- [ ] A.2 `StreamEvent` 型を追加 (`events.ts`) -- [ ] A.3 `subscribe()` で `stream` イベントを配信 -- [ ] A.4 テスト: stream の基本動作 - -## Phase B: イベント永続化と再接続 - -- [ ] B.1 `events` テーブルスキーマを追加 (`schema.ts`) -- [ ] B.2 マイグレーションを追加 (`migrations.ts`) -- [ ] B.3 `createEvent()`, `getEvents()` を Storage に追加 -- [ ] B.4 粗いイベント (run:*, step:*) の永続化 -- [ ] B.5 `subscribe()` に `resumeFrom` オプションを追加 -- [ ] B.6 テスト: 再接続でイベント再生 - -## Phase C: チェックポイント(将来) - -- [ ] C.1 `checkpoint()` API を設計 -- [ ] C.2 チェックポイントからの再開サポート -- [ ] C.3 TTL によるイベントログのクリーンアップ diff --git a/openspec/project.md b/openspec/project.md deleted file mode 100644 index 4bf813a5..00000000 --- a/openspec/project.md +++ /dev/null @@ -1,126 +0,0 @@ -# Project Context - -## Purpose - -Durably is a step-oriented resumable batch execution framework for Node.js and browsers. It enables resumable batch processing with minimal dependencies using only SQLite for persistence. The same job definition code runs in both environments. - -**Target Users**: Individual developers and small teams building AI agents, workflow automation, and batch processing systems. - -**Core Value Proposition**: -- Resume interrupted jobs automatically -- Persist step results for debugging and replay -- Single SQLite file for all state (no Redis, no Postgres required) -- Works in browsers (WASM + OPFS) and Node.js - -## Tech Stack - -- **Language**: TypeScript (ESM-only, strict mode) -- **Package Manager**: pnpm (v10+) with workspaces -- **Database**: SQLite via Kysely ORM - - Node.js: Turso/libsql - - Browser: SQLocal (SQLite WASM with OPFS backend) -- **Schema Validation**: Zod v4 -- **Testing**: Vitest (node, browser, react configs) -- **Linting**: Biome -- **Formatting**: Prettier - -## Project Conventions - -### Code Style - -- **Formatter**: Prettier (auto-runs via hooks) -- **Linter**: Biome -- **Type Checking**: TypeScript strict mode -- **Naming**: - - Files: kebab-case (`job-context.ts`) - - Classes/Types: PascalCase (`Durably`, `JobContext`) - - Functions/Variables: camelCase (`createDurably`, `defineJob`) - - Constants: UPPER_SNAKE_CASE for true constants - -### Architecture Patterns - -- **Dialect Injection**: Kysely dialect passed to `createDurably()` to abstract SQLite implementations -- **Event System**: Extensibility via event emitter (`run:start`, `run:complete`, `run:fail`, `step:*`, `log:write`) -- **Single-threaded Execution**: No parallel run processing in minimal config -- **No Automatic Retry**: Failures are immediate and explicit (`retry()` API for manual retry) -- **Step Idempotency**: Completed steps return cached output on replay - -### Testing Strategy - -- Shared test suites in `tests/shared/*.shared.ts` -- Environment-specific runners in `tests/node/` and `tests/browser/` -- Use `vi.waitFor()` for async assertions -- Each test cleans up with `afterEach` (stop worker, destroy db) -- Playwright for browser environment tests - -### Git Workflow - -- Main branch: `main` -- Feature branches: `feature/` -- Conventional commits preferred -- PR-based workflow - -## Domain Context - -### Core Concepts - -- **Job**: Defined via `defineJob()` and registered with `durably.register()`, receives a step context and payload -- **Step**: Created via `step.run()`, each step's success state and return value is persisted -- **Run**: A job execution instance, created via `trigger()`, always persisted as `pending` before execution -- **Worker**: Polls for pending runs and executes them sequentially - -### Run Status Flow - -```text -pending -> running -> completed - | | - +-> failed-+ - | - +-> cancelled -``` - -### Database Schema - -Four tables: `durably_runs`, `durably_steps`, `durably_logs`, `durably_schema_versions` - -- Runs: `status`, `idempotency_key`, `concurrency_key`, `heartbeat_at` -- Steps: `status` (completed/failed), `output` (JSON), indexed by `run_id` and `index` - -## Important Constraints - -- **ESM-only**: CommonJS is not supported -- **SQLite-first**: No Redis or external queue dependencies -- **Browser Constraints**: - - Single tab usage assumed (OPFS exclusivity) - - Requires Secure Context (HTTPS/localhost) for OPFS - - Background tab interruptions handled via heartbeat recovery - -## External Dependencies - -- **Kysely**: SQL query builder (dialect abstraction) -- **libsql**: SQLite driver for Node.js (Turso compatible) -- **SQLocal**: SQLite WASM with OPFS for browsers -- **Zod**: Schema validation for job inputs/outputs - -## Monorepo Structure - -```text -/ -├── packages/durably/ # Main library (@coji/durably) -├── packages/durably-react/ # React hooks (@coji/durably-react) -├── examples/ # Example applications -│ ├── node/ -│ ├── browser/ -│ └── react/ -├── docs/ # Specification documents -└── website/ # Documentation website -``` - -## Development Commands - -```bash -pnpm validate # Format check, lint, typecheck, tests -pnpm test # Run all tests -pnpm format # Fix formatting -pnpm lint:fix # Fix lint issues -``` diff --git a/openspec/specs/core/spec.md b/openspec/specs/core/spec.md deleted file mode 100644 index 939a8698..00000000 --- a/openspec/specs/core/spec.md +++ /dev/null @@ -1,363 +0,0 @@ -# Core: Step-Oriented Batch Execution Framework - -## Purpose - -A step-oriented batch execution framework for Node.js and browsers. Durably enables resumable batch processing with automatic step replay on failure, using SQLite for persistence. - -## Requirements - -### Requirement: Job Definition - -The system SHALL allow jobs to be statically defined using `defineJob` function. - -- Jobs MUST have name, input schema, output schema, and handler function -- Job definitions MUST be independent of durably instance -- Schemas SHALL be defined using Zod v4 for type-safe input/output - -#### Scenario: Define a job with Zod schemas - -- **GIVEN** input/output are defined with Zod schemas -- **WHEN** `defineJob()` is called -- **THEN** `JobDefinition` object is returned -- **AND** input/output types are inferred from Zod schemas - ---- - -### Requirement: Job Registration - -The system SHALL require jobs to be registered with durably instance for execution. - -- `register` method MUST accept `JobDefinition` and return `JobHandle` -- Registering the same `JobDefinition` multiple times SHALL register only once - -#### Scenario: Register a job and get handle - -- **GIVEN** a `JobDefinition` created with `defineJob()` -- **WHEN** `durably.register({ job })` is called -- **THEN** object containing `JobHandle` is returned -- **AND** `trigger`, `triggerAndWait`, `getRun`, `getRuns` are available on handle - ---- - -### Requirement: Run Trigger - -Runs SHALL be created by `trigger` function and persisted as `pending` before execution. - -- `trigger` MUST only create the Run without waiting for completion -- `triggerAndWait` SHALL create Run and wait for completion -- `timeout` option SHALL cause timeout error if not completed in time - -#### Scenario: Trigger a job - -- **WHEN** `job.trigger(payload)` is called -- **THEN** Run is created in database with `pending` status -- **AND** Run object is returned immediately - -#### Scenario: Trigger and wait for completion - -- **WHEN** `job.triggerAndWait(payload, { timeout: 5000 })` is called -- **THEN** waits until Run completes -- **AND** `{ id, output }` is returned after completion - -#### Scenario: Timeout on triggerAndWait - -- **GIVEN** job takes 10 seconds -- **WHEN** `triggerAndWait(payload, { timeout: 1000 })` is called -- **THEN** timeout error occurs after 1 second -- **AND** Run continues in background - ---- - -### Requirement: Idempotency Key - -The system SHALL prevent duplicate registrations using `idempotencyKey`. - -- If same job name and `idempotencyKey` combination exists, new Run MUST NOT be created -- `idempotencyKey` SHALL have no expiration -- Deleting Run SHALL allow re-registration with same key - -#### Scenario: Duplicate trigger with same idempotency key - -- **GIVEN** Run already created with `idempotencyKey: "event-123"` -- **WHEN** `trigger` is called again with same `idempotencyKey` -- **THEN** new Run is NOT created -- **AND** existing Run is returned - ---- - -### Requirement: Concurrency Key - -The system SHALL prevent simultaneous processing of same target using `concurrencyKey`. - -- If Run with same `concurrencyKey` is running, subsequent Runs MUST wait -- Run creation itself SHALL NOT be cancelled - -#### Scenario: Concurrent runs with same concurrency key - -- **GIVEN** Run with `concurrencyKey: "org_123"` is in `running` state -- **WHEN** new Run is triggered with same `concurrencyKey` -- **THEN** new Run is created in `pending` state -- **AND** execution waits until preceding Run completes - ---- - -### Requirement: Batch Trigger - -The system SHALL allow multiple triggers to be registered at once. - -- `batchTrigger` MUST register multiple Runs in a single transaction -- Execution model SHALL NOT be affected - -#### Scenario: Batch trigger multiple runs - -- **WHEN** `job.batchTrigger([{ payload: p1 }, { payload: p2 }])` is called -- **THEN** 2 Runs are created in single transaction -- **AND** array of Runs is returned - ---- - -### Requirement: Run Status - -Run SHALL have `pending`, `running`, `completed`, `failed`, `cancelled` states. - -- `pending`: waiting for execution -- `running`: currently executing -- `completed`: successfully completed -- `failed`: execution failed -- `cancelled`: manually cancelled - -#### Scenario: Normal run completion - -- **GIVEN** Run is in `pending` state -- **WHEN** Worker picks up and executes the Run -- **THEN** Run transitions `running` → `completed` - -#### Scenario: Run failure - -- **GIVEN** Run is in `running` state -- **WHEN** exception occurs in a step -- **THEN** Run transitions to `failed` state -- **AND** error message is recorded - ---- - -### Requirement: Step Execution - -Step names MUST be unique within a Run. - -- Successful steps SHALL be automatically skipped on re-execution -- Saved return values MUST be returned - -#### Scenario: Step completes successfully - -- **WHEN** `step.run("fetch-users", fn)` is called -- **THEN** `fn` is executed -- **AND** result is saved to database - -#### Scenario: Step replay on resume - -- **GIVEN** `step.run("fetch-users", fn)` was previously completed -- **WHEN** Run resumes and reaches same step -- **THEN** `fn` is NOT executed -- **AND** saved result is returned - ---- - -### Requirement: Run Retry - -The system SHALL allow re-execution of failed Runs. - -- `retry` MUST reset `failed` Run to `pending` state -- Successful steps SHALL be skipped - -#### Scenario: Retry a failed run - -- **GIVEN** Run is in `failed` state -- **WHEN** `durably.retry(runId)` is called -- **THEN** Run transitions to `pending` state -- **AND** Worker picks up and executes again - ---- - -### Requirement: Run Cancel - -The system SHALL allow cancellation of running or pending Runs. - -- `cancel` MUST transition `pending` or `running` Run to `cancelled` -- Cancelling `running` Run SHALL allow current step to complete - -#### Scenario: Cancel a pending run - -- **GIVEN** Run is in `pending` state -- **WHEN** `durably.cancel(runId)` is called -- **THEN** Run transitions to `cancelled` state - ---- - -### Requirement: Run Delete - -The system SHALL allow deletion of completed, failed, or cancelled Runs. - -- Run and related steps, logs MUST be deleted -- `pending` or `running` Runs MUST NOT be deletable -- After deletion, new Run MAY be created with same `idempotencyKey` - -#### Scenario: Delete a completed run - -- **GIVEN** Run is in `completed` state -- **WHEN** `durably.deleteRun(runId)` is called -- **THEN** Run and related data are deleted - ---- - -### Requirement: Run Query - -The system SHALL provide API to check Run status. - -- `JobHandle.getRun(id)` MUST return type-safe output -- `JobHandle.getRuns(filter)` MUST return Runs for this job -- `durably.getRun(id)` SHALL return output as `unknown` type -- `durably.getRuns(filter)` SHALL query across all jobs - -#### Scenario: Get runs with filter - -- **WHEN** `durably.getRuns({ status: 'failed', limit: 10 })` is called -- **THEN** up to 10 failed Runs are returned -- **AND** sorted by `created_at` descending - ---- - -### Requirement: Run Subscription - -The system SHALL allow real-time subscription to Run execution. - -- `subscribe` MUST return `ReadableStream` -- Stream SHALL auto-close on `run:complete` or `run:fail` - -#### Scenario: Subscribe to run events - -- **GIVEN** Run is executing -- **WHEN** `durably.subscribe(runId)` is called -- **THEN** event stream is returned -- **AND** `step:start`, `step:complete` events are delivered - ---- - -### Requirement: Worker - -Worker SHALL be started by `start` function and execute `pending` Runs sequentially. - -- Worker MUST operate on polling basis (default 1000ms) -- Worker MUST process one Run at a time -- Heartbeat MUST be updated periodically for crash detection - -#### Scenario: Worker processes pending run - -- **GIVEN** Run is in `pending` state -- **WHEN** Worker's polling cycle occurs -- **THEN** Run transitions to `running` and executes - -#### Scenario: Stale run recovery - -- **GIVEN** Run is `running` with stale heartbeat -- **WHEN** heartbeat exceeds threshold (default 30 seconds) -- **THEN** Run is reset to `pending` -- **AND** re-executed on next poll - ---- - -### Requirement: Event System - -The system SHALL have event system to notify external consumers. - -- Events MUST include: `run:trigger`, `run:start`, `run:complete`, `run:fail`, `run:cancel`, `run:retry`, `run:progress` -- Events MUST include: `step:start`, `step:complete`, `step:fail` -- Events MUST include: `log:write`, `worker:error` - -#### Scenario: Listen to run events - -- **WHEN** `durably.on('run:complete', handler)` is registered -- **AND** Run completes -- **THEN** `handler` is called with `{ runId, jobName, output, duration }` - ---- - -### Requirement: Progress Tracking - -The system SHALL allow job progress to be tracked externally. - -- `step.progress(current, total?, message?)` MUST record progress -- `getRun` SHALL return `progress` field - -#### Scenario: Report progress - -- **WHEN** `step.progress(50, 100, "Processing...")` is called -- **THEN** Run's `progress` field is updated -- **AND** `run:progress` event is emitted - ---- - -### Requirement: Structured Logging - -The system SHALL allow explicit logging from within jobs. - -- `step.log.info(message, data?)`, `step.log.warn(...)`, `step.log.error(...)` MUST be available -- Logs SHALL be emitted as `log:write` events - -#### Scenario: Write structured log - -- **WHEN** `step.log.info('fetched users', { count: 10 })` is called -- **THEN** `log:write` event is emitted -- **AND** event contains `{ level: 'info', message, data }` - ---- - -### Requirement: Plugin System - -The system SHALL provide plugin-based extensions using events. - -- `durably.use(plugin)` MUST register plugins -- `withLogPersistence()` SHALL persist logs to database - -#### Scenario: Enable log persistence plugin - -- **WHEN** `durably.use(withLogPersistence())` is called -- **AND** `step.log.info(...)` is called -- **THEN** log is persisted to `logs` table - ---- - -### Requirement: Database Migration - -Database tables SHALL be created via `migrate` function. - -- Migration MUST be idempotent (safe to call multiple times) -- Schema versioning SHALL be managed internally - -#### Scenario: Run migration - -- **WHEN** `await durably.migrate()` is called -- **THEN** required tables are created -- **AND** schema version is recorded - ---- - -### Requirement: Cross-Environment Support - -Same job definition code SHALL work in both Node.js and browsers. - -- Node.js: Turso/libsql dialect MUST be supported -- Browser: SQLocal (SQLite WASM + OPFS) MUST be supported -- Environment differences SHALL be abstracted via dialect passed to `createDurably` - -#### Scenario: Run in Node.js - -- **GIVEN** durably is created with `LibsqlDialect` -- **WHEN** job is triggered -- **THEN** state is persisted to local SQLite file - -#### Scenario: Run in browser - -- **GIVEN** durably is created with `SQLocalKysely` dialect -- **WHEN** job is triggered -- **THEN** state is persisted to OPFS diff --git a/openspec/specs/react/spec.md b/openspec/specs/react/spec.md deleted file mode 100644 index 6149959d..00000000 --- a/openspec/specs/react/spec.md +++ /dev/null @@ -1,108 +0,0 @@ -# React: Durably React Bindings - -## Purpose - -React bindings for Durably, supporting both browser-complete mode and server-connected client mode. - -## Requirements - -### Requirement: Durably Provider Context - -The system SHALL provide a React context for a Durably instance. - -- The system MUST provide `DurablyProvider` that accepts `durably: Durably | Promise` -- The provider MUST expose `useDurably()` to read the instance -- `useDurably()` MUST throw when used outside `DurablyProvider` -- When `fallback` is provided, the provider MUST wrap children in a Suspense boundary - -#### Scenario: Resolve provider from Promise - -- **GIVEN** `durably` is a Promise -- **WHEN** `DurablyProvider` renders with `fallback` -- **THEN** the fallback is shown until the Promise resolves -- **AND** `useDurably()` returns the resolved instance - ---- - -### Requirement: Browser Mode Job Hook - -The system SHALL provide a browser-complete job hook. - -- The system MUST provide `useJob(jobDefinition, options?)` -- The hook MUST return `trigger`, `triggerAndWait`, `status`, `output`, `error`, `logs`, and `progress` -- The hook MUST return state booleans `isRunning`, `isPending`, `isCompleted`, `isFailed`, `isCancelled` -- The hook MUST return `currentRunId` and `reset` -- The hook MUST accept `initialRunId`, `autoResume`, and `followLatest` - -#### Scenario: Trigger and observe job - -- **GIVEN** a registered job definition -- **WHEN** `useJob(jobDefinition)` triggers a run -- **THEN** `currentRunId` is set and `status` updates as the run progresses - ---- - -### Requirement: Browser Mode Run Hooks - -The system SHALL provide run-focused hooks in browser mode. - -- The system MUST provide `useJobRun({ runId })` for run status/output -- The system MUST provide `useJobLogs({ runId, maxLogs? })` for log streams -- The system MUST provide `useRuns(options?)` for listing runs - -#### Scenario: Subscribe to an existing run - -- **GIVEN** a valid `runId` -- **WHEN** `useJobRun({ runId })` is called -- **THEN** the hook returns `status`, `output`, and `progress` for that run - ---- - -### Requirement: Client Mode Job Hook - -The system SHALL provide a server-connected job hook. - -- The system MUST provide `useJob({ api, jobName, options? })` -- The hook MUST return `trigger`, `triggerAndWait`, `status`, `output`, `error`, `logs`, and `progress` -- The hook MUST return state booleans `isRunning`, `isPending`, `isCompleted`, `isFailed`, and `isCancelled` -- The hook MUST return `currentRunId` and `reset` -- The hook MUST use HTTP/SSE to trigger and subscribe - -#### Scenario: Trigger via API - -- **GIVEN** `api` and `jobName` -- **WHEN** `useJob({ api, jobName })` triggers a run -- **THEN** the hook receives updates via SSE subscription - ---- - -### Requirement: Client Mode Run Hooks - -The system SHALL provide server-connected run hooks. - -- The system MUST provide `useJobRun({ api, runId })` -- The system MUST provide `useJobLogs({ api, runId, maxLogs? })` -- The system MUST provide `useRuns({ api, options? })` -- The system MUST provide `useRunActions({ api })` with `retry`, `cancel`, `deleteRun`, `getRun`, and `getSteps` - -#### Scenario: Fetch run actions - -- **GIVEN** `useRunActions({ api })` -- **WHEN** `retry(runId)` is called -- **THEN** the run is retried via the API - ---- - -### Requirement: Typed Client Factories - -The system SHALL provide typed helper factories for client mode. - -- The system MUST provide `createDurablyClient({ api })` -- The system MUST provide `createJobHooks({ api, jobName })` -- The factories MUST return `useJob`, `useRun`, and `useLogs` hooks - -#### Scenario: Create typed hooks for a job - -- **GIVEN** a job type and API base -- **WHEN** `createJobHooks({ api, jobName })` is called -- **THEN** the returned hooks are type-safe for that job From 1d6ec95913edf34efbdf98f7d688c5955ff62e72 Mon Sep 17 00:00:00 2001 From: coji Date: Wed, 4 Mar 2026 21:50:46 +0900 Subject: [PATCH 4/6] feat: add labels to examples and update documentation Add labels to browser example trigger calls (Image Processing only, keeping Data Sync simple for contrast). Display labels in all three dashboard components with table column and modal section. Add labels field to ClientRun and RunRecord types. Update all docs to show simple labels example before multi-tenancy pattern. Co-Authored-By: Claude Opus 4.6 --- .../app/routes/_index.tsx | 25 +++-- .../app/routes/_index/dashboard.tsx | 104 +++++++++++------ examples/browser-vite-react/src/App.tsx | 26 +++-- .../src/components/dashboard.tsx | 104 +++++++++++------ .../app/routes/_index.tsx | 25 +++-- .../app/routes/_index/dashboard.tsx | 106 ++++++++++++------ packages/durably-react/docs/llms.md | 25 +++++ .../src/client/use-run-actions.ts | 1 + packages/durably-react/src/types.ts | 1 + .../tests/client/use-runs.test.tsx | 1 + packages/durably/docs/llms.md | 10 +- website/api/durably-react/types.md | 80 ++++++------- website/guide/concepts.md | 21 +++- website/public/llms.txt | 35 +++++- 14 files changed, 377 insertions(+), 187 deletions(-) diff --git a/examples/browser-react-router-spa/app/routes/_index.tsx b/examples/browser-react-router-spa/app/routes/_index.tsx index 574480ec..2767a266 100644 --- a/examples/browser-react-router-spa/app/routes/_index.tsx +++ b/examples/browser-react-router-spa/app/routes/_index.tsx @@ -34,7 +34,10 @@ export async function clientAction({ request }: { request: Request }) { if (intent === 'image') { const filename = formData.get('filename') as string const width = Number(formData.get('width')) - const run = await durably.jobs.processImage.trigger({ filename, width }) + const run = await durably.jobs.processImage.trigger( + { filename, width }, + { labels: { source: 'browser' } }, + ) return { intent: 'image', runId: run.id } } @@ -59,22 +62,22 @@ export default function Index() { return (
              -
              +

              Durably - Browser-Only SPA

              -

              +

              React Router v7 SPA mode with clientAction + Form

              -
              +
              {/* Left: Job Trigger + Progress */}
              {/* Job Selection */} -
              -
              +
              +

              Run Job

              -
              +