docs(rfc-001): add execution model — Role, Moderator, Agent types

Ported from nerve's workflow types. Covers ThreadContext, StartStep,
RoleStep, Moderator (pure router), Role (async actor), AgentFn (LLM adapter),
WorkflowDefinition, and execution flow.

小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-06 04:41:52 +00:00
parent e9729fbe3a
commit 01e930df8f
14 changed files with 692 additions and 3 deletions
+8
View File
@@ -7,5 +7,13 @@
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
},
"dependencies": {
"acorn": "^8.16.0",
"xxhashjs": "^0.2.2",
"yaml": "^2.8.4"
},
"devDependencies": {
"@types/acorn": "^6.0.4"
}
}
+77
View File
@@ -0,0 +1,77 @@
import { err, ok, type Result } from "./result.js";
/** Crockford Base32 alphabet (no I, L, O, U). */
export const CROCKFORD_BASE32_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXZ";
const DECODE_MAP: Record<string, number> = (() => {
const map: Record<string, number> = {};
for (let i = 0; i < CROCKFORD_BASE32_ALPHABET.length; i++) {
map[CROCKFORD_BASE32_ALPHABET[i]] = i;
}
return map;
})();
function padBitCount(bitLength: number): number {
const r = bitLength % 5;
return r === 0 ? 0 : 5 - r;
}
/**
* Encode an integer using exactly `bitLength` significant bits, MSB-first,
* with the minimum number of leading zero bits so the total is a multiple of 5.
*/
export function encodeCrockfordBase32Bits(value: bigint, bitLength: number): string {
if (bitLength <= 0) {
throw new Error("bitLength must be positive");
}
const padBits = padBitCount(bitLength);
const totalBits = bitLength + padBits;
const charCount = totalBits / 5;
const shifted = value << BigInt(padBits);
let result = "";
for (let i = 0; i < charCount; i++) {
const shift = totalBits - 5 * (i + 1);
const quintet = Number((shifted >> BigInt(shift)) & 0x1fn);
result += CROCKFORD_BASE32_ALPHABET[quintet];
}
return result;
}
export function decodeCrockfordBase32Bits(encoded: string, bitLength: number): Result<bigint, Error> {
if (bitLength <= 0) {
return err(new Error("bitLength must be positive"));
}
const padBits = padBitCount(bitLength);
const totalBits = encoded.length * 5;
if (totalBits !== bitLength + padBits) {
return err(new Error("encoded length does not match bitLength"));
}
let shifted = 0n;
for (let i = 0; i < encoded.length; i++) {
const ch = encoded[i];
if (ch === undefined) {
return err(new Error("invalid encoded string"));
}
const upper = ch.toUpperCase();
const val = DECODE_MAP[upper];
if (val === undefined) {
return err(new Error(`invalid Crockford Base32 character: ${ch}`));
}
shifted = (shifted << 5n) | BigInt(val);
}
return ok(shifted >> BigInt(padBits));
}
/** XXH64-sized value (13 Crockford chars). */
export function encodeUint64AsCrockford(value: bigint): string {
const masked = value & 0xffff_ffff_ffff_ffffn;
return encodeCrockfordBase32Bits(masked, 64);
}
export function decodeCrockfordToUint64(encoded: string): Result<bigint, Error> {
const decoded = decodeCrockfordBase32Bits(encoded, 64);
if (!decoded.ok) {
return decoded;
}
return ok(decoded.value & 0xffff_ffff_ffff_ffffn);
}
+142
View File
@@ -0,0 +1,142 @@
import { isBuiltin } from "node:module";
import * as acorn from "acorn";
import type { Node, Program } from "acorn";
import { err, ok, type Result } from "./result.js";
export type WorkflowBundleValidationInput = {
/** Absolute or relative path (used for `.esm.js` suffix checks). */
filePath: string;
/** UTF-8 source of the bundle. */
source: string;
};
function endsWithEsmJs(path: string): boolean {
return path.endsWith(".esm.js");
}
function isAllowedImportSpecifier(spec: string): boolean {
if (spec.length === 0) {
return false;
}
if (spec.startsWith(".") || spec.startsWith("/")) {
return false;
}
return isBuiltin(spec);
}
function walk(node: Node, visit: (n: Node) => void): void {
visit(node);
for (const key of Object.keys(node)) {
const val = (node as Record<string, unknown>)[key];
if (val === null || val === undefined) {
continue;
}
if (Array.isArray(val)) {
for (const item of val) {
if (item !== null && typeof item === "object" && "type" in item) {
walk(item as Node, visit);
}
}
} else if (typeof val === "object" && "type" in val) {
walk(val as Node, visit);
}
}
}
function programHasDefaultExport(body: readonly Node[]): boolean {
for (const stmt of body) {
if (stmt.type === "ExportDefaultDeclaration") {
return true;
}
}
return false;
}
/**
* Validate RFC-001 bundle rules: single-file ESM shape, default export,
* no dynamic `import()`, static imports restricted to Node builtins.
*/
export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Result<void, string> {
if (!endsWithEsmJs(input.filePath)) {
return err('workflow bundle file must use the ".esm.js" suffix');
}
let ast: Node;
try {
ast = acorn.parse(input.source, {
ecmaVersion: 2022,
sourceType: "module",
locations: false,
}) as Node;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return err(`failed to parse module: ${message}`);
}
if (ast.type !== "Program") {
return err("internal error: expected Program root");
}
const program = ast as Program;
if (!programHasDefaultExport(program.body)) {
return err("workflow bundle must have a default export");
}
let walkError: string | null = null;
walk(ast, (n) => {
if (walkError !== null) {
return;
}
if (n.type === "ImportExpression") {
walkError = "dynamic import() is not allowed in workflow bundles";
return;
}
if (n.type === "ImportDeclaration") {
const src = n.source;
if (src.type !== "Literal" || typeof src.value !== "string") {
walkError = "only static string import specifiers are allowed";
return;
}
if (!isAllowedImportSpecifier(src.value)) {
walkError = `disallowed import specifier "${src.value}" (only Node built-ins are allowed)`;
}
return;
}
if (n.type === "ExportNamedDeclaration" && n.source !== null && n.source !== undefined) {
const src = n.source;
if (src.type !== "Literal" || typeof src.value !== "string") {
walkError = "only static string re-export specifiers are allowed";
return;
}
if (!isAllowedImportSpecifier(src.value)) {
walkError = `disallowed re-export specifier "${src.value}" (only Node built-ins are allowed)`;
}
return;
}
if (n.type === "ExportAllDeclaration") {
const src = n.source;
if (src.type !== "Literal" || typeof src.value !== "string") {
walkError = "only static string export-all specifiers are allowed";
return;
}
if (!isAllowedImportSpecifier(src.value)) {
walkError = `disallowed export-all specifier "${src.value}" (only Node built-ins are allowed)`;
}
return;
}
if (n.type === "CallExpression") {
const c = n.callee;
if (c.type === "Identifier" && c.name === "require") {
walkError = "require() is not allowed in workflow bundles";
}
}
});
if (walkError !== null) {
return err(walkError);
}
return ok(undefined);
}
+17
View File
@@ -0,0 +1,17 @@
import { Buffer } from "node:buffer";
import XXH from "xxhashjs";
import { encodeUint64AsCrockford } from "./base32.js";
function digestToUint64(digest: { toString(radix?: number): string }): bigint {
const hex = digest.toString(16).padStart(16, "0");
return BigInt(`0x${hex}`);
}
/** XXH64 (seed 0) over bundle bytes, encoded as 13-char Crockford Base32. */
export function hashWorkflowBundleBytes(data: Uint8Array): string {
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
const digest = XXH.h64(0).update(buf).digest();
return encodeUint64AsCrockford(digestToUint64(digest));
}
+32 -2
View File
@@ -1,2 +1,32 @@
// @uncaged/workflow - core library
export {};
export {
CROCKFORD_BASE32_ALPHABET,
decodeCrockfordBase32Bits,
decodeCrockfordToUint64,
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
} from "./base32.js";
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
export { hashWorkflowBundleBytes } from "./hash.js";
export {
createLogger,
type CreateLoggerOptions,
type LogFn,
type LoggerSink,
} from "./logger.js";
export {
getRegisteredWorkflow,
listRegisteredWorkflowNames,
parseWorkflowRegistryYaml,
readWorkflowRegistry,
registerWorkflowVersion,
stringifyWorkflowRegistryYaml,
unregisterWorkflow,
workflowRegistryPath,
writeWorkflowRegistry,
type WorkflowHistoryEntry,
type WorkflowRegistryEntry,
type WorkflowRegistryFile,
} from "./registry.js";
export { err, ok, type Result } from "./result.js";
export { getDefaultWorkflowStorageRoot } from "./storage-root.js";
export { generateUlid } from "./ulid.js";
+57
View File
@@ -0,0 +1,57 @@
import { appendFileSync } from "node:fs";
const TAG_LENGTH = 8;
function assertValidLogTag(tag: string): void {
if (tag.length !== TAG_LENGTH) {
throw new Error(`log tag must be exactly ${TAG_LENGTH} characters`);
}
for (let i = 0; i < tag.length; i++) {
const ch = tag[i];
if (ch === undefined) {
throw new Error("log tag validation failed");
}
const upper = ch.toUpperCase();
if (!/[0-9A-HJKMNP-TV-Z]/.test(upper)) {
throw new Error(`invalid Crockford Base32 character in log tag: ${ch}`);
}
}
}
export type LoggerSink =
| { kind: "stderr" }
| { kind: "file"; path: string };
export type CreateLoggerOptions = {
sink: LoggerSink;
};
export type LogFn = (tag: string, content: string) => void;
/** Append one JSONL log record: `{ tag, content, timestamp }` per RFC-001. */
export function createLogger(options: CreateLoggerOptions): LogFn {
if (options.sink.kind === "stderr") {
return (tag: string, content: string) => {
assertValidLogTag(tag);
const line =
`${JSON.stringify({
tag: tag.toUpperCase(),
content,
timestamp: Date.now(),
})}\n`;
process.stderr.write(line);
};
}
const filePath = options.sink.path;
return (tag: string, content: string) => {
assertValidLogTag(tag);
const line =
`${JSON.stringify({
tag: tag.toUpperCase(),
content,
timestamp: Date.now(),
})}\n`;
appendFileSync(filePath, line, "utf8");
};
}
+164
View File
@@ -0,0 +1,164 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { parseDocument, stringify } from "yaml";
import { err, ok, type Result } from "./result.js";
export type WorkflowHistoryEntry = {
hash: string;
timestamp: number;
};
export type WorkflowRegistryEntry = {
hash: string;
timestamp: number;
history: WorkflowHistoryEntry[];
};
export type WorkflowRegistryFile = {
workflows: Record<string, WorkflowRegistryEntry>;
};
export function workflowRegistryPath(storageRoot: string): string {
return join(storageRoot, "workflow.yaml");
}
function emptyRegistry(): WorkflowRegistryFile {
return { workflows: {} };
}
function normalizeRegistry(raw: unknown): Result<WorkflowRegistryFile, Error> {
if (raw === null || typeof raw !== "object") {
return err(new Error("registry root must be a mapping"));
}
const root = raw as Record<string, unknown>;
const workflowsRaw = root.workflows;
if (workflowsRaw === null || workflowsRaw === undefined || typeof workflowsRaw !== "object") {
return err(new Error('registry must contain a "workflows" mapping'));
}
const workflows: Record<string, WorkflowRegistryEntry> = {};
for (const [name, entryRaw] of Object.entries(workflowsRaw)) {
if (entryRaw === null || typeof entryRaw !== "object") {
return err(new Error(`workflow "${name}" must be a mapping`));
}
const e = entryRaw as Record<string, unknown>;
const hash = e.hash;
const timestamp = e.timestamp;
const historyRaw = e.history;
if (typeof hash !== "string") {
return err(new Error(`workflow "${name}" must have a string hash`));
}
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
return err(new Error(`workflow "${name}" must have a finite numeric timestamp`));
}
if (!Array.isArray(historyRaw)) {
return err(new Error(`workflow "${name}" must have a history array`));
}
const history: WorkflowHistoryEntry[] = [];
for (let i = 0; i < historyRaw.length; i++) {
const h = historyRaw[i];
if (h === null || typeof h !== "object") {
return err(new Error(`workflow "${name}" history[${i}] must be a mapping`));
}
const he = h as Record<string, unknown>;
if (typeof he.hash !== "string" || typeof he.timestamp !== "number" || !Number.isFinite(he.timestamp)) {
return err(new Error(`workflow "${name}" history[${i}] must have hash and timestamp`));
}
history.push({ hash: he.hash, timestamp: he.timestamp });
}
workflows[name] = { hash, timestamp, history };
}
return ok({ workflows });
}
export function parseWorkflowRegistryYaml(text: string): Result<WorkflowRegistryFile, Error> {
if (text.trim() === "") {
return ok(emptyRegistry());
}
let doc: unknown;
try {
doc = parseDocument(text).toJSON();
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
return normalizeRegistry(doc);
}
export function stringifyWorkflowRegistryYaml(registry: WorkflowRegistryFile): string {
return `${stringify(registry, { indent: 2 })}\n`;
}
export async function readWorkflowRegistry(storageRoot: string): Promise<Result<WorkflowRegistryFile, Error>> {
const path = workflowRegistryPath(storageRoot);
let text: string;
try {
text = await readFile(path, "utf8");
} catch (e) {
const errObj = e as NodeJS.ErrnoException;
if (errObj.code === "ENOENT") {
return ok(emptyRegistry());
}
return err(errObj instanceof Error ? errObj : new Error(String(e)));
}
return parseWorkflowRegistryYaml(text);
}
export async function writeWorkflowRegistry(
storageRoot: string,
registry: WorkflowRegistryFile,
): Promise<Result<void, Error>> {
const path = workflowRegistryPath(storageRoot);
try {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, stringifyWorkflowRegistryYaml(registry), "utf8");
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
return ok(undefined);
}
export function listRegisteredWorkflowNames(registry: WorkflowRegistryFile): string[] {
return Object.keys(registry.workflows).sort();
}
export function getRegisteredWorkflow(
registry: WorkflowRegistryFile,
name: string,
): WorkflowRegistryEntry | null {
const entry = registry.workflows[name];
if (entry === undefined) {
return null;
}
return entry;
}
/** Register or upgrade a workflow version, moving the previous head into `history`. */
export function registerWorkflowVersion(
registry: WorkflowRegistryFile,
name: string,
hash: string,
timestamp: number,
): WorkflowRegistryFile {
const prev = registry.workflows[name];
const baseHistory = prev === undefined ? [] : prev.history;
const history: WorkflowHistoryEntry[] =
prev === undefined
? baseHistory
: [{ hash: prev.hash, timestamp: prev.timestamp }, ...baseHistory];
const next: WorkflowRegistryEntry = { hash, timestamp, history };
return {
workflows: { ...registry.workflows, [name]: next },
};
}
export function unregisterWorkflow(
registry: WorkflowRegistryFile,
name: string,
): Result<WorkflowRegistryFile, Error> {
if (registry.workflows[name] === undefined) {
return err(new Error(`workflow not registered: ${name}`));
}
const { [name]: _removed, ...rest } = registry.workflows;
return ok({ workflows: rest });
}
+9
View File
@@ -0,0 +1,9 @@
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
+7
View File
@@ -0,0 +1,7 @@
import { homedir } from "node:os";
import { join } from "node:path";
/** Default filesystem root for workflow data (`~/.uncaged/workflow`). */
export function getDefaultWorkflowStorageRoot(): string {
return join(homedir(), ".uncaged", "workflow");
}
+28
View File
@@ -0,0 +1,28 @@
import { encodeCrockfordBase32Bits } from "./base32.js";
const ULID_TIME_BITS = 48;
const ULID_RANDOM_BITS = 80;
function readRandomUint80(): bigint {
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
let x = 0n;
for (let i = 0; i < bytes.length; i++) {
x = (x << 8n) | BigInt(bytes[i]);
}
return x & ((1n << 80n) - 1n);
}
/**
* Generate a ULID using Crockford Base32: 10 timestamp chars + 16 random chars.
* Timestamp uses 48 bits of Unix time in milliseconds.
*/
export function generateUlid(nowMs: number): string {
if (!Number.isFinite(nowMs) || nowMs < 0 || nowMs >= 2 ** ULID_TIME_BITS) {
throw new Error("nowMs must be a finite number in [0, 2^48)");
}
const time = BigInt(Math.floor(nowMs));
const rand = readRandomUint80();
const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand;
return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS);
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts", "xxhashjs.d.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
declare module "xxhashjs" {
type Digest = {
toString(radix?: number): string;
};
type Hasher64 = {
update(data: Buffer): Hasher64;
digest(): Digest;
};
type XXH = {
h64(seed: number): Hasher64;
};
const XXH: XXH;
export default XXH;
}