Compare commits

...

15 Commits

Author SHA1 Message Date
xiaomo cd0a79d72b chore: remove accidental pnpm-lock.yaml 2026-05-23 06:47:25 +00:00
xiaomo 54631c43c7 docs: update cli-reference with log commands, --count flag, edge prompt concept 2026-05-23 06:32:27 +00:00
xiaomo 655b57c4b5 Merge pull request 'feat: add uwf log subcommands (list, show, clean)' (#415) from fix/413-log-subcommands into main 2026-05-23 06:27:15 +00:00
xiaoju 7faa8184ae feat: add uwf log subcommands (list, show, clean)
- uwf log list: list log files with sizes
- uwf log show --thread <id>: filter by thread ID
- uwf log show --process <pid>: filter by process ID
- uwf log clean --before <date>: delete old log files
- Tests: 12 new tests covering all subcommands

Implemented by solve-issue workflow, biome fixes applied manually.

Closes #413
Refs #411, #410
2026-05-23 06:23:56 +00:00
xiaoju 816137315e feat: add uwf log subcommands (list, show, clean)
- cmdLogList: list log files with sizes, sorted by date descending
- cmdLogShow: filter entries by thread, process, and/or date
- cmdLogClean: delete log files older than given date
- 12 tests covering all functions and edge cases

Fixes #413
2026-05-23 06:21:06 +00:00
xiaoju 9a111d16c7 fix: invalid Crockford Base32 char 'L' in log tag PL_AGENT_DONE
Fixes runtime crash on uwf thread step.
2026-05-23 06:13:29 +00:00
xiaoju ea6ceafe51 merge: resolve conflict in process-logger test (use null 3rd arg) 2026-05-23 06:10:53 +00:00
xiaoju d0dc7b5a19 feat: add process-level debug logger (Phase 1)
- New ProcessLogger in workflow-util: process-scoped JSONL logger
- Entry schema: {ts, pid, tag, msg, thread, workflow}
- Storage: ~/.uncaged/workflow/logs/YYYY-MM-DD.jsonl
- Auto logs process init info (argv, node version, context)
- cli-workflow thread commands fully instrumented:
  - thread start/step, moderator evaluate, agent spawn/done
  - thread archived, error paths

Refs #411, #412, #410
2026-05-23 06:10:05 +00:00
xiaomo 3b81521e9d Merge pull request 'feat: add process-level debug logger (Phase 1)' (#414) from feat/411-process-logger into main 2026-05-23 06:09:15 +00:00
xiaoju aa0a23293f feat: add process-level debug logger (Phase 1)
- New ProcessLogger in workflow-util: process-scoped JSONL logger
- Entry schema: {ts, pid, tag, msg, thread, workflow}
- Storage: ~/.uncaged/workflow/logs/YYYY-MM-DD.jsonl
- Auto logs process init info (argv, node version, context)
- cli-workflow thread commands fully instrumented:
  - thread start/step, moderator evaluate, agent spawn/done
  - thread archived, error paths

Refs #411, #412, #410
2026-05-23 06:07:45 +00:00
xiaomo 187dd036e5 Merge pull request 'feat: replace edgePrompt null check with isFirstVisit (Phase 2)' (#409) from feat/405-phase2-find-last-role-index into main 2026-05-23 04:55:23 +00:00
xiaoju 4b45f4e6d1 feat: replace edgePrompt null check with isFirstVisit (Phase 2)
- Add isFirstVisit: boolean to AgentContext
- Compute from steps history: !steps.some(s => s.role === role)
- hermes.ts: use isFirstVisit for first-entry vs re-entry logic
- buildInitialPrompt: always append edgePrompt as Moderator Instruction
- edgePrompt is never blanked — always the real moderator instruction
- New tests for first-visit, re-entry, and fallback scenarios

Refs #405, #407, #404
2026-05-23 04:54:11 +00:00
xiaomo 2a6bce4918 Merge pull request 'feat: make edge prompt required (Phase 1)' (#408) from feat/405-edge-prompt-required into main 2026-05-23 04:36:53 +00:00
xiaoju 3d6399c0e3 feat: make edge prompt required (Phase 1)
- Transition.prompt: string | null → string
- EvaluateResult.prompt: string | null → string
- AgentContext.edgePrompt: string | null → string
- CLI YAML validation rejects missing prompt
- All tests updated

Phase 2 will replace edgePrompt === null checks with findLastRoleIndex.

Refs #405, #406, #404
2026-05-23 04:28:58 +00:00
xiaomo b9258f84a5 Merge pull request 'feat: edge prompt + session resume (#402)' (#403) from feat/402-edge-prompt-session-resume into main 2026-05-23 04:00:24 +00:00
28 changed files with 922 additions and 102 deletions
+18
View File
@@ -154,25 +154,43 @@ conditions:
graph:
$START:
- role: "planner"
condition: null
prompt: "Analyze the issue and produce an implementation plan."
planner:
- role: "$END"
condition: "insufficientInfo"
prompt: "Insufficient information to proceed; end the workflow."
- role: "developer"
condition: null
prompt: "Implement the plan from the planner."
developer:
- role: "$END"
condition: "devFailed"
prompt: "Development failed; end the workflow."
- role: "reviewer"
condition: null
prompt: "Send the implementation to the reviewer."
reviewer:
- role: "developer"
condition: "rejected"
prompt: "Reviewer rejected the implementation; fix the issues."
- role: "tester"
condition: null
prompt: "Review passed; run tests on the implementation."
tester:
- role: "developer"
condition: "fixCode"
prompt: "Tests found code issues; return to developer."
- role: "planner"
condition: "fixSpec"
prompt: "Tests found spec issues; return to planner."
- role: "committer"
condition: null
prompt: "Tests passed; commit and push the changes."
committer:
- role: "developer"
condition: "hookFailed"
prompt: "Push hook failed; return to developer to fix."
- role: "$END"
condition: null
prompt: "Commit succeeded; complete the workflow."
+2
View File
@@ -36,6 +36,8 @@ graph:
$START:
- role: "analyst"
condition: null
prompt: "Analyze the topic in the task and produce a structured summary with key points."
analyst:
- role: "$END"
condition: null
prompt: "Analysis complete. Finish the workflow."
+4 -4
View File
@@ -62,19 +62,19 @@ graph:
$START:
- role: "planner"
condition: null
prompt: null
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
planner:
- role: "developer"
condition: null
prompt: null
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
developer:
- role: "reviewer"
condition: null
prompt: null
prompt: "Review the developer's implementation against the plan for correctness and quality."
reviewer:
- role: "developer"
condition: "notApproved"
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
- role: "$END"
condition: null
prompt: null
prompt: "The review passed. Complete the workflow."
@@ -0,0 +1,181 @@
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdLogClean, cmdLogList, cmdLogShow } from "../commands/log.js";
let storageRoot: string;
beforeEach(async () => {
storageRoot = join(tmpdir(), `uwf-log-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(storageRoot, "logs"), { recursive: true });
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
const entry1 = JSON.stringify({
ts: "2026-05-20T10:00:00.000Z",
pid: "1716200000000-1234",
tag: "W9F3RK2M",
msg: "process start",
thread: "01J1234ABCDEF",
workflow: "solve-issue",
});
const entry2 = JSON.stringify({
ts: "2026-05-20T10:00:01.000Z",
pid: "1716200000000-1234",
tag: "ABC12345",
msg: "step executed",
thread: "01J1234ABCDEF",
workflow: "solve-issue",
});
const entry3 = JSON.stringify({
ts: "2026-05-20T10:00:02.000Z",
pid: "1716200000000-5678",
tag: "XYZ98765",
msg: "different process",
thread: "01JOTHER000000",
workflow: "review-code",
});
const oldEntry = JSON.stringify({
ts: "2026-05-19T08:00:00.000Z",
pid: "1716200000000-9999",
tag: "OLD1TAG1",
msg: "old entry",
thread: "01JOLD0000000",
workflow: "solve-issue",
});
const olderEntry = JSON.stringify({
ts: "2026-05-18T08:00:00.000Z",
pid: "1716200000000-0001",
tag: "OLD2TAG2",
msg: "older entry",
thread: "01JOLDER00000",
workflow: "review-code",
});
async function writeLogFiles(): Promise<void> {
const logsDir = join(storageRoot, "logs");
await writeFile(join(logsDir, "2026-05-20.jsonl"), [entry1, entry2, entry3].join("\n") + "\n");
await writeFile(join(logsDir, "2026-05-19.jsonl"), oldEntry + "\n");
await writeFile(join(logsDir, "2026-05-18.jsonl"), olderEntry + "\n");
}
describe("cmdLogList", () => {
test("lists log files with sizes sorted by date descending", async () => {
await writeLogFiles();
const result = await cmdLogList(storageRoot);
expect(result).toHaveLength(3);
expect(result[0].name).toBe("2026-05-20.jsonl");
expect(result[0].date).toBe("2026-05-20");
expect(result[0].size).toBeGreaterThan(0);
expect(result[1].name).toBe("2026-05-19.jsonl");
expect(result[2].name).toBe("2026-05-18.jsonl");
});
test("returns empty array when no log files exist", async () => {
const result = await cmdLogList(storageRoot);
expect(result).toEqual([]);
});
test("returns empty array when logs directory does not exist", async () => {
const noLogsRoot = join(storageRoot, "nonexistent");
await mkdir(noLogsRoot, { recursive: true });
const result = await cmdLogList(noLogsRoot);
expect(result).toEqual([]);
});
});
describe("cmdLogShow", () => {
test("filters by thread ID", async () => {
await writeLogFiles();
const result = await cmdLogShow(storageRoot, {
thread: "01J1234ABCDEF",
process: null,
date: null,
});
expect(result).toHaveLength(2);
expect(result.every((e) => e.thread === "01J1234ABCDEF")).toBe(true);
});
test("filters by process ID", async () => {
await writeLogFiles();
const result = await cmdLogShow(storageRoot, {
thread: null,
process: "1716200000000-1234",
date: null,
});
expect(result).toHaveLength(2);
expect(result.every((e) => e.pid === "1716200000000-1234")).toBe(true);
});
test("filters by date", async () => {
await writeLogFiles();
const result = await cmdLogShow(storageRoot, {
thread: null,
process: null,
date: "2026-05-19",
});
expect(result).toHaveLength(1);
expect(result[0].msg).toBe("old entry");
});
test("reads all files when no date filter", async () => {
await writeLogFiles();
const result = await cmdLogShow(storageRoot, { thread: null, process: null, date: null });
expect(result).toHaveLength(5);
// sorted by ts ascending
expect(result[0].ts).toBe("2026-05-18T08:00:00.000Z");
expect(result[4].ts).toBe("2026-05-20T10:00:02.000Z");
});
test("returns empty when no matches", async () => {
await writeLogFiles();
const result = await cmdLogShow(storageRoot, {
thread: "NONEXISTENT",
process: null,
date: null,
});
expect(result).toEqual([]);
});
test("combined thread + date filter", async () => {
await writeLogFiles();
const result = await cmdLogShow(storageRoot, {
thread: "01J1234ABCDEF",
process: null,
date: "2026-05-20",
});
expect(result).toHaveLength(2);
expect(result.every((e) => e.thread === "01J1234ABCDEF")).toBe(true);
});
});
describe("cmdLogClean", () => {
test("deletes files before given date", async () => {
await writeLogFiles();
const result = await cmdLogClean(storageRoot, "2026-05-20");
expect(result.deleted).toBe(2);
const remaining = await readdir(join(storageRoot, "logs"));
expect(remaining).toEqual(["2026-05-20.jsonl"]);
});
test("deletes nothing when all files are newer", async () => {
await writeLogFiles();
const result = await cmdLogClean(storageRoot, "2026-05-18");
expect(result.deleted).toBe(0);
});
test("handles missing logs directory gracefully", async () => {
const noLogsRoot = join(storageRoot, "nonexistent");
await mkdir(noLogsRoot, { recursive: true });
const result = await cmdLogClean(noLogsRoot, "2026-05-20");
expect(result).toEqual({ deleted: 0 });
});
});
+50
View File
@@ -14,6 +14,7 @@ import {
cmdCasSchemaList,
cmdCasWalk,
} from "./commands/cas.js";
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import { cmdSkillCli } from "./commands/skill.js";
import {
@@ -379,6 +380,55 @@ casSchema
});
});
const log = program.command("log").description("Process-level debug logs");
log
.command("list")
.description("List log files with sizes")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdLogList(storageRoot);
writeOutput(result);
});
});
log
.command("show")
.description("Show and filter log entries")
.option("--thread <thread-id>", "Filter by thread ID")
.option("--process <pid>", "Filter by process ID")
.option("--date <date>", "Filter by date (YYYY-MM-DD)")
.action(
(opts: {
thread: string | undefined;
process: string | undefined;
date: string | undefined;
}) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdLogShow(storageRoot, {
thread: opts.thread ?? null,
process: opts.process ?? null,
date: opts.date ?? null,
});
writeOutput(result);
});
},
);
log
.command("clean")
.description("Delete log files older than given date")
.requiredOption("--before <date>", "Delete files before this date (YYYY-MM-DD)")
.action((opts: { before: string }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdLogClean(storageRoot, opts.before);
writeOutput(result);
});
});
program.parseAsync(process.argv).catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${message}\n`);
+116
View File
@@ -0,0 +1,116 @@
import { readdir, readFile, stat, unlink } from "node:fs/promises";
import { join } from "node:path";
type LogListItem = {
name: string;
size: number;
date: string;
};
type LogShowFilter = {
thread: string | null;
process: string | null;
date: string | null;
};
type LogEntry = {
ts: string;
pid: string;
tag: string;
msg: string;
thread: string | null;
workflow: string | null;
};
type LogCleanResult = {
deleted: number;
};
function logsDir(storageRoot: string): string {
return join(storageRoot, "logs");
}
async function listLogFiles(dir: string): Promise<Array<string>> {
try {
const files = await readdir(dir);
return files.filter((f) => f.endsWith(".jsonl")).sort();
} catch {
return [];
}
}
function dateFromFilename(name: string): string {
return name.replace(".jsonl", "");
}
async function parseJsonlFile(path: string): Promise<Array<LogEntry>> {
const content = await readFile(path, "utf-8");
const lines = content
.trim()
.split("\n")
.filter((l) => l.length > 0);
return lines.map((line) => JSON.parse(line) as LogEntry);
}
export async function cmdLogList(storageRoot: string): Promise<Array<LogListItem>> {
const dir = logsDir(storageRoot);
const files = await listLogFiles(dir);
const items: Array<LogListItem> = [];
for (const name of files) {
const s = await stat(join(dir, name));
items.push({ name, size: s.size, date: dateFromFilename(name) });
}
// sort by date descending
items.sort((a, b) => (a.date > b.date ? -1 : a.date < b.date ? 1 : 0));
return items;
}
export async function cmdLogShow(
storageRoot: string,
filter: LogShowFilter,
): Promise<Array<LogEntry>> {
const dir = logsDir(storageRoot);
let files: Array<string>;
if (filter.date !== null) {
files = [`${filter.date}.jsonl`];
} else {
files = await listLogFiles(dir);
}
let entries: Array<LogEntry> = [];
for (const file of files) {
try {
const parsed = await parseJsonlFile(join(dir, file));
entries = entries.concat(parsed);
} catch {
// file doesn't exist or is unreadable, skip
}
}
if (filter.thread !== null) {
entries = entries.filter((e) => e.thread === filter.thread);
}
if (filter.process !== null) {
entries = entries.filter((e) => e.pid === filter.process);
}
entries.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
return entries;
}
export async function cmdLogClean(storageRoot: string, before: string): Promise<LogCleanResult> {
const dir = logsDir(storageRoot);
const files = await listLogFiles(dir);
let deleted = 0;
for (const name of files) {
const date = dateFromFilename(name);
if (date < before) {
await unlink(join(dir, name));
deleted++;
}
}
return { deleted };
}
+76 -16
View File
@@ -23,7 +23,7 @@ import type {
WorkflowConfig,
WorkflowPayload,
} from "@uncaged/workflow-protocol";
import { generateUlid } from "@uncaged/workflow-util";
import { createProcessLogger, generateUlid, type ProcessLogger } from "@uncaged/workflow-util";
import { config as loadDotenv } from "dotenv";
import { parse, stringify } from "yaml";
@@ -47,6 +47,18 @@ import { materializeWorkflowPayload } from "./workflow.js";
const END_ROLE = "$END";
export const THREAD_READ_DEFAULT_QUOTA = 4000;
const PL_THREAD_START = "7HNQ4B2X";
const PL_MODERATOR = "M3K8V9T1";
const PL_AGENT_SPAWN = "R5J2W8N4";
const PL_AGENT_DONE = "C6P9E3H7";
const PL_THREAD_ARCHIVED = "F4D8Q2K5";
const PL_STEP_ERROR = "B8T5N1V6";
function failStep(plog: ProcessLogger, message: string): never {
plog.log(PL_STEP_ERROR, message, null);
fail(message);
}
type ChainState = {
startHash: CasRef;
start: StartNodePayload;
@@ -168,6 +180,10 @@ export async function cmdThreadStart(
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot);
const threadId = generateUlid(Date.now()) as ThreadId;
const plog = createProcessLogger({
storageRoot,
context: { thread: threadId, workflow: workflowHash },
});
const startPayload: StartNodePayload = {
workflow: workflowHash,
prompt,
@@ -183,6 +199,12 @@ export async function cmdThreadStart(
index[threadId] = headHash;
await saveThreadsIndex(storageRoot, index);
plog.log(
PL_THREAD_START,
`thread created workflow=${workflowHash} thread=${threadId} head=${headHash}`,
null,
);
return { workflow: workflowHash, thread: threadId };
}
@@ -624,12 +646,15 @@ function resolveAgentConfig(
return agentConfig;
}
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string, edgePrompt: string | null): CasRef {
function spawnAgent(
plog: ProcessLogger,
agent: AgentConfig,
threadId: ThreadId,
role: string,
edgePrompt: string,
): CasRef {
const argv = [...agent.args, threadId, role];
const env = { ...process.env };
if (edgePrompt !== null) {
env.UWF_EDGE_PROMPT = edgePrompt;
}
const env = { ...process.env, UWF_EDGE_PROMPT: edgePrompt };
let stdout: string;
try {
stdout = execFileSync(agent.command, argv, {
@@ -646,12 +671,12 @@ function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string, edgePr
? err.stderr
: err.stderr.toString("utf8");
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
fail(`agent command failed (${agent.command})${detail}`);
failStep(plog, `agent command failed (${agent.command})${detail}`);
}
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
if (!isCasRef(line)) {
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
failStep(plog, `agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
}
return line;
}
@@ -683,9 +708,15 @@ export async function cmdThreadStep(
fail(`--count must be a positive integer, got: ${count}`);
}
const workflowHash = await resolveActiveThreadWorkflowHash(storageRoot, threadId);
const plog = createProcessLogger({
storageRoot,
context: { thread: threadId, workflow: workflowHash },
});
const results: StepOutput[] = [];
for (let i = 0; i < count; i++) {
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride);
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog);
results.push(result);
if (result.done) {
break;
@@ -694,16 +725,31 @@ export async function cmdThreadStep(
return results;
}
async function cmdThreadStepOnce(
async function resolveActiveThreadWorkflowHash(
storageRoot: string,
threadId: ThreadId,
agentOverride: string | null,
): Promise<StepOutput> {
): Promise<CasRef> {
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
fail(`thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
return chain.start.workflow;
}
async function cmdThreadStepOnce(
storageRoot: string,
threadId: ThreadId,
agentOverride: string | null,
plog: ProcessLogger,
): Promise<StepOutput> {
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
failStep(plog, `thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
@@ -713,10 +759,17 @@ async function cmdThreadStepOnce(
const nextResult = await evaluate(workflow, context);
if (!nextResult.ok) {
fail(nextResult.error.message);
failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
}
plog.log(
PL_MODERATOR,
`moderator role=${nextResult.value.role} prompt=${nextResult.value.prompt}`,
null,
);
if (nextResult.value.role === END_ROLE) {
plog.log(PL_THREAD_ARCHIVED, `thread archived head=${headHash}`, null);
await archiveThread(storageRoot, threadId, workflowHash, headHash);
return {
workflow: workflowHash,
@@ -731,14 +784,20 @@ async function cmdThreadStepOnce(
const config = await loadWorkflowConfig(storageRoot);
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
plog.log(PL_AGENT_SPAWN, `spawning agent command=${agent.command}`, {
args: [...agent.args, threadId, role].join(" "),
});
loadDotenv({ path: getEnvPath(storageRoot) });
const newHead = spawnAgent(agent, threadId, role, edgePrompt);
const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt);
plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null);
// Re-create store to pick up nodes written by the agent subprocess
const uwfAfter = await createUwfStore(storageRoot);
const newNode = uwfAfter.store.get(newHead);
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
fail(`agent returned hash that is not a StepNode: ${newHead}`);
failStep(plog, `agent returned hash that is not a StepNode: ${newHead}`);
}
// Reload threads index to avoid overwriting changes made by the agent subprocess
@@ -750,11 +809,12 @@ async function cmdThreadStepOnce(
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
const afterResult = await evaluate(workflow, contextAfter);
if (!afterResult.ok) {
fail(afterResult.error.message);
failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
}
const done = afterResult.value.role === END_ROLE;
if (done) {
plog.log(PL_THREAD_ARCHIVED, `thread archived head=${newHead}`, null);
await archiveThread(storageRoot, threadId, workflowHash, newHead);
}
+10 -5
View File
@@ -55,11 +55,16 @@ function isJsonSchema(value: unknown): value is JSONSchema {
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
const result: Record<string, Transition[]> = {};
for (const [node, transitions] of Object.entries(graph)) {
result[node] = transitions.map((t) => ({
role: t.role,
condition: t.condition ?? null,
prompt: t.prompt ?? null,
}));
result[node] = transitions.map((t) => {
if (typeof t.prompt !== "string" || t.prompt.trim() === "") {
fail(`graph[${node}] transition to "${t.role}": prompt is required (non-empty string)`);
}
return {
role: t.role,
condition: t.condition ?? null,
prompt: t.prompt,
};
});
}
return result;
}
+2
View File
@@ -44,6 +44,8 @@ function isTransition(value: unknown): boolean {
const condition = value.condition;
return (
typeof value.role === "string" &&
typeof value.prompt === "string" &&
value.prompt.trim() !== "" &&
(condition === null || condition === undefined || typeof condition === "string")
);
}
@@ -1,9 +1,13 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-agent-kit";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { buildClaudeCodePrompt } from "../src/claude-code.js";
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
return {
threadId: "01JTEST0000000000000000000" as ThreadId,
edgePrompt: "Proceed with the assigned role.",
isFirstVisit: true,
workflow: {
roles: {
developer: {
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-agent-kit";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { buildHermesPrompt } from "../src/hermes.js";
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
return {
threadId: "01JTEST0000000000000000000" as ThreadId,
edgePrompt: "Proceed with the assigned role.",
isFirstVisit: true,
workflow: {
roles: {
developer: {
description: "TDD implementation per test spec",
goal: "Write code",
capabilities: ["coding"],
procedure: "1. Read spec\n2. Write code",
output: "List files changed",
frontmatter: "",
},
},
conditions: {},
graph: {},
},
role: "developer",
start: { prompt: "Fix the bug", workflowHash: "abc123", threadId: "t1" },
steps: [],
store: {} as AgentContext["store"],
outputFormatInstruction: "Use YAML frontmatter",
...overrides,
};
}
describe("buildHermesPrompt", () => {
test("first visit uses full role prompt and includes moderator instruction", () => {
const result = buildHermesPrompt(
makeCtx({ edgePrompt: "Focus on the failing test.", isFirstVisit: true }),
);
expect(result).toMatch(/^Use YAML frontmatter/);
expect(result).toContain("Write code");
expect(result).toContain("## Task\nFix the bug");
expect(result).toContain("## Moderator Instruction");
expect(result).toContain("Focus on the failing test.");
});
test("re-entry uses continuation prompt with edge instruction", () => {
const ctx = makeCtx({
isFirstVisit: false,
edgePrompt: "The reviewer rejected your work. Fix the issues.",
steps: [
{ role: "developer", output: { summary: "Initial fix" }, agent: "uwf-hermes" },
{ role: "reviewer", output: { approved: false }, agent: "uwf-hermes" },
],
});
const result = buildHermesPrompt(ctx);
expect(result).not.toContain("## Task");
expect(result).toContain("## What Happened Since Your Last Turn");
expect(result).toContain("## Moderator Instruction");
expect(result).toContain("The reviewer rejected your work.");
});
test("forced first visit via isFirstVisit uses initial prompt even when role appears in history", () => {
const result = buildHermesPrompt(
makeCtx({
isFirstVisit: true,
steps: [{ role: "developer", output: { done: true }, agent: "uwf-hermes" }],
edgePrompt: "Retry with a fresh approach.",
}),
);
expect(result).toContain("## Task");
expect(result).toContain("Retry with a fresh approach.");
expect(result).not.toContain("## What Happened Since Your Last Turn");
});
});
+4 -3
View File
@@ -45,12 +45,13 @@ function buildInitialPrompt(ctx: AgentContext): string {
if (historyBlock !== "") {
parts.push("", historyBlock);
}
parts.push("", "## Moderator Instruction", "", ctx.edgePrompt);
return parts.join("\n");
}
/** Assemble system prompt, task, and prior step outputs for Hermes. */
export function buildHermesPrompt(ctx: AgentContext): string {
if (ctx.edgePrompt !== null) {
if (!ctx.isFirstVisit) {
const parts: string[] = [];
if (ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, "");
@@ -86,7 +87,7 @@ async function prepareSession(
ctx: AgentContext,
cwd: string,
): Promise<PromptAttempt> {
if (ctx.edgePrompt === null || isResumeDisabled()) {
if (ctx.isFirstVisit || isResumeDisabled()) {
await client.connect(cwd);
return { useContinuation: false, resumed: false };
}
@@ -127,7 +128,7 @@ export function createHermesAgent(): () => Promise<void> {
});
async function runPrompt(ctx: AgentContext, useContinuation: boolean): Promise<AgentRunResult> {
const effectiveCtx = useContinuation ? ctx : { ...ctx, edgePrompt: null as string | null };
const effectiveCtx = useContinuation ? ctx : { ...ctx, isFirstVisit: true };
const fullPrompt = buildHermesPrompt(effectiveCtx);
const { text, sessionId, messages } = await client.prompt(fullPrompt);
const { detailHash } = await storePromptResult(ctx.store, sessionId, messages);
+14 -2
View File
@@ -21,6 +21,14 @@ function fail(message: string): never {
throw new Error(message);
}
function readEdgePrompt(): string {
const value = process.env.UWF_EDGE_PROMPT;
if (value === undefined || value === "") {
fail("UWF_EDGE_PROMPT environment variable is required");
}
return value;
}
function walkChain(store: Store, schemas: AgentStore["schemas"], headHash: CasRef): ChainState {
const headNode = store.get(headHash);
if (headNode === null) {
@@ -133,7 +141,8 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
}
const steps = await buildHistory(store, chain.stepsNewestFirst);
const edgePrompt = process.env.UWF_EDGE_PROMPT ?? null;
const edgePrompt = readEdgePrompt();
const isFirstVisit = !steps.some((s) => s.role === role);
return {
threadId,
@@ -144,6 +153,7 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
store,
outputFormatInstruction: "",
edgePrompt,
isFirstVisit,
};
}
@@ -180,7 +190,8 @@ export async function buildContextWithMeta(
}
const steps = await buildHistory(store, chain.stepsNewestFirst);
const edgePrompt = process.env.UWF_EDGE_PROMPT ?? null;
const edgePrompt = readEdgePrompt();
const isFirstVisit = !steps.some((s) => s.role === role);
return {
threadId,
@@ -191,6 +202,7 @@ export async function buildContextWithMeta(
store,
outputFormatInstruction: "",
edgePrompt,
isFirstVisit,
meta: { storageRoot, store, schemas, headHash, chain },
};
}
+7 -4
View File
@@ -13,11 +13,14 @@ export type AgentContext = ModeratorContext & {
*/
outputFormatInstruction: string;
/**
* Edge prompt from the graph transition that led to this role.
* null on first entry (use full role definition), non-null on re-entry
* (use as continuation instruction from moderator).
* Edge prompt from the graph transition that led to this role (UWF_EDGE_PROMPT).
* Always the real moderator instruction for this step.
*/
edgePrompt: string | null;
edgePrompt: string;
/**
* True when the current role has not appeared in steps history before this invocation.
*/
isFirstVisit: boolean;
};
export type AgentRunResult = {
+11 -2
View File
@@ -77,9 +77,11 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
};
}
}
const targetRole = t.target === "END" ? "$END" : t.target;
return {
role: t.target === "END" ? "$END" : t.target,
role: targetRole,
condition: condName,
prompt: `Transition to ${targetRole}.`,
};
});
@@ -87,7 +89,14 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
}
if (steps.length > 0) {
graph["$START"] = [{ role: steps[0].role.name, condition: null }];
const firstRole = steps[0].role.name;
graph["$START"] = [
{
role: firstRole,
condition: null,
prompt: `Begin workflow at role ${firstRole}.`,
},
];
}
return { name, description, roles, conditions, graph };
@@ -9,27 +9,27 @@ const solveIssueWorkflow: WorkflowPayload = {
roles: {
planner: {
description: "Creates implementation plan",
identity: "You are a planning agent.",
prepare: "Review the issue context.",
execute: "Create a step-by-step plan.",
report: "Output the plan and steps.",
outputSchema: "5GWKR8TN1V3JA",
goal: "You are a planning agent.",
capabilities: ["planning"],
procedure: "Create a step-by-step plan.",
output: "Output the plan and steps.",
frontmatter: "5GWKR8TN1V3JA",
},
developer: {
description: "Implements code changes",
identity: "You are a developer agent.",
prepare: "Load coding tools.",
execute: "Implement the plan.",
report: "List files changed and summary.",
outputSchema: "8CNWT4KR6D1HV",
goal: "You are a developer agent.",
capabilities: ["coding"],
procedure: "Implement the plan.",
output: "List files changed and summary.",
frontmatter: "8CNWT4KR6D1HV",
},
reviewer: {
description: "Reviews code changes",
identity: "You are a code reviewer.",
prepare: "Review project conventions.",
execute: "Review the implementation.",
report: "Approve or reject with comments.",
outputSchema: "1VPBG9SM5E7WK",
goal: "You are a code reviewer.",
capabilities: ["code-review"],
procedure: "Review the implementation.",
output: "Approve or reject with comments.",
frontmatter: "1VPBG9SM5E7WK",
},
},
conditions: {
@@ -43,15 +43,35 @@ const solveIssueWorkflow: WorkflowPayload = {
},
},
graph: {
$START: [{ role: "planner", condition: null }],
planner: [
{ role: "developer", condition: "needsClarification" },
{ role: "$END", condition: null },
$START: [
{
role: "planner",
condition: null,
prompt: "Start planning from the issue in the task.",
},
],
planner: [
{
role: "developer",
condition: "needsClarification",
prompt: "Clarification is needed; hand off to developer.",
},
{ role: "$END", condition: null, prompt: "Planning complete; end workflow." },
],
developer: [
{
role: "reviewer",
condition: null,
prompt: "Implementation done; send to reviewer.",
},
],
developer: [{ role: "reviewer", condition: null }],
reviewer: [
{ role: "developer", condition: "rejected" },
{ role: "$END", condition: null },
{
role: "developer",
condition: "rejected",
prompt: "Reviewer rejected; return to developer.",
},
{ role: "$END", condition: null, prompt: "Review passed; end workflow." },
],
},
};
@@ -69,7 +89,10 @@ function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
describe("evaluate", () => {
test("$START → first role (fallback)", async () => {
const result = await evaluate(solveIssueWorkflow, makeContext([]));
expect(result).toEqual({ ok: true, value: { role: "planner", prompt: null } });
expect(result).toEqual({
ok: true,
value: { role: "planner", prompt: "Start planning from the issue in the task." },
});
});
test("condition match (rejected → developer)", async () => {
@@ -82,7 +105,10 @@ describe("evaluate", () => {
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: { role: "developer", prompt: null } });
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Reviewer rejected; return to developer." },
});
});
test("fallback when condition does not match → $END", async () => {
@@ -95,7 +121,10 @@ describe("evaluate", () => {
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: { role: "$END", prompt: null } });
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "Review passed; end workflow." },
});
});
test("missing role in graph → error", async () => {
@@ -124,7 +153,10 @@ describe("evaluate", () => {
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: { role: "developer", prompt: null } });
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Clarification is needed; hand off to developer." },
});
});
test("$last returns most recent matching role's frontmatter", async () => {
@@ -137,10 +169,20 @@ describe("evaluate", () => {
},
},
graph: {
$START: [{ role: "developer", condition: null }],
$START: [
{
role: "developer",
condition: null,
prompt: "Begin development.",
},
],
developer: [
{ role: "$END", condition: "devFailed" },
{ role: "reviewer", condition: null },
{ role: "$END", condition: "devFailed", prompt: "Development failed; end." },
{
role: "reviewer",
condition: null,
prompt: "Development succeeded; review.",
},
],
},
};
@@ -165,7 +207,10 @@ describe("evaluate", () => {
},
]);
const result = await evaluate(workflow, context);
expect(result).toEqual({ ok: true, value: { role: "$END", prompt: null } });
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "Development failed; end." },
});
});
test("$first returns earliest matching role's frontmatter", async () => {
@@ -178,10 +223,20 @@ describe("evaluate", () => {
},
},
graph: {
$START: [{ role: "planner", condition: null }],
$START: [
{
role: "planner",
condition: null,
prompt: "Begin planning.",
},
],
planner: [
{ role: "$END", condition: "firstPlanReady" },
{ role: "developer", condition: null },
{ role: "$END", condition: "firstPlanReady", prompt: "First plan was ready; end." },
{
role: "developer",
condition: null,
prompt: "Plan not ready on first pass; implement.",
},
],
},
};
@@ -206,7 +261,10 @@ describe("evaluate", () => {
},
]);
const result = await evaluate(workflow, context);
expect(result).toEqual({ ok: true, value: { role: "$END", prompt: null } });
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "First plan was ready; end." },
});
});
test("$last returns undefined for unmatched role", async () => {
@@ -219,10 +277,20 @@ describe("evaluate", () => {
},
},
graph: {
$START: [{ role: "planner", condition: null }],
$START: [
{
role: "planner",
condition: null,
prompt: "Begin planning.",
},
],
planner: [
{ role: "$END", condition: "hasReviewer" },
{ role: "developer", condition: null },
{ role: "$END", condition: "hasReviewer", prompt: "Reviewer already ran; end." },
{
role: "developer",
condition: null,
prompt: "No reviewer yet; implement.",
},
],
},
};
@@ -236,6 +304,9 @@ describe("evaluate", () => {
]);
const result = await evaluate(workflow, context);
// no reviewer step → $exists returns false → fallback to developer
expect(result).toEqual({ ok: true, value: { role: "developer", prompt: null } });
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "No reviewer yet; implement." },
});
});
});
+2 -2
View File
@@ -90,7 +90,7 @@ export async function evaluate(
for (const transition of transitions) {
if (transition.condition === null) {
return { ok: true, value: { role: transition.role, prompt: transition.prompt ?? null } };
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
}
const conditionDef = workflow.conditions[transition.condition];
@@ -106,7 +106,7 @@ export async function evaluate(
return evalResult;
}
if (isTruthy(evalResult.value)) {
return { ok: true, value: { role: transition.role, prompt: transition.prompt ?? null } };
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
/** The result of moderator evaluation — which role to go to, and the edge prompt (if any). */
/** The result of moderator evaluation — which role to go to, and the edge prompt. */
export type EvaluateResult = {
role: string;
prompt: string | null;
prompt: string;
};
+2 -1
View File
@@ -26,10 +26,11 @@ const CONDITION_DEFINITION: JSONSchema = {
const TRANSITION: JSONSchema = {
type: "object",
required: ["role", "condition"],
required: ["role", "condition", "prompt"],
properties: {
role: { type: "string" },
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
prompt: { type: "string" },
},
additionalProperties: false,
};
+1 -1
View File
@@ -28,7 +28,7 @@ export type RoleDefinition = {
export type Transition = {
role: string;
condition: string | null;
prompt: string | null;
prompt: string;
};
export type ConditionDefinition = {
@@ -0,0 +1,81 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import { createProcessLogger } from "../src/process-logger/index.js";
function logDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
describe("createProcessLogger", () => {
let tmpDir: string;
afterEach(() => {
if (tmpDir !== undefined) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
test("writes init and log lines to dated JSONL under storage root", () => {
tmpDir = mkdtempSync(join(tmpdir(), "uwf-process-log-"));
const plog = createProcessLogger({
storageRoot: tmpDir,
context: { thread: "THREAD01", workflow: "WORKFLOW01" },
});
expect(plog.pid).toMatch(/^\d+-\d+$/);
plog.log("7NQW4HBT", "moderator selected role=planner", null);
const logPath = join(tmpDir, "logs", `${logDateKey(new Date())}.jsonl`);
const lines = readFileSync(logPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Record<string, string>);
expect(lines).toHaveLength(2);
expect(lines[0]?.tag).toBe("W9F3RK2M");
expect(lines[0]?.pid).toBe(plog.pid);
expect(lines[0]?.thread).toBe("THREAD01");
expect(lines[0]?.workflow).toBe("WORKFLOW01");
expect(lines[0]?.msg).toContain("process start");
expect(lines[0]?.msg).toContain("node=");
expect(lines[1]?.tag).toBe("7NQW4HBT");
expect(lines[1]?.msg).toBe("moderator selected role=planner");
expect(lines[1]?.thread).toBe("THREAD01");
expect(lines[1]?.workflow).toBe("WORKFLOW01");
});
test("creates logs directory when missing", () => {
tmpDir = mkdtempSync(join(tmpdir(), "uwf-process-log-"));
createProcessLogger({
storageRoot: tmpDir,
context: { thread: null, workflow: null },
});
mkdirSync(join(tmpDir, "logs"), { recursive: true });
expect(() =>
readFileSync(join(tmpDir, "logs", `${logDateKey(new Date())}.jsonl`), "utf8"),
).not.toThrow();
});
test("merges per-call context into the JSONL entry", () => {
tmpDir = mkdtempSync(join(tmpdir(), "uwf-process-log-"));
const plog = createProcessLogger({
storageRoot: tmpDir,
context: { thread: "T1", workflow: null },
});
plog.log("M3K8V9T1", "spawn agent", { command: "uwf-hermes", args: "tid role" });
const logPath = join(tmpDir, "logs", `${logDateKey(new Date())}.jsonl`);
const lines = readFileSync(logPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Record<string, string>);
const last = lines[lines.length - 1];
expect(last?.command).toBe("uwf-hermes");
expect(last?.args).toBe("tid role");
});
});
+14 -1
View File
@@ -26,6 +26,7 @@ uwf workflow list # list all registered workflows
uwf thread start <workflow> -p <prompt> # create a thread (no execution)
uwf thread step <thread-id> # execute one moderator→agent→extract cycle
[--agent <cmd>] # override agent command
[-c, --count <number>] # run multiple steps (default: 1)
uwf thread show <thread-id> # show thread head pointer
uwf thread list # list active threads
[--all] # include archived threads
@@ -56,6 +57,17 @@ uwf cas schema list # list all registered schemas
uwf cas schema get <hash> # show a schema by its type hash
\`\`\`
## Log Commands
\`\`\`
uwf log list # list log files with sizes
uwf log show # show all log entries
[--thread <thread-id>] # filter by thread ID
[--process <pid>] # filter by process ID
[--date <YYYY-MM-DD>] # filter by date
uwf log clean --before <date> # delete log files before given date
\`\`\`
## Global Options
\`\`\`
@@ -69,6 +81,7 @@ uwf -V, --version # print version
- **Thread**: A single workflow execution (ULID). State is an immutable CAS chain; active threads are indexed in \`threads.yaml\`.
- **Step**: One moderator→agent→extract cycle. Run \`uwf thread step\` repeatedly until \`$END\`.
- **CAS**: Content-Addressed Storage — all nodes are immutable and identified by hash.
- **Role**: Named actor with goal, capabilities, procedure, output, and meta; the moderator routes between roles.
- **Role**: Named actor with goal, capabilities, procedure, output, and frontmatter schema; the moderator routes between roles.
- **Edge Prompt**: Required instruction on each graph edge — the moderator's dispatch message to the agent.
`;
}
+7
View File
@@ -13,6 +13,13 @@ export {
validateFrontmatter,
} from "./frontmatter-markdown/index.js";
export { createLogger } from "./logger.js";
export { createProcessLogger } from "./process-logger/index.js";
export type {
CreateProcessLoggerOptions,
ProcessLogFn,
ProcessLogger,
ProcessLoggerContext,
} from "./process-logger/index.js";
export { normalizeRefsField } from "./refs-field.js";
export { err, ok } from "./result.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
+1 -21
View File
@@ -1,28 +1,8 @@
import { appendFileSync } from "node:fs";
import { CROCKFORD_BASE32_ALPHABET } from "./base32.js";
import { assertValidLogTag } from "./process-logger/log-tag.js";
import type { CreateLoggerOptions, LogFn } from "./types.js";
const TAG_LENGTH = 8;
const TAG_CHAR_SET: ReadonlySet<string> = new Set(CROCKFORD_BASE32_ALPHABET.split(""));
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 (!TAG_CHAR_SET.has(upper)) {
throw new Error(`invalid Crockford Base32 character in log tag: ${ch}`);
}
}
}
/** Append one JSONL log record: `{ tag, content, timestamp }` per RFC-001. */
export function createLogger(options: CreateLoggerOptions): LogFn {
if (options.sink.kind === "stderr") {
@@ -0,0 +1,7 @@
export { createProcessLogger } from "./process-logger.js";
export type {
CreateProcessLoggerOptions,
ProcessLogFn,
ProcessLogger,
ProcessLoggerContext,
} from "./types.js";
@@ -0,0 +1,21 @@
import { CROCKFORD_BASE32_ALPHABET } from "../base32.js";
const TAG_LENGTH = 8;
const TAG_CHAR_SET: ReadonlySet<string> = new Set(CROCKFORD_BASE32_ALPHABET.split(""));
export 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 (!TAG_CHAR_SET.has(upper)) {
throw new Error(`invalid Crockford Base32 character in log tag: ${ch}`);
}
}
}
@@ -0,0 +1,78 @@
import { appendFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { getDefaultWorkflowStorageRoot } from "../storage-root.js";
import { assertValidLogTag } from "./log-tag.js";
import type { CreateProcessLoggerOptions, ProcessLogger, ProcessLoggerContext } from "./types.js";
const INIT_TAG = "W9F3RK2M";
function logDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
function getProcessLogsDir(storageRoot: string): string {
return join(storageRoot, "logs");
}
function getProcessLogFilePath(storageRoot: string, date: Date): string {
return join(getProcessLogsDir(storageRoot), `${logDateKey(date)}.jsonl`);
}
function buildEntry(
processId: string,
tag: string,
msg: string,
baseContext: ProcessLoggerContext,
extra: Record<string, string> | null,
): Record<string, string> {
const entry: Record<string, string> = {
ts: new Date().toISOString(),
pid: processId,
tag: tag.toUpperCase(),
msg,
};
if (baseContext.thread !== null) {
entry.thread = baseContext.thread;
}
if (baseContext.workflow !== null) {
entry.workflow = baseContext.workflow;
}
if (extra !== null) {
for (const [key, value] of Object.entries(extra)) {
entry[key] = value;
}
}
return entry;
}
function appendEntry(filePath: string, entry: Record<string, string>): void {
appendFileSync(filePath, `${JSON.stringify(entry)}\n`, "utf8");
}
/** Process-scoped debug logger — append-only JSONL under `<storageRoot>/logs/YYYY-MM-DD.jsonl`. */
export function createProcessLogger(options: CreateProcessLoggerOptions): ProcessLogger {
const storageRoot = options.storageRoot ?? getDefaultWorkflowStorageRoot();
const processId = `${Date.now()}-${process.pid}`;
const baseContext = options.context;
const logFilePath = getProcessLogFilePath(storageRoot, new Date());
mkdirSync(getProcessLogsDir(storageRoot), { recursive: true });
const log: ProcessLogger["log"] = (tag, msg, context = null) => {
assertValidLogTag(tag);
appendEntry(logFilePath, buildEntry(processId, tag, msg, baseContext, context));
};
const argvSummary = JSON.stringify(process.argv);
const initParts = [`argv=${argvSummary}`, `node=${process.version}`];
if (baseContext.thread !== null) {
initParts.push(`thread=${baseContext.thread}`);
}
if (baseContext.workflow !== null) {
initParts.push(`workflow=${baseContext.workflow}`);
}
log(INIT_TAG, `process start ${initParts.join(" ")}`, null);
return { pid: processId, log };
}
@@ -0,0 +1,20 @@
export type ProcessLoggerContext = {
thread: string | null;
workflow: string | null;
};
export type CreateProcessLoggerOptions = {
storageRoot: string | null;
context: ProcessLoggerContext;
};
export type ProcessLogFn = (
tag: string,
msg: string,
context: Record<string, string> | null,
) => void;
export type ProcessLogger = {
pid: string;
log: ProcessLogFn;
};