635 lines
18 KiB
JavaScript
635 lines
18 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
|
|||
|
|
import crypto from "node:crypto";
|
|||
|
|
import fs from "node:fs";
|
|||
|
|
import os from "node:os";
|
|||
|
|
import path from "node:path";
|
|||
|
|
import process from "node:process";
|
|||
|
|
import { execFileSync } from "node:child_process";
|
|||
|
|
import { fileURLToPath } from "node:url";
|
|||
|
|
|
|||
|
|
const DEFAULT_GUARD =
|
|||
|
|
"/Volumes/JZAO/HoloLake/persona-runtime/continuity-guard/scripts/continuity-lane.mjs";
|
|||
|
|
const DEFAULT_STORE =
|
|||
|
|
"/Volumes/JZAO/HoloLake/persona-runtime/continuity-memory/collaboration";
|
|||
|
|
const CODE_CHANNEL_HOST = "guanghulab.com";
|
|||
|
|
const CODE_CHANNEL_PREFIX = "/code/bingshuo/";
|
|||
|
|
const CODE_CHANNEL_REPOSITORIES = new Set([
|
|||
|
|
"guanghu-ice-heart",
|
|||
|
|
"hololake-system-architecture",
|
|||
|
|
"grok-build-upstream-mirror",
|
|||
|
|
]);
|
|||
|
|
const SAFE_CACHE_RELATIVE = [
|
|||
|
|
/^target$/u,
|
|||
|
|
/^coverage$/u,
|
|||
|
|
/^\.nyc_output$/u,
|
|||
|
|
/^\.pytest_cache$/u,
|
|||
|
|
/^\.mypy_cache$/u,
|
|||
|
|
/^\.ruff_cache$/u,
|
|||
|
|
/^\.cache$/u,
|
|||
|
|
/^\.next\/cache$/u,
|
|||
|
|
/^node_modules\/\.cache$/u,
|
|||
|
|
/(?:^|\/)__pycache__$/u,
|
|||
|
|
];
|
|||
|
|
const DEFAULT_CACHE_CANDIDATES = [
|
|||
|
|
"target",
|
|||
|
|
"coverage",
|
|||
|
|
".nyc_output",
|
|||
|
|
".pytest_cache",
|
|||
|
|
".mypy_cache",
|
|||
|
|
".ruff_cache",
|
|||
|
|
".cache",
|
|||
|
|
".next/cache",
|
|||
|
|
"node_modules/.cache",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
export function parseArgs(argv) {
|
|||
|
|
const args = { receipts: [], cleanupPaths: [] };
|
|||
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|||
|
|
const key = argv[index];
|
|||
|
|
if (key === "--self-test") args.selfTest = true;
|
|||
|
|
else if (key === "--receipt") args.receipts.push(argv[++index]);
|
|||
|
|
else if (key === "--cleanup-path") args.cleanupPaths.push(argv[++index]);
|
|||
|
|
else if (key.startsWith("--")) {
|
|||
|
|
const name = key.slice(2).replaceAll(/-([a-z])/gu, (_, letter) =>
|
|||
|
|
letter.toUpperCase(),
|
|||
|
|
);
|
|||
|
|
args[name] = argv[++index];
|
|||
|
|
} else {
|
|||
|
|
throw new Error(`UNKNOWN_ARGUMENT:${key}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return args;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function normalizeCodeChannelRepository(value) {
|
|||
|
|
let url;
|
|||
|
|
try {
|
|||
|
|
url = new URL(String(value || "").trim().replace(/^repo:\/\//u, "https://"));
|
|||
|
|
} catch {
|
|||
|
|
throw new Error("INVALID_CODE_CHANNEL_REPOSITORY");
|
|||
|
|
}
|
|||
|
|
if (url.username || url.password) {
|
|||
|
|
throw new Error("CODE_CHANNEL_CREDENTIALS_FORBIDDEN");
|
|||
|
|
}
|
|||
|
|
const pathname = url.pathname.replace(/\.git$/u, "").replace(/\/+$/u, "");
|
|||
|
|
const slug = pathname.startsWith(CODE_CHANNEL_PREFIX)
|
|||
|
|
? pathname.slice(CODE_CHANNEL_PREFIX.length)
|
|||
|
|
: "";
|
|||
|
|
if (
|
|||
|
|
url.protocol !== "https:" ||
|
|||
|
|
url.hostname !== CODE_CHANNEL_HOST ||
|
|||
|
|
!CODE_CHANNEL_REPOSITORIES.has(slug)
|
|||
|
|
) {
|
|||
|
|
throw new Error("NON_CANONICAL_CODE_CHANNEL_REPOSITORY");
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
identity: `repo://${CODE_CHANNEL_HOST}${CODE_CHANNEL_PREFIX}${slug}`,
|
|||
|
|
cloneUrl: `https://${CODE_CHANNEL_HOST}${CODE_CHANNEL_PREFIX}${slug}.git`,
|
|||
|
|
slug,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function normalizeBranch(value) {
|
|||
|
|
const branch = String(value || "").trim();
|
|||
|
|
if (
|
|||
|
|
!branch ||
|
|||
|
|
!/^(?!\/)(?!.*(?:\.\.|\/\/|@\{|[~^:?*\[\\]))[A-Za-z0-9._/-]+$/u.test(
|
|||
|
|
branch,
|
|||
|
|
) ||
|
|||
|
|
branch.endsWith("/") ||
|
|||
|
|
branch.endsWith(".")
|
|||
|
|
) {
|
|||
|
|
throw new Error("INVALID_TARGET_BRANCH");
|
|||
|
|
}
|
|||
|
|
return branch;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function normalizeReceipt(value) {
|
|||
|
|
const relative = String(value || "").replaceAll("\\", "/").replace(/^\.\/+/u, "");
|
|||
|
|
if (
|
|||
|
|
!relative.startsWith("deployment/receipts/") ||
|
|||
|
|
relative.includes("/../") ||
|
|||
|
|
relative.endsWith("/") ||
|
|||
|
|
!/^[A-Za-z0-9._/-]+$/u.test(relative)
|
|||
|
|
) {
|
|||
|
|
throw new Error("INVALID_DEPLOYMENT_RECEIPT_PATH");
|
|||
|
|
}
|
|||
|
|
return relative;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function safeCacheRelative(worktree, candidate) {
|
|||
|
|
const root = fs.realpathSync(worktree);
|
|||
|
|
const absolute = path.resolve(root, candidate);
|
|||
|
|
const relative = path.relative(root, absolute).replaceAll(path.sep, "/");
|
|||
|
|
if (
|
|||
|
|
!relative ||
|
|||
|
|
relative.startsWith("../") ||
|
|||
|
|
path.isAbsolute(relative) ||
|
|||
|
|
!SAFE_CACHE_RELATIVE.some((pattern) => pattern.test(relative))
|
|||
|
|
) {
|
|||
|
|
throw new Error(`CACHE_PATH_NOT_ALLOWLISTED:${candidate}`);
|
|||
|
|
}
|
|||
|
|
return { absolute, relative };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function run(file, args, options = {}) {
|
|||
|
|
return execFileSync(file, args, {
|
|||
|
|
cwd: options.cwd,
|
|||
|
|
encoding: "utf8",
|
|||
|
|
stdio: options.stdio || ["ignore", "pipe", "pipe"],
|
|||
|
|
maxBuffer: 16 * 1024 * 1024,
|
|||
|
|
}).trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function git(worktree, args) {
|
|||
|
|
return run("/usr/bin/git", ["-C", worktree, ...args]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function guard(guardScript, args) {
|
|||
|
|
const output = run("/usr/bin/node", [guardScript, ...args]);
|
|||
|
|
return output ? JSON.parse(output) : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readJson(filePath) {
|
|||
|
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sha256(value) {
|
|||
|
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function listOwnedLeases(storeRoot, developmentId) {
|
|||
|
|
const directory = path.join(storeRoot, "leases");
|
|||
|
|
if (!fs.existsSync(directory)) return [];
|
|||
|
|
return fs
|
|||
|
|
.readdirSync(directory)
|
|||
|
|
.filter((name) => name.endsWith(".json"))
|
|||
|
|
.map((name) => readJson(path.join(directory, name)))
|
|||
|
|
.filter((lease) => lease.development_id === developmentId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ensureLane(storeRoot, developmentId, worktree) {
|
|||
|
|
const lanePath = path.join(storeRoot, "lanes", `${developmentId}.json`);
|
|||
|
|
const lane = readJson(lanePath);
|
|||
|
|
if (lane.status !== "active" && lane.status !== "waiting") {
|
|||
|
|
throw new Error(`LANE_NOT_RUNNABLE:${lane.status}`);
|
|||
|
|
}
|
|||
|
|
const owned = listOwnedLeases(storeRoot, developmentId);
|
|||
|
|
const resolvedWorktree = fs.realpathSync(worktree);
|
|||
|
|
if (
|
|||
|
|
!owned.some((lease) => {
|
|||
|
|
if (!path.isAbsolute(lease.resource)) return false;
|
|||
|
|
try {
|
|||
|
|
const resource = fs.realpathSync(lease.resource);
|
|||
|
|
return (
|
|||
|
|
resolvedWorktree === resource ||
|
|||
|
|
resolvedWorktree.startsWith(`${resource}${path.sep}`) ||
|
|||
|
|
resource.startsWith(`${resolvedWorktree}${path.sep}`)
|
|||
|
|
);
|
|||
|
|
} catch {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
) {
|
|||
|
|
throw new Error("WORKTREE_WRITE_LEASE_REQUIRED");
|
|||
|
|
}
|
|||
|
|
return lane;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function verifyWorktree(worktree, receipts) {
|
|||
|
|
const root = fs.realpathSync(git(worktree, ["rev-parse", "--show-toplevel"]));
|
|||
|
|
if (root !== fs.realpathSync(worktree)) {
|
|||
|
|
throw new Error("WORKTREE_MUST_BE_GIT_ROOT");
|
|||
|
|
}
|
|||
|
|
if (git(root, ["status", "--porcelain=v1"])) {
|
|||
|
|
throw new Error("WORKTREE_NOT_CLEAN");
|
|||
|
|
}
|
|||
|
|
const head = git(root, ["rev-parse", "HEAD"]);
|
|||
|
|
if (!/^[0-9a-f]{40}$/u.test(head)) throw new Error("INVALID_LOCAL_HEAD");
|
|||
|
|
const branch = normalizeBranch(git(root, ["branch", "--show-current"]));
|
|||
|
|
const receiptEvidence = receipts.map((receipt) => {
|
|||
|
|
const relative = normalizeReceipt(receipt);
|
|||
|
|
const content = git(root, ["show", `HEAD:${relative}`]);
|
|||
|
|
if (!content) throw new Error(`RECEIPT_NOT_COMMITTED:${relative}`);
|
|||
|
|
return {
|
|||
|
|
path: relative,
|
|||
|
|
sha256: sha256(Buffer.from(content)),
|
|||
|
|
bytes: Buffer.byteLength(content),
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
return { root, head, branch, receiptEvidence };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startPublish({
|
|||
|
|
guardScript,
|
|||
|
|
developmentId,
|
|||
|
|
repository,
|
|||
|
|
branch,
|
|||
|
|
}) {
|
|||
|
|
const requested = guard(guardScript, [
|
|||
|
|
"publish-request",
|
|||
|
|
"--development-id",
|
|||
|
|
developmentId,
|
|||
|
|
"--repository",
|
|||
|
|
repository.identity,
|
|||
|
|
"--target",
|
|||
|
|
branch,
|
|||
|
|
"--priority",
|
|||
|
|
"normal",
|
|||
|
|
]);
|
|||
|
|
guard(guardScript, ["publish-dispatch"]);
|
|||
|
|
guard(guardScript, [
|
|||
|
|
"publish-start",
|
|||
|
|
"--development-id",
|
|||
|
|
developmentId,
|
|||
|
|
"--request-id",
|
|||
|
|
requested.request_id,
|
|||
|
|
]);
|
|||
|
|
return requested.request_id;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function finishPublish(guardScript, developmentId, requestId, outcome, receipt) {
|
|||
|
|
return guard(guardScript, [
|
|||
|
|
"publish-finish",
|
|||
|
|
"--development-id",
|
|||
|
|
developmentId,
|
|||
|
|
"--request-id",
|
|||
|
|
requestId,
|
|||
|
|
"--outcome",
|
|||
|
|
outcome,
|
|||
|
|
"--receipt",
|
|||
|
|
receipt,
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pushAndReadBack(worktree, repository, branch, head, receipts) {
|
|||
|
|
run("/usr/bin/git", [
|
|||
|
|
"-C",
|
|||
|
|
worktree,
|
|||
|
|
"fetch",
|
|||
|
|
"--quiet",
|
|||
|
|
"origin",
|
|||
|
|
`refs/heads/${branch}`,
|
|||
|
|
]);
|
|||
|
|
const remoteBefore = git(worktree, [
|
|||
|
|
"rev-parse",
|
|||
|
|
`refs/remotes/origin/${branch}`,
|
|||
|
|
]);
|
|||
|
|
try {
|
|||
|
|
git(worktree, ["merge-base", "--is-ancestor", remoteBefore, head]);
|
|||
|
|
} catch {
|
|||
|
|
throw new Error(`NON_FAST_FORWARD_REFUSED:${remoteBefore}`);
|
|||
|
|
}
|
|||
|
|
run("/usr/bin/git", [
|
|||
|
|
"-C",
|
|||
|
|
worktree,
|
|||
|
|
"push",
|
|||
|
|
"origin",
|
|||
|
|
`HEAD:refs/heads/${branch}`,
|
|||
|
|
]);
|
|||
|
|
const remoteLine = run("/usr/bin/git", [
|
|||
|
|
"ls-remote",
|
|||
|
|
repository.cloneUrl,
|
|||
|
|
`refs/heads/${branch}`,
|
|||
|
|
]);
|
|||
|
|
const remoteHead = remoteLine.split(/\s+/u)[0] || "";
|
|||
|
|
if (remoteHead !== head) {
|
|||
|
|
throw new Error(`REMOTE_HEAD_MISMATCH:${remoteHead || "missing"}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const readbackRoot = fs.mkdtempSync(
|
|||
|
|
path.join(os.tmpdir(), "hololake-finalize-readback-"),
|
|||
|
|
);
|
|||
|
|
try {
|
|||
|
|
const clone = path.join(readbackRoot, "repo");
|
|||
|
|
run("/usr/bin/git", [
|
|||
|
|
"clone",
|
|||
|
|
"--quiet",
|
|||
|
|
"--depth",
|
|||
|
|
"1",
|
|||
|
|
"--branch",
|
|||
|
|
branch,
|
|||
|
|
repository.cloneUrl,
|
|||
|
|
clone,
|
|||
|
|
]);
|
|||
|
|
const cloneHead = git(clone, ["rev-parse", "HEAD"]);
|
|||
|
|
if (cloneHead !== head) throw new Error(`FRESH_CLONE_HEAD_MISMATCH:${cloneHead}`);
|
|||
|
|
git(clone, ["fsck", "--full"]);
|
|||
|
|
for (const receipt of receipts) {
|
|||
|
|
const remoteContent = git(clone, ["show", `HEAD:${receipt.path}`]);
|
|||
|
|
if (sha256(Buffer.from(remoteContent)) !== receipt.sha256) {
|
|||
|
|
throw new Error(`REMOTE_RECEIPT_MISMATCH:${receipt.path}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
fs.rmSync(readbackRoot, { recursive: true, force: true });
|
|||
|
|
}
|
|||
|
|
return { remoteBefore, remoteHead };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function activeProcessFor(absolute) {
|
|||
|
|
const output = run("/bin/ps", ["-axo", "pid=,command="]);
|
|||
|
|
return output
|
|||
|
|
.split("\n")
|
|||
|
|
.map((line) => line.trim())
|
|||
|
|
.filter(Boolean)
|
|||
|
|
.some((line) => {
|
|||
|
|
const match = line.match(/^(\d+)\s+(.*)$/u);
|
|||
|
|
if (!match) return false;
|
|||
|
|
const pid = Number(match[1]);
|
|||
|
|
return pid !== process.pid && pid !== process.ppid && match[2].includes(absolute);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function directoryBytes(root) {
|
|||
|
|
let total = 0;
|
|||
|
|
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|||
|
|
const candidate = path.join(root, entry.name);
|
|||
|
|
const stat = fs.lstatSync(candidate);
|
|||
|
|
if (stat.isSymbolicLink()) continue;
|
|||
|
|
if (stat.isDirectory()) total += directoryBytes(candidate);
|
|||
|
|
else if (stat.isFile()) total += stat.size;
|
|||
|
|
}
|
|||
|
|
return total;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function collectPythonCaches(root, limit = 256) {
|
|||
|
|
const found = [];
|
|||
|
|
const walk = (directory, depth) => {
|
|||
|
|
if (depth > 6 || found.length >= limit) return;
|
|||
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|||
|
|
if (found.length >= limit) return;
|
|||
|
|
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|||
|
|
const candidate = path.join(directory, entry.name);
|
|||
|
|
if (entry.name === "__pycache__") {
|
|||
|
|
found.push(path.relative(root, candidate));
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if ([".git", "node_modules", "target", "build", "dist"].includes(entry.name)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
walk(candidate, depth + 1);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
walk(root, 0);
|
|||
|
|
return found;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cleanupCaches(worktree, explicitPaths) {
|
|||
|
|
const candidates = [
|
|||
|
|
...DEFAULT_CACHE_CANDIDATES,
|
|||
|
|
...collectPythonCaches(worktree),
|
|||
|
|
...explicitPaths,
|
|||
|
|
];
|
|||
|
|
const unique = new Map();
|
|||
|
|
for (const candidate of candidates) {
|
|||
|
|
const safe = safeCacheRelative(worktree, candidate);
|
|||
|
|
unique.set(safe.absolute, safe);
|
|||
|
|
}
|
|||
|
|
const results = [];
|
|||
|
|
for (const item of unique.values()) {
|
|||
|
|
if (!fs.existsSync(item.absolute)) {
|
|||
|
|
results.push({ path: item.relative, result: "SKIPPED_NOT_PRESENT", bytes: 0 });
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const stat = fs.lstatSync(item.absolute);
|
|||
|
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|||
|
|
throw new Error(`CACHE_TARGET_NOT_REAL_DIRECTORY:${item.relative}`);
|
|||
|
|
}
|
|||
|
|
if (git(worktree, ["ls-files", "--", item.relative])) {
|
|||
|
|
throw new Error(`TRACKED_CACHE_REFUSED:${item.relative}`);
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
git(worktree, ["check-ignore", "--no-index", "-q", "--", item.relative]);
|
|||
|
|
} catch {
|
|||
|
|
throw new Error(`UNIGNORED_CACHE_REFUSED:${item.relative}`);
|
|||
|
|
}
|
|||
|
|
if (activeProcessFor(item.absolute)) {
|
|||
|
|
results.push({ path: item.relative, result: "SKIPPED_ACTIVE", bytes: 0 });
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const bytes = directoryBytes(item.absolute);
|
|||
|
|
fs.rmSync(item.absolute, { recursive: true, force: true });
|
|||
|
|
results.push({ path: item.relative, result: "REMOVED", bytes });
|
|||
|
|
}
|
|||
|
|
return results;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function writeFinalizationReceipt(storeRoot, developmentId, payload) {
|
|||
|
|
const directory = path.join(storeRoot, "finalization-receipts");
|
|||
|
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|||
|
|
const filePath = path.join(
|
|||
|
|
directory,
|
|||
|
|
`${developmentId}-${new Date().toISOString().replaceAll(/[:.]/gu, "-")}.json`,
|
|||
|
|
);
|
|||
|
|
const temporary = `${filePath}.tmp-${process.pid}`;
|
|||
|
|
fs.writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, {
|
|||
|
|
mode: 0o600,
|
|||
|
|
});
|
|||
|
|
fs.renameSync(temporary, filePath);
|
|||
|
|
return filePath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function releaseAndComplete({
|
|||
|
|
guardScript,
|
|||
|
|
storeRoot,
|
|||
|
|
developmentId,
|
|||
|
|
summary,
|
|||
|
|
}) {
|
|||
|
|
for (const lease of listOwnedLeases(storeRoot, developmentId)) {
|
|||
|
|
guard(guardScript, [
|
|||
|
|
"release",
|
|||
|
|
"--development-id",
|
|||
|
|
developmentId,
|
|||
|
|
"--resource",
|
|||
|
|
lease.resource,
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
const lane = guard(guardScript, [
|
|||
|
|
"complete",
|
|||
|
|
"--development-id",
|
|||
|
|
developmentId,
|
|||
|
|
"--summary",
|
|||
|
|
summary,
|
|||
|
|
]);
|
|||
|
|
const actions = guard(guardScript, ["completion-actions"]);
|
|||
|
|
const current = actions.find(
|
|||
|
|
(item) => item.development_id === developmentId,
|
|||
|
|
);
|
|||
|
|
if (!current) throw new Error("COMPLETION_ACTION_MISSING");
|
|||
|
|
if ((current.delete_automation_ids || []).length === 0) {
|
|||
|
|
guard(guardScript, [
|
|||
|
|
"mark-completion-stopped",
|
|||
|
|
"--development-id",
|
|||
|
|
developmentId,
|
|||
|
|
]);
|
|||
|
|
return { lane_status: lane.status, notification_state: "stopped" };
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
lane_status: lane.status,
|
|||
|
|
notification_state: "stop_requested",
|
|||
|
|
delete_automation_ids: current.delete_automation_ids,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function finalize(options) {
|
|||
|
|
const developmentId = String(options.developmentId || "");
|
|||
|
|
if (!/^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+$/u.test(developmentId)) {
|
|||
|
|
throw new Error("INVALID_DEVELOPMENT_ID");
|
|||
|
|
}
|
|||
|
|
const guardScript = options.guardScript || DEFAULT_GUARD;
|
|||
|
|
const storeRoot = options.storeRoot || DEFAULT_STORE;
|
|||
|
|
const worktree = fs.realpathSync(options.worktree || "");
|
|||
|
|
if (!options.receipts?.length) throw new Error("AT_LEAST_ONE_RECEIPT_REQUIRED");
|
|||
|
|
ensureLane(storeRoot, developmentId, worktree);
|
|||
|
|
const verified = verifyWorktree(worktree, options.receipts);
|
|||
|
|
const repository = normalizeCodeChannelRepository(
|
|||
|
|
options.repository || git(worktree, ["config", "--get", "remote.origin.url"]),
|
|||
|
|
);
|
|||
|
|
const branch = normalizeBranch(options.target || verified.branch);
|
|||
|
|
if (branch !== verified.branch) throw new Error("CHECKED_OUT_BRANCH_MISMATCH");
|
|||
|
|
|
|||
|
|
let publishRequestId = null;
|
|||
|
|
let publishStarted = false;
|
|||
|
|
try {
|
|||
|
|
publishRequestId = startPublish({
|
|||
|
|
guardScript,
|
|||
|
|
developmentId,
|
|||
|
|
repository,
|
|||
|
|
branch,
|
|||
|
|
});
|
|||
|
|
publishStarted = true;
|
|||
|
|
const remote = pushAndReadBack(
|
|||
|
|
worktree,
|
|||
|
|
repository,
|
|||
|
|
branch,
|
|||
|
|
verified.head,
|
|||
|
|
verified.receiptEvidence,
|
|||
|
|
);
|
|||
|
|
const publishReceipt =
|
|||
|
|
`PASS_100 remote=${repository.identity}#${branch} ` +
|
|||
|
|
`head=${remote.remoteHead} receipts=${verified.receiptEvidence
|
|||
|
|
.map((item) => `${item.path}:${item.sha256}`)
|
|||
|
|
.join(",")}`;
|
|||
|
|
finishPublish(
|
|||
|
|
guardScript,
|
|||
|
|
developmentId,
|
|||
|
|
publishRequestId,
|
|||
|
|
"completed",
|
|||
|
|
publishReceipt,
|
|||
|
|
);
|
|||
|
|
publishStarted = false;
|
|||
|
|
|
|||
|
|
const cleanup = cleanupCaches(worktree, options.cleanupPaths || []);
|
|||
|
|
const removedBytes = cleanup
|
|||
|
|
.filter((item) => item.result === "REMOVED")
|
|||
|
|
.reduce((total, item) => total + item.bytes, 0);
|
|||
|
|
const payload = {
|
|||
|
|
schema: "hololake.development-finalization-receipt/v1",
|
|||
|
|
development_id: developmentId,
|
|||
|
|
result: "PASS_100",
|
|||
|
|
code_channel: {
|
|||
|
|
repository: repository.identity,
|
|||
|
|
branch,
|
|||
|
|
remote_head: remote.remoteHead,
|
|||
|
|
fresh_clone_readback: 100,
|
|||
|
|
git_fsck: 100,
|
|||
|
|
receipts: verified.receiptEvidence,
|
|||
|
|
},
|
|||
|
|
cleanup: {
|
|||
|
|
scope: "CURRENT_WORKTREE_IGNORED_ALLOWLISTED_CACHES_ONLY",
|
|||
|
|
removed_bytes: removedBytes,
|
|||
|
|
items: cleanup,
|
|||
|
|
},
|
|||
|
|
recorded_at: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
const finalizationReceipt = writeFinalizationReceipt(
|
|||
|
|
storeRoot,
|
|||
|
|
developmentId,
|
|||
|
|
payload,
|
|||
|
|
);
|
|||
|
|
const completion = releaseAndComplete({
|
|||
|
|
guardScript,
|
|||
|
|
storeRoot,
|
|||
|
|
developmentId,
|
|||
|
|
summary:
|
|||
|
|
`自动收口PASS_100:第五域代码频道${repository.identity}#${branch}` +
|
|||
|
|
`远端回读${remote.remoteHead};${verified.receiptEvidence.length}份回执一致;` +
|
|||
|
|
`本车道安全缓存清理${removedBytes}字节;回执${finalizationReceipt}`,
|
|||
|
|
});
|
|||
|
|
return { ...payload, finalization_receipt: finalizationReceipt, completion };
|
|||
|
|
} catch (error) {
|
|||
|
|
if (publishStarted && publishRequestId) {
|
|||
|
|
try {
|
|||
|
|
finishPublish(
|
|||
|
|
guardScript,
|
|||
|
|
developmentId,
|
|||
|
|
publishRequestId,
|
|||
|
|
"failed",
|
|||
|
|
`FAIL_0 ${String(error.message || error).slice(0, 400)}`,
|
|||
|
|
);
|
|||
|
|
} catch {
|
|||
|
|
// The original finalization failure remains authoritative.
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function selfTest() {
|
|||
|
|
const canonical = normalizeCodeChannelRepository(
|
|||
|
|
"https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git",
|
|||
|
|
);
|
|||
|
|
if (
|
|||
|
|
canonical.identity !==
|
|||
|
|
"repo://guanghulab.com/code/bingshuo/guanghu-ice-heart"
|
|||
|
|
) {
|
|||
|
|
throw new Error("SELF_TEST_REPOSITORY_NORMALIZATION_FAILED");
|
|||
|
|
}
|
|||
|
|
for (const invalid of [
|
|||
|
|
"https://guanghulab.com/fifth-domain/bingshuo/fifth-domain.git",
|
|||
|
|
"https://user:secret@guanghulab.com/code/bingshuo/guanghu-ice-heart.git",
|
|||
|
|
]) {
|
|||
|
|
let rejected = false;
|
|||
|
|
try {
|
|||
|
|
normalizeCodeChannelRepository(invalid);
|
|||
|
|
} catch {
|
|||
|
|
rejected = true;
|
|||
|
|
}
|
|||
|
|
if (!rejected) throw new Error("SELF_TEST_INVALID_REPOSITORY_ACCEPTED");
|
|||
|
|
}
|
|||
|
|
if (
|
|||
|
|
normalizeReceipt(
|
|||
|
|
"deployment/receipts/GLW-CHJH-ROUTER-CONTROLLER-JD-20260805.json",
|
|||
|
|
) !==
|
|||
|
|
"deployment/receipts/GLW-CHJH-ROUTER-CONTROLLER-JD-20260805.json"
|
|||
|
|
) {
|
|||
|
|
throw new Error("SELF_TEST_RECEIPT_NORMALIZATION_FAILED");
|
|||
|
|
}
|
|||
|
|
return { result: "PASS_100" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function main() {
|
|||
|
|
const args = parseArgs(process.argv.slice(2));
|
|||
|
|
const result = args.selfTest
|
|||
|
|
? selfTest()
|
|||
|
|
: await finalize({
|
|||
|
|
developmentId: args.developmentId,
|
|||
|
|
worktree: args.worktree,
|
|||
|
|
repository: args.repository,
|
|||
|
|
target: args.target,
|
|||
|
|
receipts: args.receipts,
|
|||
|
|
cleanupPaths: args.cleanupPaths,
|
|||
|
|
guardScript: args.guardScript,
|
|||
|
|
storeRoot: args.storeRoot,
|
|||
|
|
});
|
|||
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|||
|
|
main().catch((error) => {
|
|||
|
|
process.stderr.write(`FINALIZATION_FAIL_0:${String(error.message || error)}\n`);
|
|||
|
|
process.exitCode = 1;
|
|||
|
|
});
|
|||
|
|
}
|