Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
563 changes: 563 additions & 0 deletions ops/reviews/20260903-scheduled-trigger-design.md

Large diffs are not rendered by default.

63 changes: 61 additions & 2 deletions sdk/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ import type {
KernelRunSpec,
KernelStepCommon,
KernelStepSpec,
KernelTriggerSpec,
KernelVerificationSpec,
LlmStepSpec,
NamedAgentSpec,
StepSpec,
StepType,
TriggerSpec,
} from './spec.js';
import { SPEC_SCHEMA_VERSION } from './spec.js';
import { canonicalize, specHash } from './canonical.js';
Expand Down Expand Up @@ -195,7 +197,7 @@ export function toKernelSpec(flow: FlowSpec): KernelRunSpec {
...(flow.name !== undefined ? { name: flow.name } : {}),
...(flow.description !== undefined ? { description: flow.description } : {}),
...(flow.cli !== undefined ? { cli: flow.cli } : {}),
...(flow.triggers?.length ? { triggers: flow.triggers } : {}),
...(flow.triggers?.length ? { triggers: flow.triggers.map(toKernelTrigger) } : {}),
steps: flow.steps.map((step) => toKernelStep(resolveNamedAgent(step, flow.agents))),
...(flow.budget !== undefined
? {
Expand All @@ -222,15 +224,72 @@ export function kernelToAuthoring(value: unknown): unknown {
);
const steps = requireKernelArray(root['steps'], 'spec.steps')
.map((step, index) => kernelStepToAuthoring(step, `spec.steps[${index}]`));
const triggers = root['triggers'];
return {
...copyDefined(root, ['version', 'name', 'description', 'cli', 'triggers']),
...copyDefined(root, ['version', 'name', 'description', 'cli']),
...(triggers !== undefined
? {
triggers: requireKernelArray(triggers, 'spec.triggers')
.map((trigger, index) => kernelTriggerToAuthoring(trigger, `spec.triggers[${index}]`)),
}
: {}),
steps,
...(root['budget'] !== undefined
? { budget: kernelBudgetToAuthoring(root['budget'], 'spec.budget') }
: {}),
};
}

/**
* Lower one authoring trigger into the kernel dialect.
*
* This mapping was missing entirely: `toKernelSpec` used to spread
* `flow.triggers` through untouched, so every event subscription reached the
* kernel in camelCase and `relayflowd` — whose `TriggerSpec` is
* `#[serde(deny_unknown_fields)]` over snake_case — refused the spec outright:
*
* malformed run spec: unknown field `dedupeKeyTemplate`, expected one of
* `id`, `executor`, `event_type`, `pattern`, `dedupe_key_template`,
* `stale_after_ms`
*
* The committed `testdata/*.spec.canonical.json` fixtures are snake_case and
* the kernel accepts them, which is why nothing noticed: no test compiled a
* triggered flow through this function and compared it to a fixture. Every
* triggered flow in `testdata/` was therefore unauthorable through the
* supported SDK path. `tests/spec-parity.test.ts` now pins the mapping.
*/
function toKernelTrigger(trigger: TriggerSpec): KernelTriggerSpec {
return {
id: trigger.id,
executor: trigger.executor,
...(trigger.eventType !== undefined ? { event_type: trigger.eventType } : {}),
...(trigger.pattern !== undefined ? { pattern: trigger.pattern } : {}),
...(trigger.dedupeKeyTemplate !== undefined
? { dedupe_key_template: trigger.dedupeKeyTemplate }
: {}),
...(trigger.staleAfterMs !== undefined ? { stale_after_ms: trigger.staleAfterMs } : {}),
};
}

/** Inverse of `toKernelTrigger`. Kernel-only keys are refused, never dropped. */
function kernelTriggerToAuthoring(value: unknown, at: string): unknown {
const trigger = requireKernelObject(
value,
['id', 'executor', 'event_type', 'pattern', 'dedupe_key_template', 'stale_after_ms'],
at,
);
return {
id: trigger['id'],
executor: trigger['executor'],
...(trigger['event_type'] !== undefined ? { eventType: trigger['event_type'] } : {}),
...(trigger['pattern'] !== undefined ? { pattern: trigger['pattern'] } : {}),
...(trigger['dedupe_key_template'] !== undefined
? { dedupeKeyTemplate: trigger['dedupe_key_template'] }
: {}),
...(trigger['stale_after_ms'] !== undefined ? { staleAfterMs: trigger['stale_after_ms'] } : {}),
};
}

function kernelStepToAuthoring(value: unknown, at: string): unknown {
const unionKeys = [
'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification',
Expand Down
20 changes: 20 additions & 0 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,23 @@ export {
type DirLister,
type PollOptions as DirWatcherPollOptions,
} from './dir-watcher-poller.js';

// Tick event source — a relayflow can be scheduled. A schedule is an event
// source subject to the same liveness sweep as any other subscription, not a
// scheduler inside the kernel (RFC-0001 gate 2: "triggers are entry
// conditions, not schedulers").
export {
emitDueTicks,
scheduledForMs,
TickEmitError,
slotFor,
tickDedupeKey,
DEFAULT_MAX_CATCH_UP,
TICK_DEDUPE_KEY_TEMPLATE,
TICK_EVENT_TYPE,
type EventSink as TickEventSink,
type TickCursor,
type TickEmitResult,
type TickPayload,
type TickSchedule,
} from './tick-source.js';
37 changes: 36 additions & 1 deletion sdk/src/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,42 @@ export interface NamedAgentSpec {
model: string;
}

/** Inert gate-1 trigger declaration. Matching and dispatch belong to gate 2. */
/**
* A trigger is an entry condition, not a scheduler (RFC-0001 gate 2). It names
* the event type that wakes the flow, the payload subset that must match, the
* template that derives the dedupe key, and the silence budget after which the
* kernel's liveness sweep declares the subscription dead.
*
* The event-subscription fields were shipped in `testdata/` long before this
* interface described them: `hn-monitor.flow.yaml`, `dir-watcher.flow.yaml`
* and `event-triggered-flow.yaml` all carry `eventType`, `pattern` and
* `dedupeKeyTemplate`, and `validate.ts` has always accepted them. The type
* still said "inert gate-1 declaration" with only `id` and `executor`, so the
* authoring dialect disagreed with both the shipped specs and the kernel.
*/
export interface TriggerSpec {
id: string;
/** Executor registration required before this trigger may start a run. */
executor: string;
/** Event type this trigger subscribes to. Lowers to `event_type`. */
eventType?: string;
/** Recursive-subset match against the event payload. Lowers to `pattern`. */
pattern?: Record<string, unknown>;
/** Derives the dedupe key. Lowers to `dedupe_key_template`. */
dedupeKeyTemplate?: string;
/**
* Silence budget in milliseconds. When no matching event arrives inside it,
* the kernel's liveness sweep journals `subscription.stale` and emits a
* `relayflowd: subscription.stale ...` line
* (`kernel/relayflowd/src/server/liveness.rs`). Omitted means the engine
* default (`DEFAULT_STALE_AFTER_MS`, 5 minutes) applies — which is a
* decision the author did not make, not the absence of a budget.
*
* A flow that is never triggered is silently zero (RFC-0001 §"Trigger
* liveness"), so declaring this is how a schedule stops being able to die
* quietly. Lowers to `stale_after_ms`.
*/
staleAfterMs?: number;
}

/**
Expand Down Expand Up @@ -290,6 +321,10 @@ export interface KernelBudgetSpec {
export interface KernelTriggerSpec {
id: string;
executor: string;
event_type?: string;
pattern?: Record<string, unknown>;
dedupe_key_template?: string;
stale_after_ms?: number;
}

/** The compiled spec as the kernel parses, journals, and hashes it. */
Expand Down
Loading
Loading