From e72d4f174b6dd7937d8ef2dfdd8ca65f55510ee3 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:26:52 -0600 Subject: [PATCH] writes: auto-sync / continuous self-heal loop (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in. With --env + --poll, `--auto-sync apply` triggers the project's ApplyOp (heal the cloud toward source) on a polled drift; `pull-request` triggers the ReconcileOp (adopt live → PR). Off by default. Delegated + gated as ever: behold triggers a committed Op on the executor; a gated destructive apply still pauses for Approve — auto-sync never approves. Source edits don't auto-sync (new declaration = new desired state, not drift); only polled drift does. - src/op-runner.ts: OpRunner — one place an Op is triggered, shared by the HTTP routes and the auto-sync loop, so both honour one running-guard and the post-op frame capture. Replaces the inline run logic in the route. - src/autosync.ts: AutoSyncMode + pickAutoSyncOp (pure; off/running/no-op → null). - server: poll drift → pickAutoSyncOp → runner.trigger; /api/ops exposes autoSync. - cli: --auto-sync off|apply|pull-request (needs --env + --poll); boot log. - web: auto-sync banner; the trigger streams to the now-line. tsc + 48 tests (+6 autosync). Auto-apply not live-fired (would mutate real infra); decision logic unit-tested, wiring smoke-verified. --- src/autosync.test.ts | 40 +++++++++++++++++++++++++ src/autosync.ts | 29 +++++++++++++++++++ src/cli.ts | 27 +++++++++++++---- src/op-runner.ts | 51 ++++++++++++++++++++++++++++++++ src/server.ts | 69 +++++++++++++++++++++++++++----------------- web/app.js | 11 ++++++- 6 files changed, 194 insertions(+), 33 deletions(-) create mode 100644 src/autosync.test.ts create mode 100644 src/autosync.ts create mode 100644 src/op-runner.ts diff --git a/src/autosync.test.ts b/src/autosync.test.ts new file mode 100644 index 0000000..680d888 --- /dev/null +++ b/src/autosync.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { pickAutoSyncOp, isAutoSyncMode } from "./autosync.ts"; +import type { OpInfo } from "./ops.ts"; + +const ops: OpInfo[] = [ + { name: "prod-apply", kind: "apply", env: "prod", gate: "approve-prod-apply" }, + { name: "prod-reconcile", kind: "reconcile", env: "prod" }, +]; + +describe("pickAutoSyncOp", () => { + it("apply mode picks the ApplyOp", () => { + expect(pickAutoSyncOp("apply", ops, null)?.name).toBe("prod-apply"); + }); + + it("pull-request mode picks the ReconcileOp", () => { + expect(pickAutoSyncOp("pull-request", ops, null)?.name).toBe("prod-reconcile"); + }); + + it("off mode picks nothing", () => { + expect(pickAutoSyncOp("off", ops, null)).toBeNull(); + }); + + it("picks nothing while an Op is already running (no concurrent triggers)", () => { + expect(pickAutoSyncOp("apply", ops, "prod-apply")).toBeNull(); + }); + + it("picks nothing when the project has no matching Op", () => { + expect(pickAutoSyncOp("apply", [{ name: "r", kind: "reconcile" }], null)).toBeNull(); + expect(pickAutoSyncOp("pull-request", [{ name: "a", kind: "apply" }], null)).toBeNull(); + }); +}); + +describe("isAutoSyncMode", () => { + it("accepts valid modes, rejects others", () => { + expect(isAutoSyncMode("apply")).toBe(true); + expect(isAutoSyncMode("pull-request")).toBe(true); + expect(isAutoSyncMode("off")).toBe(true); + expect(isAutoSyncMode("nonsense")).toBe(false); + }); +}); diff --git a/src/autosync.ts b/src/autosync.ts new file mode 100644 index 0000000..b430eb1 --- /dev/null +++ b/src/autosync.ts @@ -0,0 +1,29 @@ +/** + * Auto-sync (#29) — the opt-in self-heal loop. When `--poll` detects the estate + * moved and auto-sync is on, behold triggers the project's committed Op: + * - `apply` → the ApplyOp (heal the cloud toward source) + * - `pull-request` → the ReconcileOp (adopt the cloud into source via a PR) + * Off by default. Delegated + gated as ever: behold triggers a committed Op on + * the executor; a gated (destructive) apply still pauses for Approve — auto-sync + * never approves. This module is the pure decision; the loop lives in the server. + */ +import type { OpInfo } from "./ops.ts"; + +export type AutoSyncMode = "off" | "apply" | "pull-request"; + +export const AUTO_SYNC_MODES: AutoSyncMode[] = ["off", "apply", "pull-request"]; + +export function isAutoSyncMode(v: string): v is AutoSyncMode { + return (AUTO_SYNC_MODES as string[]).includes(v); +} + +/** + * Which Op (if any) auto-sync should trigger for a drift event. Returns null + * when it's off, an Op is already running, or the project has no matching Op. + * Pure — unit-tested. + */ +export function pickAutoSyncOp(mode: AutoSyncMode, ops: OpInfo[], running: string | null): OpInfo | null { + if (mode === "off" || running) return null; + const kind = mode === "apply" ? "apply" : "reconcile"; + return ops.find((o) => o.kind === kind) ?? null; +} diff --git a/src/cli.ts b/src/cli.ts index 74e5224..da6343f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { resolve } from "node:path"; import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { startServer } from "./server.ts"; +import { isAutoSyncMode, type AutoSyncMode } from "./autosync.ts"; const USAGE = `behold — a live control plane on chant (read-only core) @@ -17,10 +18,13 @@ Usage: browser, coloured by drift. Read-only — never mutates. Options: - --port Port (default 4600). - --env Environment name — turns on the live drift overlay. - --poll Re-query live drift every and push updates (needs --env). - -h, --help This text. + --port Port (default 4600). + --env Environment name — turns on the live drift overlay. + --poll Re-query live drift every and push updates (needs --env). + --auto-sync On a polled drift, trigger a committed Op (needs --env + --poll). + off (default) | apply (heal via ApplyOp) | pull-request + (adopt via ReconcileOp). Gated applies still wait for Approve. + -h, --help This text. `; export async function run(argv: string[]): Promise { @@ -40,13 +44,21 @@ export async function run(argv: string[]): Promise { let port = 4600; let env: string | undefined; let pollSecs: number | undefined; + let autoSync: AutoSyncMode = "off"; for (let i = 0; i < rest.length; i++) { const a = rest[i]; if (a === "--port") port = Number(rest[++i]); else if (a === "--env") env = rest[++i]; else if (a === "--poll") pollSecs = Number(rest[++i]); - else if (a === "-h" || a === "--help") { + else if (a === "--auto-sync") { + const m = rest[++i]; + if (!m || !isAutoSyncMode(m)) { + process.stderr.write("behold serve: --auto-sync must be off | apply | pull-request\n"); + process.exit(2); + } + autoSync = m; + } else if (a === "-h" || a === "--help") { process.stdout.write(USAGE); return; } else if (!a.startsWith("-") && projectDir === undefined) projectDir = a; @@ -72,12 +84,17 @@ export async function run(argv: string[]): Promise { process.stderr.write("behold serve: --poll needs --env (it polls the live overlay)\n"); process.exit(2); } + if (autoSync !== "off" && (!env || pollSecs === undefined)) { + process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n"); + process.exit(2); + } startServer({ projectDir: resolve(projectDir), port, ...(env ? { env } : {}), ...(pollSecs !== undefined ? { pollSecs } : {}), + ...(autoSync !== "off" ? { autoSync } : {}), }); } diff --git a/src/op-runner.ts b/src/op-runner.ts new file mode 100644 index 0000000..5fb7a3c --- /dev/null +++ b/src/op-runner.ts @@ -0,0 +1,51 @@ +/** + * OpRunner — the single place a delegated Op is triggered. Both the HTTP route + * (Sync/Adopt) and the auto-sync loop (#29) go through it, so they share one + * "an Op is already running" guard and one post-op capture path. behold never + * applies; it runs `chant run ` on the executor and streams the phases. + */ +import { runChantStream } from "./chant.ts"; +import { extractPrUrl } from "./adopt.ts"; +import type { Broadcaster } from "./events.ts"; + +export interface OpRunnerDeps { + projectDir: string; + broadcaster: Broadcaster; + /** After an op finishes: capture a lanes frame for the op's env (#25). */ + onDone: (opEnv: string | undefined) => Promise | void; +} + +export class OpRunner { + private current: string | null = null; + + constructor(private deps: OpRunnerDeps) {} + + /** Name of the running op, or null. */ + get running(): string | null { + return this.current; + } + + /** + * Start `chant run ` unless one is already running. Returns true if it + * started, false if busy. Streams output as `op` events; lifts a PR URL to a + * `pr` event; on completion captures a frame and emits `changed`. + */ + trigger(name: string, opEnv?: string): boolean { + if (this.current) return false; + const { projectDir, broadcaster } = this.deps; + broadcaster.emit("op", `▶ chant run ${name}`); + const op = runChantStream(["run", name], projectDir, (line) => { + broadcaster.emit("op", line); + const pr = extractPrUrl(line); + if (pr) broadcaster.emit("pr", pr); + }); + this.current = name; + void op.done.then(async (code) => { + broadcaster.emit("op", `■ ${name} exited ${code}`); + await this.deps.onDone(opEnv); + broadcaster.emit("changed"); + this.current = null; + }); + return true; + } +} diff --git a/src/server.ts b/src/server.ts index f4cf94d..dd9f94f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,13 +13,15 @@ import { serve } from "@hono/node-server"; import { fileURLToPath } from "node:url"; import { dirname, join, relative } from "node:path"; import type { GraphIR } from "@intentius/chant"; -import { graphIr, runChantStream, runChantRaw, type GraphOptions } from "./chant.ts"; +import { graphIr, runChantRaw, type GraphOptions } from "./chant.ts"; import { renderGraph } from "./render.ts"; import { discoverOps } from "./ops.ts"; -import { LIVE_IMPORT_LEXICONS, extractPrUrl } from "./adopt.ts"; +import { LIVE_IMPORT_LEXICONS } from "./adopt.ts"; import { detectProject } from "./project.ts"; import { nodeDiff, nodeObserved, type LiveDiffJson } from "./diff.ts"; import { classifyHealth } from "./health.ts"; +import { OpRunner } from "./op-runner.ts"; +import { pickAutoSyncOp, type AutoSyncMode } from "./autosync.ts"; import { Broadcaster, watchSource } from "./events.ts"; import { startDriftPoll } from "./poll.ts"; import { FrameBuffer } from "./frames.ts"; @@ -34,6 +36,9 @@ export interface ServerOptions { env?: string; /** Seconds between live-drift polls (#4). Only with `env`; off when unset. */ pollSecs?: number; + /** Auto-sync mode (#29): on a polled drift, trigger the ApplyOp ("apply") or + * ReconcileOp ("pull-request"). Off by default; needs `env` + `pollSecs`. */ + autoSync?: AutoSyncMode; port: number; } @@ -79,6 +84,11 @@ export function createApp( cfg: ServerOptions, broadcaster: Broadcaster = new Broadcaster(), frames: FrameBuffer = new FrameBuffer(), + runner: OpRunner = new OpRunner({ + projectDir: cfg.projectDir, + broadcaster, + onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster), + }), ): Hono { const app = new Hono(); @@ -105,14 +115,15 @@ export function createApp( // Delegated writes (#7 Sync / #8 Adopt): the project's committed Ops, and a // trigger. behold NEVER applies — it runs `chant run ` on the executor and // streams the phases as the now-line. It holds no apply creds. - let running: { name: string; op: ReturnType } | null = null; app.get("/api/ops", (c) => c.json({ ops: discoverOps(cfg.projectDir), - running: running?.name ?? null, + running: runner.running, // The substrates Adopt is offered on — the SPA gates the per-node button on // this so the "which lexicons live-import" truth stays server-side. adoptLexicons: LIVE_IMPORT_LEXICONS, + // Auto-sync mode (#29), so the SPA can show the banner. + autoSync: cfg.autoSync ?? "off", }), ); @@ -122,25 +133,9 @@ export function createApp( if (!info) { return c.json({ error: `no Op named "${name}" in the project` }, 404); } - if (running) return c.json({ error: `an Op is already running (${running.name})` }, 409); - broadcaster.emit("op", `▶ chant run ${name}`); - const op = runChantStream(["run", name], cfg.projectDir, (line) => { - broadcaster.emit("op", line); - // A ReconcileOp opens a PR and surfaces the URL as an outcome (chant #841); - // lift it to a `pr` event so the SPA can link the opened PR. - const pr = extractPrUrl(line); - if (pr) broadcaster.emit("pr", pr); - }); - running = { name, op }; - void op.done.then(async (code) => { - broadcaster.emit("op", `■ ${name} exited ${code}`); - // The op may have moved the estate — capture a keyframe of the result (#25) - // for the env it targeted, so a Sync/Adopt lands on the lanes timeline, then - // re-pull the graph. - await captureFrame(cfg.projectDir, info.env ?? cfg.env, frames, broadcaster); - broadcaster.emit("changed"); - running = null; - }); + if (!runner.trigger(name, info.env)) { + return c.json({ error: `an Op is already running (${runner.running})` }, 409); + } return c.json({ started: true, name }); }); @@ -269,7 +264,14 @@ export function createApp( export function startServer(cfg: ServerOptions): void { const broadcaster = new Broadcaster(); const frames = new FrameBuffer(); - const app = createApp(cfg, broadcaster, frames); + // One runner shared by the HTTP routes and the auto-sync loop (one running-guard). + const runner = new OpRunner({ + projectDir: cfg.projectDir, + broadcaster, + onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster), + }); + const app = createApp(cfg, broadcaster, frames, runner); + const autoSync = cfg.autoSync ?? "off"; // Capture the current graph as a keyframe (overlay when an env is set, else the // source graph). Shares the module helper with Refresh + post-op capture. @@ -280,16 +282,28 @@ export function startServer(cfg: ServerOptions): void { broadcaster.emit("changed"); void capture(); }; + // A polled *drift* (live moved) — re-render, and if auto-sync is on, trigger the + // configured Op to heal/adopt (#29). Source edits (watchSource) don't auto-sync: + // a new declaration is new desired state, not drift. + const onPollDrift = (): void => { + onEstateChange(); + if (autoSync === "off") return; + const op = pickAutoSyncOp(autoSync, discoverOps(cfg.projectDir), runner.running); + if (op) { + broadcaster.emit("op", `⟳ auto-sync (${autoSync}) → ${op.name}`); + runner.trigger(op.name, op.env); + } + }; // Watch the served project's source (the dev loop) and, with an env + --poll, - // poll live drift (#4). Both feed onEstateChange. + // poll live drift (#4) — the latter also drives auto-sync. const stopWatch = watchSource(cfg.projectDir, onEstateChange); const stopPoll = cfg.env && cfg.pollSecs ? startDriftPoll({ intervalMs: cfg.pollSecs * 1000, query: () => graphIr(cfg.projectDir, { live: true, overlay: true, env: cfg.env }), - onChange: onEstateChange, + onChange: onPollDrift, onError: (err) => process.stderr.write(`poll: ${err instanceof Error ? err.message : String(err)}\n`), }) : () => {}; @@ -301,9 +315,10 @@ export function startServer(cfg: ServerOptions): void { }); serve({ fetch: app.fetch, port: cfg.port }, (info) => { const poll = cfg.env && cfg.pollSecs ? `, polling drift every ${cfg.pollSecs}s` : ""; + const auto = autoSync !== "off" ? ` auto-sync: ${autoSync}` : ""; process.stdout.write( `behold → http://localhost:${info.port}\n` + - ` project: ${cfg.projectDir}${cfg.env ? ` env: ${cfg.env}` : ""}\n` + + ` project: ${cfg.projectDir}${cfg.env ? ` env: ${cfg.env}` : ""}${auto}\n` + ` read-only, watching for edits${poll}. lanes: /lanes. Ctrl-C to stop.\n`, ); // Report what the pickers will offer, so an empty env picker is diagnosable. diff --git a/web/app.js b/web/app.js index 412b6ad..c864b01 100644 --- a/web/app.js +++ b/web/app.js @@ -359,9 +359,18 @@ async function initActions() { const r = button("↻ Refresh", "", refresh); r.title = "Re-check live drift now and capture a lanes frame"; bar.appendChild(r); - const { ops, adoptLexicons } = await fetch("/api/ops") + const { ops, adoptLexicons, autoSync } = await fetch("/api/ops") .then((r) => r.json()) .catch(() => ({ ops: [], adoptLexicons: [] })); + // Auto-sync banner (#29) — make an active self-heal loop visible, not silent. + if (autoSync && autoSync !== "off") { + const pill = document.createElement("span"); + pill.textContent = `⟳ auto-sync: ${autoSync}`; + pill.title = `On polled drift, behold triggers the ${autoSync === "apply" ? "ApplyOp (heal)" : "ReconcileOp (adopt)"}. Gated applies still wait for Approve.`; + pill.style.cssText = + "align-self:center;font-size:11px;color:var(--pending);border:1px solid var(--pending);border-radius:6px;padding:2px 8px"; + bar.appendChild(pill); + } const apply = ops.find((o) => o.kind === "apply"); adopt = { reconcile: ops.find((o) => o.kind === "reconcile") ?? null, lexicons: adoptLexicons ?? [] }; if (apply) {