97637ad831
This resolves issue #573 by moving uwf's CAS directory from ~/.uncaged/workflow/cas/ to the shared ~/.uncaged/json-cas/ location. Changes: - Added getGlobalCasDir() function with UNCAGED_CAS_DIR support - Updated createUwfStore() to use global CAS directory - Added comprehensive test coverage (11 new tests) - Updated all existing tests for environment isolation - Updated documentation (CLAUDE.md, README.md) Benefits: - Cross-tool visibility: json-cas CLI can read uwf-created nodes - Schema sharing: both tools access same schema registry - Future-proofing: enables json-cas render/verbose for uwf data Fixes #573 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
339 lines
9.2 KiB
TypeScript
339 lines
9.2 KiB
TypeScript
import type { BootstrapCapableStore } from "@uncaged/json-cas";
|
|
import type {
|
|
CasRef,
|
|
StartEntry,
|
|
StepEntry,
|
|
StepNodePayload,
|
|
ThreadForkOutput,
|
|
ThreadId,
|
|
ThreadStepsOutput,
|
|
} from "@uncaged/workflow-protocol";
|
|
import { generateUlid } from "@uncaged/workflow-util";
|
|
import { createUwfStore, loadThreadsIndex, saveThreadsIndex } from "../store.js";
|
|
import {
|
|
collectOrderedSteps,
|
|
expandDeep,
|
|
expandOutput,
|
|
fail,
|
|
resolveHeadHash,
|
|
walkChain,
|
|
} from "./shared.js";
|
|
|
|
type TurnToolCall = {
|
|
name: string;
|
|
args: string;
|
|
};
|
|
|
|
type TurnData = {
|
|
index: number;
|
|
role: string;
|
|
content: string;
|
|
toolCalls: TurnToolCall[] | null;
|
|
};
|
|
|
|
/**
|
|
* List all steps in a thread (previously: thread steps)
|
|
*/
|
|
export async function cmdStepList(
|
|
storageRoot: string,
|
|
threadId: ThreadId,
|
|
): Promise<ThreadStepsOutput> {
|
|
const headHash = await resolveHeadHash(storageRoot, threadId);
|
|
const uwf = await createUwfStore(storageRoot);
|
|
const chain = walkChain(uwf, headHash);
|
|
|
|
const startNode = uwf.store.get(chain.startHash);
|
|
if (startNode === null) {
|
|
fail(`StartNode not found: ${chain.startHash}`);
|
|
}
|
|
|
|
const startEntry: StartEntry = {
|
|
hash: chain.startHash,
|
|
workflow: chain.start.workflow,
|
|
prompt: chain.start.prompt,
|
|
timestamp: startNode.timestamp,
|
|
};
|
|
|
|
const stepEntries: StepEntry[] = [];
|
|
const ordered = collectOrderedSteps(uwf, headHash, chain);
|
|
|
|
for (const item of ordered) {
|
|
stepEntries.push({
|
|
hash: item.hash,
|
|
role: item.payload.role,
|
|
output: expandOutput(uwf, item.payload.output),
|
|
detail: item.payload.detail ?? null,
|
|
agent: item.payload.agent,
|
|
timestamp: item.timestamp,
|
|
durationMs: item.payload.completedAtMs - item.payload.startedAtMs,
|
|
});
|
|
}
|
|
|
|
return {
|
|
thread: threadId,
|
|
workflow: chain.start.workflow,
|
|
steps: [startEntry, ...stepEntries],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Show details of a specific step (previously: thread step-details)
|
|
*/
|
|
export async function cmdStepShow(storageRoot: string, stepHash: CasRef): Promise<unknown> {
|
|
const uwf = await createUwfStore(storageRoot);
|
|
const node = uwf.store.get(stepHash);
|
|
if (node === null) {
|
|
fail(`CAS node not found: ${stepHash}`);
|
|
}
|
|
if (node.type !== uwf.schemas.stepNode) {
|
|
fail(`node ${stepHash} is not a StepNode`);
|
|
}
|
|
const payload = node.payload as StepNodePayload;
|
|
if (!payload.detail) {
|
|
fail(`step ${stepHash} has no detail`);
|
|
}
|
|
return expandDeep(uwf.store, payload.detail);
|
|
}
|
|
|
|
/**
|
|
* Fork a thread from a specific step (previously: thread fork)
|
|
*/
|
|
export async function cmdStepFork(
|
|
storageRoot: string,
|
|
stepHash: CasRef,
|
|
): Promise<ThreadForkOutput> {
|
|
const uwf = await createUwfStore(storageRoot);
|
|
const node = uwf.store.get(stepHash);
|
|
if (node === null) {
|
|
fail(`CAS node not found: ${stepHash}`);
|
|
}
|
|
if (node.type !== uwf.schemas.startNode && node.type !== uwf.schemas.stepNode) {
|
|
fail(`node ${stepHash} is not a StartNode or StepNode`);
|
|
}
|
|
|
|
const newThreadId = generateUlid(Date.now()) as ThreadId;
|
|
const index = await loadThreadsIndex(storageRoot);
|
|
index[newThreadId] = stepHash;
|
|
await saveThreadsIndex(storageRoot, index);
|
|
|
|
return {
|
|
thread: newThreadId,
|
|
forkedFrom: {
|
|
step: stepHash,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load and validate step detail node from CAS store
|
|
*/
|
|
function loadStepDetail(store: BootstrapCapableStore, detailRef: CasRef): Record<string, unknown> {
|
|
const detailNode = store.get(detailRef);
|
|
if (detailNode === null) {
|
|
fail(`detail node not found: ${detailRef}`);
|
|
}
|
|
return detailNode.payload as Record<string, unknown>;
|
|
}
|
|
|
|
function parseTurnToolCalls(raw: unknown): TurnToolCall[] | null {
|
|
if (!Array.isArray(raw) || raw.length === 0) {
|
|
return null;
|
|
}
|
|
const calls: TurnToolCall[] = [];
|
|
for (const entry of raw) {
|
|
if (typeof entry !== "object" || entry === null) {
|
|
continue;
|
|
}
|
|
const record = entry as Record<string, unknown>;
|
|
const name = record.name;
|
|
const args = record.args;
|
|
if (typeof name === "string") {
|
|
calls.push({ name, args: typeof args === "string" ? args : "" });
|
|
}
|
|
}
|
|
return calls.length > 0 ? calls : null;
|
|
}
|
|
|
|
function formatTurnBody(turn: TurnData): string {
|
|
const parts: string[] = [];
|
|
parts.push(`**Turn role:** ${turn.role}`);
|
|
|
|
if (turn.toolCalls !== null) {
|
|
for (const call of turn.toolCalls) {
|
|
const argsSuffix = call.args !== "" ? ` — \`${call.args}\`` : "";
|
|
parts.push(`- **${call.name}**${argsSuffix}`);
|
|
}
|
|
}
|
|
|
|
if (turn.content !== "") {
|
|
if (parts.length > 0) {
|
|
parts.push("");
|
|
}
|
|
parts.push(turn.content);
|
|
}
|
|
|
|
return parts.join("\n");
|
|
}
|
|
|
|
function parseSingleTurn(
|
|
store: BootstrapCapableStore,
|
|
turnRef: unknown,
|
|
fallbackIndex: number,
|
|
): TurnData | null {
|
|
if (typeof turnRef !== "string") {
|
|
return null;
|
|
}
|
|
const turnNode = store.get(turnRef as CasRef);
|
|
if (turnNode === null) {
|
|
return null;
|
|
}
|
|
const turn = turnNode.payload as Record<string, unknown>;
|
|
const content = typeof turn.content === "string" ? turn.content : "";
|
|
const toolCalls = parseTurnToolCalls(turn.toolCalls);
|
|
if (content === "" && toolCalls === null) {
|
|
return null;
|
|
}
|
|
return {
|
|
index: typeof turn.index === "number" ? turn.index : fallbackIndex,
|
|
role: typeof turn.role === "string" ? turn.role : "assistant",
|
|
content,
|
|
toolCalls,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load all turn nodes from CAS store and extract display fields
|
|
*/
|
|
function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[] {
|
|
if (!Array.isArray(turns) || turns.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const turnData: TurnData[] = [];
|
|
for (const turnRef of turns) {
|
|
const parsed = parseSingleTurn(store, turnRef, turnData.length);
|
|
if (parsed !== null) {
|
|
turnData.push(parsed);
|
|
}
|
|
}
|
|
return turnData;
|
|
}
|
|
|
|
/**
|
|
* Select turns that fit within quota, working backwards from most recent
|
|
*/
|
|
function selectTurnsForQuota(turnData: TurnData[], availableQuota: number): TurnData[] {
|
|
const selectedTurns: TurnData[] = [];
|
|
let totalChars = 0;
|
|
|
|
for (let i = turnData.length - 1; i >= 0; i--) {
|
|
const turn = turnData[i];
|
|
if (turn === undefined) continue;
|
|
|
|
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
|
|
const turnBlock = turnHeader + formatTurnBody(turn);
|
|
const separatorCost = selectedTurns.length > 0 ? 2 : 0;
|
|
const addCost = turnBlock.length + separatorCost;
|
|
|
|
if (totalChars + addCost > availableQuota && selectedTurns.length > 0) {
|
|
break;
|
|
}
|
|
|
|
selectedTurns.unshift(turn);
|
|
totalChars += addCost;
|
|
}
|
|
|
|
return selectedTurns;
|
|
}
|
|
|
|
/**
|
|
* Assemble final markdown output from header and selected turns
|
|
*/
|
|
function formatStepMarkdown(
|
|
stepHash: CasRef,
|
|
role: string,
|
|
agent: string,
|
|
turnData: TurnData[],
|
|
selectedTurns: TurnData[],
|
|
): string {
|
|
const parts: string[] = [];
|
|
parts.push(`# Step ${stepHash}`);
|
|
parts.push("");
|
|
parts.push(`**Role:** ${role}`);
|
|
parts.push(`**Agent:** ${agent}`);
|
|
|
|
if (selectedTurns.length === 0) {
|
|
return parts.join("\n");
|
|
}
|
|
|
|
const skippedCount = turnData.length - selectedTurns.length;
|
|
if (skippedCount > 0) {
|
|
parts.push("");
|
|
parts.push(`_[Earlier turns omitted due to quota. Use --quota to increase.]_`);
|
|
}
|
|
|
|
for (const turn of selectedTurns) {
|
|
parts.push("");
|
|
parts.push(`## Turn ${turn.index + 1}`);
|
|
parts.push("");
|
|
parts.push(formatTurnBody(turn));
|
|
}
|
|
|
|
return parts.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Read a step's agent turns as human-readable markdown with quota enforcement
|
|
*/
|
|
export async function cmdStepRead(
|
|
storageRoot: string,
|
|
stepHash: CasRef,
|
|
quota: number,
|
|
showPrompt: boolean,
|
|
): Promise<string> {
|
|
const uwf = await createUwfStore(storageRoot);
|
|
const node = uwf.store.get(stepHash);
|
|
if (node === null) {
|
|
fail(`CAS node not found: ${stepHash}`);
|
|
}
|
|
if (node.type !== uwf.schemas.stepNode) {
|
|
fail(`node ${stepHash} is not a StepNode`);
|
|
}
|
|
const payload = node.payload as StepNodePayload;
|
|
|
|
// --prompt mode: show the assembled prompt that was sent to the agent
|
|
if (showPrompt) {
|
|
const promptRef = (payload as Record<string, unknown>).assembledPrompt;
|
|
if (typeof promptRef !== "string") {
|
|
return `# Step ${stepHash}\n\n_Prompt not recorded (legacy step)._`;
|
|
}
|
|
const promptNode = uwf.store.get(promptRef as CasRef);
|
|
if (promptNode === null) {
|
|
return `# Step ${stepHash}\n\n_Prompt CAS node not found: ${promptRef}_`;
|
|
}
|
|
const promptText =
|
|
typeof promptNode.payload === "string"
|
|
? promptNode.payload
|
|
: JSON.stringify(promptNode.payload);
|
|
return `# Step ${stepHash}\n\n**Role:** ${payload.role}\n**Agent:** ${payload.agent}\n\n## Prompt\n\n${promptText}`;
|
|
}
|
|
|
|
if (payload.detail === null) {
|
|
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
|
}
|
|
|
|
const detail = loadStepDetail(uwf.store, payload.detail);
|
|
const turnData = loadTurnData(uwf.store, detail.turns);
|
|
|
|
if (turnData.length === 0) {
|
|
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
|
}
|
|
|
|
const headerSection = formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
|
const BUFFER = 200;
|
|
const availableQuota = quota - headerSection.length - BUFFER;
|
|
const selectedTurns = selectTurnsForQuota(turnData, availableQuota);
|
|
|
|
return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
|
|
}
|