lakehouse/bot/apply.ts
profit f44b6b3e6b Control-plane pivot: Phase 38-44 plan + bot scaffold
Direction shift 2026-04-22: docs/CONTROL_PLANE_PRD.md becomes the
long-horizon architecture target. Existing Lakehouse (docs/PRD.md,
Phases 0-37) is preserved as the reference implementation and first
consumer. New 6-layer architecture:

  L1 Universal API /v1/chat /v1/usage /v1/sessions /v1/tools /v1/context
  L2 Routing & Policy Engine (rules, fallback chains, cost gating)
  L3 Provider Adapter Layer (Ollama + OpenRouter + Gemini + Claude)
  L4 Knowledge + Memory + Playbooks (already built)
  L5 Execution Loop (scenarios + bot/cycle.ts instances)
  L6 Observability + token accounting

Phases 38-44 sequenced with detailed per-phase specs in the PRD.
Current scope: staffing domain (synthetic workers_500k, contracts,
emails, SMS, playbooks). DevOps (Terraform/Ansible) is long-horizon
target — architecture-compatible but not current.

Files added:
- docs/CONTROL_PLANE_PRD.md — 6-layer architecture, Phase 38-44
  sequencing with staffing-first Truth Layer + Validation pipeline
- bot/ — manual-only PR bot scaffold. First consumer test-bed for
  /v1/chat (Phase 38). Mem0-aligned ADD/UPDATE/NOOP apply semantics;
  KB feedback loop reads prior cycles on same gap and injects into
  cloud prompt so bot cycles compound like scenario.ts runs do.
- tests/multi-agent/run_stress.ts — the 6-task diverse stress test
  referenced in the previous commit but missing from its staging

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:43:31 -05:00

87 lines
2.8 KiB
TypeScript

// Apply a proposal to disk with Mem0-aligned ADD / UPDATE / NOOP
// semantics. Whole-file writes only — never deletes. All paths
// validated repo-relative + path-traversal-free.
//
// Three outcomes per file:
// ADD — file didn't exist, new content written (is_new must be true)
// UPDATE — file existed, content differs from current, overwritten
// NOOP — file existed, content identical to current, nothing written
//
// The NOOP case is important: a cloud proposal that "re-derives" an
// existing file shouldn't churn the repo or burn test cycles.
import { writeFile, readFile, mkdir, access } from "node:fs/promises";
import { dirname, resolve, relative } from "node:path";
import type { ApplyOutcome, Proposal } from "./types.ts";
const REPO_ROOT = "/home/profit/lakehouse";
export async function applyProposal(p: Proposal): Promise<ApplyOutcome> {
const added: string[] = [];
const updated: string[] = [];
const noop: string[] = [];
const errors: string[] = [];
for (const f of p.files) {
const err = validatePath(f.path);
if (err) {
errors.push(`${f.path}: ${err}`);
continue;
}
const abs = resolve(REPO_ROOT, f.path);
try {
const exists = await fileExists(abs);
if (f.is_new && exists) {
errors.push(`${f.path}: is_new=true but file exists`);
continue;
}
if (!f.is_new && !exists) {
// Proposal claims to update a file that doesn't exist.
// Refuse rather than silently ADD — model is confused about
// repo state, and we want that surfaced, not papered over.
errors.push(`${f.path}: is_new=false but file does not exist`);
continue;
}
if (!f.is_new) {
// UPDATE candidate — compare to detect NOOP.
const current = await readFile(abs, "utf8");
if (current === f.content) {
noop.push(f.path);
continue;
}
await writeFile(abs, f.content);
updated.push(f.path);
} else {
// ADD — file doesn't exist yet.
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, f.content);
added.push(f.path);
}
} catch (e) {
errors.push(`${f.path}: ${(e as Error).message}`);
}
}
return { added, updated, noop, errors };
}
function validatePath(p: string): string | null {
if (!p) return "empty path";
if (p.startsWith("/")) return "must be repo-relative";
if (p.startsWith("..") || p.includes("/../")) return "path traversal";
const abs = resolve(REPO_ROOT, p);
const rel = relative(REPO_ROOT, abs);
if (rel.startsWith("..")) return "resolves outside repo";
return null;
}
async function fileExists(abs: string): Promise<boolean> {
try {
await access(abs);
return true;
} catch {
return false;
}
}