Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/autosync.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 29 additions & 0 deletions src/autosync.ts
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 22 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -17,10 +18,13 @@ Usage:
browser, coloured by drift. Read-only — never mutates.

Options:
--port <n> Port (default 4600).
--env <name> Environment name — turns on the live drift overlay.
--poll <secs> Re-query live drift every <secs> and push updates (needs --env).
-h, --help This text.
--port <n> Port (default 4600).
--env <name> Environment name — turns on the live drift overlay.
--poll <secs> Re-query live drift every <secs> and push updates (needs --env).
--auto-sync <mode> 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<void> {
Expand All @@ -40,13 +44,21 @@ export async function run(argv: string[]): Promise<void> {
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;
Expand All @@ -72,12 +84,17 @@ export async function run(argv: string[]): Promise<void> {
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 } : {}),
});
}

Expand Down
51 changes: 51 additions & 0 deletions src/op-runner.ts
Original file line number Diff line number Diff line change
@@ -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 <op>` 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<unknown> | 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 <name>` 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;
}
}
69 changes: 42 additions & 27 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}

Expand Down Expand Up @@ -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();

Expand All @@ -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 <op>` on the executor and
// streams the phases as the now-line. It holds no apply creds.
let running: { name: string; op: ReturnType<typeof runChantStream> } | 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",
}),
);

Expand All @@ -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 });
});

Expand Down Expand Up @@ -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.
Expand All @@ -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`),
})
: () => {};
Expand All @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading