openpencil/apps/web/server/utils/codegen-plan-store.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

354 lines
9.4 KiB
TypeScript

import type {
CodePlanFromAI,
PlannedChunk,
ChunkResult,
ChunkContract,
ChunkStatus,
ContractValidationResult,
Framework,
ExecutableChunkPayload,
ResolvedDepContract,
NodeSnapshot,
CodeGenProgress,
PenNode,
} from '@zseven-w/pen-types';
import { randomUUID } from 'node:crypto';
import { validateContract } from '@zseven-w/pen-mcp';
// --- Internal state ---
export interface PlanState {
plan: CodePlanFromAI;
nodes: Map<string, PenNode>;
order: Map<string, number>;
results: Map<string, ChunkResult>;
statuses: Map<string, ChunkStatus>;
lastActivity: number;
}
const plans = new Map<string, PlanState>();
const TTL_MS = 30 * 60 * 1000; // 30 minutes
function cleanExpired(): void {
const now = Date.now();
for (const [id, state] of plans) {
if (now - state.lastActivity > TTL_MS) plans.delete(id);
}
}
function touch(planId: string): void {
const state = plans.get(planId);
if (state) state.lastActivity = Date.now();
}
// --- Validation ---
function validatePlan(plan: CodePlanFromAI, nodeIndex: Map<string, PenNode>): string[] {
const errors: string[] = [];
const ids = new Set<string>();
// Duplicate chunkId
for (const chunk of plan.chunks) {
if (ids.has(chunk.id)) errors.push(`Duplicate chunkId: ${chunk.id}`);
ids.add(chunk.id);
}
if (errors.length > 0) return errors;
for (const chunk of plan.chunks) {
if (!chunk.nodeIds || chunk.nodeIds.length === 0) {
errors.push(`Chunk ${chunk.id} has no nodeIds`);
}
for (const depId of chunk.dependencies) {
if (!ids.has(depId)) {
errors.push(`Chunk ${chunk.id} depends on unknown chunk ${depId}`);
}
}
for (const nodeId of chunk.nodeIds) {
if (!nodeIndex.has(nodeId)) {
errors.push(`Chunk ${chunk.id}: node ${nodeId} not found in document`);
}
}
}
// Circular dependency detection via topological sort
if (errors.length === 0) {
const inDegree = new Map<string, number>();
const adj = new Map<string, string[]>();
for (const chunk of plan.chunks) {
inDegree.set(chunk.id, chunk.dependencies.length);
for (const dep of chunk.dependencies) {
const list = adj.get(dep) ?? [];
list.push(chunk.id);
adj.set(dep, list);
}
}
const queue = plan.chunks.filter((c) => c.dependencies.length === 0).map((c) => c.id);
let processed = 0;
while (queue.length > 0) {
const id = queue.shift()!;
processed++;
for (const next of adj.get(id) ?? []) {
const deg = (inDegree.get(next) ?? 1) - 1;
inDegree.set(next, deg);
if (deg === 0) queue.push(next);
}
}
if (processed < plan.chunks.length) {
const cycleIds = plan.chunks.filter((c) => (inDegree.get(c.id) ?? 0) > 0).map((c) => c.id);
errors.push(`Circular dependency: ${cycleIds.join(' → ')}`);
}
}
return errors;
}
function detectWarnings(plan: CodePlanFromAI): string[] {
const warnings: string[] = [];
const nodeToChunks = new Map<string, string[]>();
for (const chunk of plan.chunks) {
for (const nodeId of chunk.nodeIds) {
const list = nodeToChunks.get(nodeId) ?? [];
list.push(chunk.id);
nodeToChunks.set(nodeId, list);
}
}
for (const [nodeId, chunkIds] of nodeToChunks) {
if (chunkIds.length > 1) {
warnings.push(`Node ${nodeId} claimed by chunks: ${chunkIds.join(', ')}`);
}
}
return warnings;
}
// --- Node indexing ---
function indexNodes(nodes: PenNode[]): Map<string, PenNode> {
const map = new Map<string, PenNode>();
function walk(list: PenNode[]) {
for (const n of list) {
map.set(n.id, n);
const children = (n as { children?: PenNode[] }).children;
if (children) walk(children);
}
}
walk(nodes);
return map;
}
// --- Topological sort ---
function topoSort(chunks: PlannedChunk[]): PlannedChunk[] {
const inDegree = new Map<string, number>();
const adj = new Map<string, string[]>();
const byId = new Map<string, PlannedChunk>();
for (const c of chunks) {
byId.set(c.id, c);
inDegree.set(c.id, c.dependencies.length);
for (const dep of c.dependencies) {
const list = adj.get(dep) ?? [];
list.push(c.id);
adj.set(dep, list);
}
}
const result: PlannedChunk[] = [];
const queue = chunks.filter((c) => c.dependencies.length === 0).map((c) => c.id);
while (queue.length > 0) {
const id = queue.shift()!;
result.push(byId.get(id)!);
for (const next of adj.get(id) ?? []) {
const deg = (inDegree.get(next) ?? 1) - 1;
inDegree.set(next, deg);
if (deg === 0) queue.push(next);
}
}
return result;
}
// --- Chunk hydration ---
function hydrateChunk(
chunk: PlannedChunk,
order: number,
nodeIndex: Map<string, PenNode>,
depContracts: ResolvedDepContract[],
fullHydrate: boolean,
): ExecutableChunkPayload {
const nodes: NodeSnapshot[] = chunk.nodeIds
.map((id) => nodeIndex.get(id))
.filter((n): n is PenNode => n !== undefined)
.map((n) =>
fullHydrate
? (n as unknown as NodeSnapshot)
: ({ ...n, children: '...' } as unknown as NodeSnapshot),
);
return {
...chunk,
nodes,
order,
depContracts,
};
}
// --- Public API ---
export interface CreatePlanResult {
planId: string;
executionPlan: ExecutableChunkPayload[];
warnings: string[];
}
export function createPlan(plan: CodePlanFromAI, allNodes: PenNode[]): CreatePlanResult {
cleanExpired();
const nodeIndex = indexNodes(allNodes);
const errors = validatePlan(plan, nodeIndex);
if (errors.length > 0) {
throw new Error(errors.join('; '));
}
const warnings = detectWarnings(plan);
const sorted = topoSort(plan.chunks);
const orderMap = new Map<string, number>();
sorted.forEach((c, i) => orderMap.set(c.id, i));
const planId = randomUUID();
const state: PlanState = {
plan,
nodes: nodeIndex,
order: orderMap,
results: new Map(),
statuses: new Map(),
lastActivity: Date.now(),
};
for (const c of plan.chunks) {
state.statuses.set(c.id, 'pending');
}
plans.set(planId, state);
const executionPlan = sorted.map((chunk, i) => hydrateChunk(chunk, i, nodeIndex, [], i === 0));
return { planId, executionPlan, warnings };
}
export function getPlan(planId: string): PlanState | undefined {
return plans.get(planId);
}
export interface SubmitChunkResult {
validation: ContractValidationResult;
progress: CodeGenProgress[];
nextChunk?: ExecutableChunkPayload;
}
export function submitChunkResult(
planId: string,
result: ChunkResult,
statusOverride?: 'failed' | 'skipped',
): SubmitChunkResult {
const state = plans.get(planId);
if (!state) throw new Error(`Plan ${planId} not found`);
touch(planId);
const validation = validateContract(result);
let status: ChunkStatus;
if (statusOverride === 'failed' || statusOverride === 'skipped') {
status = statusOverride;
} else if (validation.valid) {
status = 'done';
} else {
status = 'degraded';
}
state.results.set(result.chunkId, result);
state.statuses.set(result.chunkId, status);
const progress: CodeGenProgress[] = state.plan.chunks.map((c) => ({
step: 'chunk' as const,
chunkId: c.id,
name: c.name,
status: state.statuses.get(c.id) ?? 'pending',
result: state.results.get(c.id),
}));
let nextChunk: ExecutableChunkPayload | undefined;
const sorted = topoSort(state.plan.chunks);
for (const chunk of sorted) {
const chunkStatus = state.statuses.get(chunk.id);
if (chunkStatus !== 'pending') continue;
const depsReady = chunk.dependencies.every((depId) => {
const depStatus = state.statuses.get(depId);
return (
depStatus === 'done' ||
depStatus === 'degraded' ||
depStatus === 'failed' ||
depStatus === 'skipped'
);
});
if (depsReady) {
const depContracts: ResolvedDepContract[] = chunk.dependencies.map((depId) => {
const depStatus = state.statuses.get(depId);
if (depStatus === 'failed' || depStatus === 'skipped') return null;
return state.results.get(depId)?.contract ?? null;
});
nextChunk = hydrateChunk(
chunk,
state.order.get(chunk.id) ?? 0,
state.nodes,
depContracts,
true,
);
break;
}
}
return { validation, progress, nextChunk };
}
export interface AssemblePlanResult {
chunks: ChunkResult[];
contracts: ChunkContract[];
dependencyGraph: Record<string, string[]>;
degraded: boolean;
}
export function assemblePlan(planId: string, _framework: Framework): AssemblePlanResult {
const state = plans.get(planId);
if (!state) throw new Error(`Plan ${planId} not found`);
const sorted = topoSort(state.plan.chunks);
const chunks: ChunkResult[] = [];
const contracts: ChunkContract[] = [];
const dependencyGraph: Record<string, string[]> = {};
let degraded = false;
for (const chunk of sorted) {
const result = state.results.get(chunk.id);
if (result) {
chunks.push(result);
contracts.push(result.contract);
}
dependencyGraph[chunk.id] = chunk.dependencies;
const status = state.statuses.get(chunk.id);
if (status !== 'done') degraded = true;
}
// Terminal operation — clear cache
plans.delete(planId);
return { chunks, contracts, dependencyGraph, degraded };
}
export function cleanPlan(planId: string): { ok: boolean; deleted: boolean } {
const existed = plans.has(planId);
if (existed) plans.delete(planId);
return { ok: true, deleted: existed };
}