feat(core,daemon): RFC-003 Phase 4 — WorkflowSpec Compiler

- WorkflowSpec + RoleSpec types in packages/core
- compileWorkflowSpec: WorkflowSpec → WorkflowDefinition (daemon)
- resolveRoleTimeoutMs: two-level timeout (role override > agent default)
- parseDurationStringToMs extracted to shared duration.ts
- AgentRegistry.getAgentConfig for timeout lookup
- Tests: 10 new cases (compile shape, agent→extract flow, timeout resolution)
- Backward compat: hand-written Role<Meta> unchanged

Closes #238
Ref: #234
This commit is contained in:
2026-04-29 05:11:29 +00:00
parent 136aafa209
commit 1218b5ddbd
11 changed files with 404 additions and 20 deletions
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { resolveRoleTimeoutMs } from "../workflow-spec.js";
describe("resolveRoleTimeoutMs", () => {
it("uses agent default when role timeout is null", () => {
const r = resolveRoleTimeoutMs(null, 300_000);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe(300_000);
});
it("uses role override string over agent default", () => {
const r = resolveRoleTimeoutMs("60s", 300_000);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe(60_000);
});
it("allows explicit role duration when agent default is null", () => {
const r = resolveRoleTimeoutMs("5s", null);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe(5000);
});
it("returns err for invalid duration string", () => {
const r = resolveRoleTimeoutMs("not-a-duration", 300_000);
expect(r.ok).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { Result } from "./result.js";
import { err, ok } from "./result.js";
const DURATION_RE = /^(\d+)([smh])$/;
const DURATION_MULTIPLIERS: Record<string, number> = {
s: 1_000,
m: 60_000,
h: 3_600_000,
};
/**
* Parse a duration string such as `5s`, `10m`, `1h` to milliseconds.
* Used by `parseNerveConfig` and WorkflowSpec role timeout (RFC-003).
*/
export function parseDurationStringToMs(value: string): Result<number> {
const match = DURATION_RE.exec(value);
if (!match) {
return err(new Error(`invalid duration "${value}" (expected e.g. "5s", "10m", "1h")`));
}
return ok(Number(match[1]) * DURATION_MULTIPLIERS[match[2]]);
}
+3
View File
@@ -27,6 +27,9 @@ export type {
WorkflowDefinition,
} from "./workflow.js";
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
export type { RoleSpec, WorkflowSpec } from "./workflow-spec.js";
export { resolveRoleTimeoutMs } from "./workflow-spec.js";
export { parseDurationStringToMs } from "./duration.js";
export type { Schema, ExtractFn } from "./extract-layer.js";
export { ExtractError } from "./extract-layer.js";
export type { Result } from "./result.js";
+5 -20
View File
@@ -9,25 +9,12 @@ import {
type SenseConfig,
type WorkflowConfig,
} from "./config.js";
import { parseDurationStringToMs } from "./duration.js";
import { isPlainRecord } from "./is-plain-record.js";
import type { Result } from "./result.js";
import { err, ok } from "./result.js";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
const DURATION_RE = /^(\d+)([smh])$/;
const DURATION_MULTIPLIERS: Record<string, number> = {
s: 1_000,
m: 60_000,
h: 3_600_000,
};
function parseDurationToMs(value: string): number | null {
const match = DURATION_RE.exec(value);
if (!match) return null;
return Number(match[1]) * DURATION_MULTIPLIERS[match[2]];
}
function isValidGroupName(value: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(value);
}
@@ -54,13 +41,11 @@ function parseDurationField(field: unknown, label: string): Result<number | null
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
);
}
const ms = parseDurationToMs(field);
if (ms === null) {
return err(
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
);
const msResult = parseDurationStringToMs(field);
if (!msResult.ok) {
return err(new Error(`${label}: ${msResult.error.message}`));
}
return ok(ms);
return ok(msResult.value);
}
function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
+37
View File
@@ -0,0 +1,37 @@
import { parseDurationStringToMs } from "./duration.js";
import type { Schema } from "./extract-layer.js";
import type { Result } from "./result.js";
import { ok } from "./result.js";
import type { Moderator, RoleMeta } from "./workflow.js";
/**
* Authoring-time role: references a named agent, prompt, extract schema, and optional timeout.
* Compiles to runtime `Role<Meta>` via `compileWorkflowSpec` (RFC-003 Phase 4).
*/
export type RoleSpec<Meta extends Record<string, unknown>> = {
agent: string;
prompt: string;
meta: Schema<Meta>;
/** Override agent default; `null` uses the agent's configured timeout from `nerve.yaml`. */
timeout: string | null;
};
/** User-facing workflow authoring shape; compiles to `WorkflowDefinition`. */
export type WorkflowSpec<M extends RoleMeta> = {
name: string;
roles: { [K in keyof M]: RoleSpec<M[K]> };
moderator: Moderator<M>;
};
/**
* Two-level timeout: explicit role string wins; otherwise agent default (milliseconds).
*/
export function resolveRoleTimeoutMs(
roleTimeout: string | null,
agentDefaultMs: number | null,
): Result<number | null> {
if (roleTimeout === null) {
return ok(agentDefaultMs);
}
return parseDurationStringToMs(roleTimeout);
}