/* 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", }, ], }; }