/*
RFC-0472: sternsystem.sync — synchronize a Sternsystem's local bare repo with an external mirror.
- Sync is an automatic pipeline step invoked after mission.reconcile — not a manual operator action.
- Do not add retry logic — fail-fast on network errors.
- Do not use git push --mirror — it deletes remote branches not present locally.
- ADR-0086: commit all dirty operational files (operations journals, status files) via commitCacheCloneIfDirty before pushing — closed missions never reconcile again, so sync is the last chance for their operational state to reach mirrors.
- RFC-1139: CLI hint accuracy and agent-safety hygiene — rfc.create hint, EC-14-PARTIAL, amend delegation, ledger scope, sync footer, mission.open remnants
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
- RFC-1231: rename --all to --all-branches in sternsystem.sync
'all branches + tags' is not a site selector — the reserved --all token stays gated off for this command (acceptsAllFlag: false), so the domain flag moved to a non-colliding name. Handler reads flags.all-branches via flagBoolean; git's own --all push flag extracted to a gitAll const to satisfy the RFC-1158 token gate on quoted '--x:' patterns.
- RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
ADR-0073, RFC-0472, RFC-0477, RFC-0480, RFC-0818
*/
import path from "node:path";
import { tmpdir } from "node:os";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import {
readSystemConfig,
resolveMirrors,
resolveMirrorPath,
isGitAccessible,
} from "./registry-io.ts";
import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts";
import { commitCacheCloneIfDirty } from "../mission/mission-git-commit.ts";
import type { WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export interface SternsystemSyncData {
systemId: string;
mirrorUrls: string[];
direction: "push";
branch: string;
commitSha: string | null;
syncedAt: string;
}
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
function flagBoolean(input: KernelCommandInput, key: string): boolean {
const v = input.flags[key];
return v === true || v === "true";
}
async function git(cwd: string, args: string[], io: WorkspaceIO): Promise {
const result = await io.exec("git", args, { cwd });
if (result.exitCode !== 0) {
throw new Error(result.stderr.trim() || `git ${args[0] ?? ""} exited ${result.exitCode}`);
}
return result.stdout.trim();
}
export async function runSternsystemSync(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const id = flagString(input, "id");
if (!id) throw new Error("[sternsystem.sync] --id is required");
const direction = flagString(input, "direction") ?? "push";
if (direction !== "push") {
throw new Error(
`[sternsystem.sync] --direction must be push (got '${direction}'). Pull and both are removed per RFC-0480 (edits-only-through-missions invariant).`,
);
}
const syncAllBranches = flagBoolean(input, "all-branches");
// git's own "--all" push flag — kept as a const because the RFC-1158 token
// gate flags a quoted "--x" literal directly followed by ":" in ternaries.
const gitAll = "--all";
const config = await readSystemConfig(workspaceRoot, id, context.io);
if (config.mirrors.length < 2) {
throw new Error(`[sternsystem.sync] system '${id}' has no bare mirror configured`);
}
const { gitMirrors, cachePath } = resolveMirrors(workspaceRoot, config);
const bareRepoPath = resolveMirrorPath(workspaceRoot, gitMirrors[0].path);
if (!(await context.io.exists(bareRepoPath))) {
throw new Error(`[sternsystem.sync] bare repo not found at ${bareRepoPath}`);
}
// RFC-0574: star topology — first push cache clone to bare repo (mirrors[1])
let branch: string;
try {
branch = await git(bareRepoPath, ["symbolic-ref", "HEAD"], context.io);
} catch {
throw new Error(`[sternsystem.sync] bare repo has no commits — nothing to push`);
}
const branchName = branch.replace("refs/heads/", "");
if (await context.io.exists(path.join(cachePath, ".git"))) {
// ADR-0086: sweep all dirty operational files (operations/ journals, status
// files) into a commit before pushing. The cache clone is system-managed —
// dirty state is operational output, not user work. Closed missions never
// reconcile again, so sync is the last chance for their operational deltas
// to reach the bare repo and external mirrors. Non-fatal: a failed sweep
// must not block propagation of already-committed state.
try {
const sweep = await commitCacheCloneIfDirty(cachePath, id);
if (sweep.committed) {
logger.info(
`[sternsystem.sync] auto-committed dirty operational files (${sweep.commitSha?.slice(0, 8)})`,
);
}
} catch (err) {
logger.warn(
`[sternsystem.sync] operational sweep failed (non-fatal): ${(err as Error).message}`,
);
}
logger.info(`[sternsystem.sync] pushing cache clone to bare repo…`);
try {
// RFC-0986: Use --force-with-lease — cache clone is the source of truth.
// If the bare repo received unexpected commits after the last fetch,
// --force-with-lease rejects the push (safe). This fixes non-fast-forward
// errors caused by divergent histories between cache and bare.
await git(cachePath, ["push", "--force-with-lease", "origin", branchName], context.io);
} catch (err) {
logger.warn(
`[sternsystem.sync] cache-to-bare push failed (non-fatal): ${(err as Error).message}`,
);
}
}
// External mirrors are mirrors[2+] (git accessible, non-bundle)
const externalMirrors = config.mirrors.slice(2).filter((m) => isGitAccessible(m.path));
const mirrorUrls = externalMirrors.map((m) => m.path);
if (syncAllBranches) {
branch = "*";
}
const refSpec = syncAllBranches ? gitAll : branchName;
const pushRefs = syncAllBranches ? [gitAll, "--tags"] : [branchName];
const warnings: string[] = [];
// RFC-0818: Capture commitSha (content SHA) BEFORE bordbuch commit — records
// what content was synced, not the bordbuch commit itself.
let commitSha: string;
try {
commitSha = await git(bareRepoPath, ["rev-parse", "HEAD"], context.io);
} catch {
commitSha = "";
}
const syncedAt = new Date().toISOString();
// RFC-0818: Bordbuch commit happens BEFORE external push so the bordbuch entry
// reaches external mirrors and bundles. appendAndCommitBordbuch commits in the
// cache clone and pushes to the bare repo, advancing bare HEAD to N+1.
try {
await appendAndCommitBordbuch(
workspaceRoot,
id,
"mirror-sync",
`Mirror sync (${direction}, branch: ${syncAllBranches ? "*" : branchName}) — ${commitSha.slice(0, 12)}`,
"sternsystem.sync",
{
writerRole: "sternsystem",
metadata: {
mirrorUrls,
direction,
branch: syncAllBranches ? "*" : branchName,
commitSha,
result: "ok",
},
},
`Bordbuch: mirror-sync ${id}`,
);
} catch (err) {
logger.error(`[sternsystem.sync] Bordbuch write failed: ${(err as Error).message}`);
}
// RFC-0818: External push moved AFTER bordbuch commit — now includes bordbuch
// entry commit in the push to external mirrors.
for (let i = 0; i < mirrorUrls.length; i++) {
const mirrorUrl = mirrorUrls[i];
const remoteName = `mirror-${i}`;
const currentRemoteUrl = await git(
bareRepoPath,
["remote", "get-url", remoteName],
context.io,
).catch(() => null);
if (currentRemoteUrl === null) {
logger.info(`[sternsystem.sync] adding remote '${remoteName}' → ${mirrorUrl}`);
await git(bareRepoPath, ["remote", "add", remoteName, mirrorUrl], context.io);
} else if (currentRemoteUrl !== mirrorUrl) {
logger.info(
`[sternsystem.sync] updating remote '${remoteName}' URL: ${currentRemoteUrl} → ${mirrorUrl}`,
);
await git(bareRepoPath, ["remote", "set-url", remoteName, mirrorUrl], context.io);
}
if (direction === "push") {
logger.info(`[sternsystem.sync] pushing ${refSpec} to ${remoteName}…`);
try {
// Fetch first to establish lease baseline for --force-with-lease.
// Bare repo is the source of truth — external mirrors are backups.
// --force-with-lease overwrites diverged external commits safely
// (edits-only-through-missions invariant) while protecting against
// true concurrent pushes between fetch and push.
try {
await git(bareRepoPath, ["fetch", remoteName], context.io);
} catch {
// First push or network issue — no tracking ref, proceed below
}
try {
await git(
bareRepoPath,
["push", "--force-with-lease", remoteName, ...pushRefs],
context.io,
);
} catch {
// Fallback: plain push (first push, no remote ref to lease against)
await git(bareRepoPath, ["push", remoteName, ...pushRefs], context.io);
}
} catch (err) {
const stderr = (err as Error).message;
const msg = `git push to ${mirrorUrl} failed: ${stderr}`;
warnings.push(msg);
logger.warn(`[sternsystem.sync] ${msg}`);
}
}
}
// RFC-0818: Bundle creation moved AFTER bordbuch commit — now includes bordbuch
// entry commit in bundles.
const bundleMirrors = config.mirrors.slice(2).filter((m) => m.storageType === "bundle");
for (const bundleMirror of bundleMirrors) {
const bundlePath = path.join(tmpdir(), `${id}-${Date.now()}.bundle`);
try {
await git(bareRepoPath, ["bundle", "create", bundlePath, gitAll], context.io);
logger.info(`[sternsystem.sync] created bundle for ${bundleMirror.path}`);
// Copy bundle to backup endpoint (non-git protocols: ftp, s3, rsync)
// For file-based bundle mirrors, copy directly
if (
bundleMirror.path.startsWith("./") ||
bundleMirror.path.startsWith("../") ||
bundleMirror.path.startsWith("/")
) {
const destPath = resolveMirrorPath(workspaceRoot, bundleMirror.path);
await context.io.mkdir(path.dirname(destPath));
await context.io.copyFile(bundlePath, destPath);
logger.info(`[sternsystem.sync] copied bundle to ${destPath}`);
} else {
// Non-file protocols (ftp, s3, rsync) — log as warning (external tool required)
warnings.push(
`bundle copy to ${bundleMirror.path} requires external tool (ftp/s3/rsync) — bundle was created but not copied (temp bundle cleaned up)`,
);
logger.warn(
`[sternsystem.sync] bundle copy to ${bundleMirror.path} requires external tool`,
);
}
} catch (err) {
const msg = `bundle creation/copy for ${bundleMirror.path} failed: ${(err as Error).message}`;
warnings.push(msg);
logger.warn(`[sternsystem.sync] ${msg}`);
} finally {
// Cleanup temp bundle
await context.io.rm(bundlePath).catch(() => {});
}
}
// RFC-0574: per-mirror failures are non-fatal — sync continues and reports warnings
// RFC-0818: Update refs/mirror/${branch} AFTER bordbuch commit and external
// push. Now accurately tracks the SHA that was pushed to external mirrors
// (bordbuch commit included). mission.close checks this ref to determine if
// external mirrors are in sync.
if (externalMirrors.length > 0 && !syncAllBranches) {
try {
const headSha = await git(bareRepoPath, ["rev-parse", branchName], context.io);
await git(bareRepoPath, ["update-ref", `refs/mirror/${branchName}`, headSha], context.io);
logger.info(`[sternsystem.sync] updated refs/mirror/${branchName} → ${headSha.slice(0, 12)}`);
} catch (err) {
warnings.push(`Failed to update refs/mirror/${branchName}: ${(err as Error).message}`);
logger.warn(
`[sternsystem.sync] Failed to update refs/mirror/${branchName}: ${(err as Error).message}`,
);
}
}
logger.info(
`[sternsystem.sync] ${id} mirrored (${direction}, branch: ${syncAllBranches ? "*" : branchName})`,
);
// RFC-1124: carry the fleet claims repo with the sync — publish any pending
// sender-side transfer claim for this system, then fetch/ff/verify/push the
// claims clone. Non-fatal: claims are additive to mirror sync.
try {
const { loadSigningKeyFromEnv, publishPendingTransferClaim, syncClaims } =
await import("../fleet/claims-repo.ts");
const signingKey = await loadSigningKeyFromEnv();
if (signingKey) {
const transfer = await publishPendingTransferClaim({
systemId: id,
werkstattRoot: workspaceRoot,
signerPrivateKeyHex: signingKey.privateKeyHex,
signerPublicKey: signingKey.publicKeyHex,
});
if (transfer) {
logger.info(`[sternsystem.sync] published sender-side transfer claim for ${id}`);
}
}
const claimsSync = await syncClaims(workspaceRoot);
if (claimsSync.diverged.length > 0) {
logger.warn(
`[sternsystem.sync] fleet claims diverged — manual resolution required: ${claimsSync.diverged.map((d) => d.remote).join(", ")}`,
);
}
} catch (err) {
logger.warn(
`[sternsystem.sync] fleet claims sync failed (non-fatal): ${(err as Error).message}`,
);
}
const data: SternsystemSyncData = {
systemId: id,
mirrorUrls,
direction: "push",
branch: syncAllBranches ? "*" : branchName,
commitSha,
syncedAt,
};
return {
data,
exitCode: 0,
summary: `[sternsystem.sync] ${id} mirrored (${direction}, branch: ${syncAllBranches ? "*" : branchName})`,
nextSteps: [
{
action: `Verify the propagated claim: pnpm exec werkstatt run fleet.claims.verify --id ${id}`,
kind: "optional",
},
],
};
}