diff --git a/AGENTS.md b/AGENTS.md index cf392023aca8d..814fc85e58646 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,14 @@ For an already-started LastCode checkpoint service run, use the resumable the action handoff and result rules in `docs/lastcode/release.md`; this action does not start or repair the service. +For an authorized local Apple Silicon package build, select the exact installable +tag and use the resumable **Build Local Package** action (`lc-build-local-package`) +as described in `docs/lastcode/release.md`. End the turn after launch; do not run +the build helper directly or poll build logs. The action builds only; installation +and restart remain separate decisions. If another Action continuation is pending, +end the turn so its result can arrive, then list Actions again. An automated +message alone does not make Actions unavailable; use the actual disabled reason. + ## Pull requests - Never make a PR unless the developer explicitly asks you to do so. diff --git a/docs/lastcode/release.md b/docs/lastcode/release.md index ea91a7d228b17..0c4acbbeac37e 100644 --- a/docs/lastcode/release.md +++ b/docs/lastcode/release.md @@ -174,6 +174,41 @@ checkpoint stamp cannot authorize a PR merge. ## Apple Silicon DMG builder +### Resumable local builds + +For an authorized local Apple Silicon build, first select one exact installable +checkpoint or revision in the requesting thread's worktree: + +```bash +mise exec node@24.13.1 -- node "$T3CODE_PROJECT_ROOT/scripts/lastcode-build-local-package.ts" select \ + --tag lastcode/revision/v0.0.34-nightly.20260825.1185.3 +``` + +`T3CODE_PROJECT_ROOT` must identify the primary repository checkout; when invoking +this outside a Project Action, substitute that checkout's absolute path if the +variable is unset. Selection records the tag and full commit for this worktree. +Then list Project Actions, launch the eligible **Build Local Package** action +(`lc-build-local-package`) with `run_project_action_and_resume`, and end the turn +immediately. Do not run the helper directly, tail logs, or poll the action. + +The action runs the existing local-update build helper, including its build lock, +checkpoint validation, full CI, and packaging. It returns the selected tag and +commit plus the resulting artifact location, or a concrete failure requiring +attention. It does not install or restart the application. It starts its own +build operation and cannot attach to a build previously launched in a terminal. +Each selection permits one attempt. Inspect a failed or interrupted result before +selecting the tag again for a deliberate retry. + +The `t3.json` declaration loads from the primary checkout so maintenance threads +with older worktrees can use it. Import the action and grant resume permission in +the owning environment; managed installations use the `lc-build-local-package` +trust entry. If a previous Action is running or awaiting continuation delivery, +preserve the next step and end the turn so its result can arrive. After delivery, +list Actions again. For other disabled reasons, report the specific blocker; +do not substitute a synchronous build or a polling loop. + +### Direct builder + An Apple Silicon DMG builder builds the selected checkpoint or revision: ```bash diff --git a/scripts/lastcode-build-local-package.test.ts b/scripts/lastcode-build-local-package.test.ts new file mode 100644 index 0000000000000..37b9a363cfc02 --- /dev/null +++ b/scripts/lastcode-build-local-package.test.ts @@ -0,0 +1,268 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Host-side disposable fixtures. +import { describe, expect, it, onTestFinished } from "vite-plus/test"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeNet from "node:net"; +import { + parseBuildResult, + consumeSelection, + executeBuild, + requestPath, + runLocalBuild, + selectLocalBuild, + verifyLocalBuild, + writeSelection, + type LocalBuildRequest, + type LocalBuildResult, + type LocalBuildDeps, +} from "./lastcode-build-local-package.ts"; + +const tag = "lastcode/checkpoint/v1.2.3-nightly.20260904.7"; +const commit = "a".repeat(40); +const request = (overrides: Partial = {}): LocalBuildRequest => ({ + schemaVersion: 1, + tag, + commit, + requestToken: "local-12345678-1234-1234-1234-123456789abc", + ...overrides, +}); +const buildPayload = (overrides: Partial = {}): LocalBuildResult => ({ + schemaVersion: 1, + status: "built", + checkpointTag: tag, + outputDir: "/builds/x", + manifestPath: "/builds/x/build-manifest.json", + dmgPath: "/builds/x/app.dmg", + dmgSha256: "b".repeat(64), + ...overrides, +}); + +describe("local build package action", () => { + it("selects an exact tag and records its full commit", () => { + let saved: LocalBuildRequest | undefined; + expect( + selectLocalBuild( + "/repo", + tag, + { + git: () => commit, + writeRequest: (value) => { + saved = value; + }, + }, + request().requestToken, + ), + ).toEqual(request()); + expect(saved).toEqual(request()); + }); + it("rejects moving or invalid selectors and parses the helper result", () => { + expect(() => + selectLocalBuild("/repo", "latest", { git: () => commit, writeRequest: () => {} }), + ).toThrow("exact"); + expect(() => parseBuildResult("noise\n")).toThrow("did not return"); + expect(() => + parseBuildResult( + `LASTCODE_LOCAL_UPDATE_RESULT=${JSON.stringify(buildPayload())}\nLASTCODE_LOCAL_UPDATE_RESULT=${JSON.stringify(buildPayload())}`, + ), + ).toThrow("exactly one"); + expect( + parseBuildResult(`LASTCODE_LOCAL_UPDATE_RESULT=${JSON.stringify(buildPayload())}`), + ).toMatchObject({ checkpointTag: tag }); + }); + it("writes and consumes one selection, requiring reselection", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "local-build-selection-")); + onTestFinished(() => NodeFS.rmSync(root, { recursive: true, force: true })); + const path = NodePath.join(root, "git", "lastcode-actions", "build.json"); + writeSelection(path, request()); + expect(() => + writeSelection(path, request({ tag: "lastcode/revision/v1.2.3-nightly.20260904.7.2" })), + ).toThrow("already pending"); + expect(consumeSelection(path)).toEqual(request()); + expect(() => consumeSelection(path)).toThrow("each selection permits one attempt"); + writeSelection(path, request({ requestToken: "local-aaaaaaaa-1234-1234-1234-123456789abc" })); + expect(consumeSelection(path).requestToken).toContain("local-aaaaaaaa"); + NodeFS.writeFileSync(path, JSON.stringify({ ...request(), commit: "not-a-commit" })); + expect(() => consumeSelection(path)).toThrow("invalid"); + expect(requestPath(root, () => "git/lastcode-actions/build.json")).toBe( + NodePath.join(root, "git/lastcode-actions/build.json"), + ); + }); + it("verifies a complete helper artifact and rejects missing DMGs or wrong commits", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "local-build-artifact-")); + onTestFinished(() => NodeFS.rmSync(root, { recursive: true, force: true })); + const git = (args: string[]) => + NodeChildProcess.execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + ...args, + ], + { cwd: root, encoding: "utf8" }, + ).trim(); + git(["init", "-q"]); + NodeFS.writeFileSync(NodePath.join(root, "source"), "source\n"); + git(["add", "."]); + git(["commit", "-qm", "source"]); + const commit = git(["rev-parse", "HEAD"]); + const tagName = "lastcode/build/v1.2.3-nightly.20260904.7.1"; + git(["tag", "-a", tagName, "-m", "build"]); + const output = NodePath.join( + root, + ".lastcode", + "local-updates", + "artifacts", + "v1.2.3-nightly.20260904.7", + commit.slice(0, 10), + ); + NodeFS.mkdirSync(output, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(output, "nightly-mac.yml"), "fixture\n"); + NodeFS.writeFileSync(NodePath.join(output, "SHA256SUMS"), "fixture\n"); + NodeFS.writeFileSync(NodePath.join(output, "LastCode.dmg"), "dmg\n"); + NodeFS.writeFileSync(NodePath.join(output, "LastCode.zip"), "zip\n"); + const sha = "b".repeat(64); + NodeFS.writeFileSync( + NodePath.join(output, "build-manifest.json"), + JSON.stringify({ + schemaVersion: 1, + checkpointTag: tag, + lastCodeCommit: commit, + buildTag: tagName, + artifacts: [{ path: "LastCode.dmg", sha256: sha }], + }), + ); + const req = request({ tag, commit }); + const result = buildPayload({ + checkpointTag: tag, + outputDir: output, + manifestPath: NodePath.join(output, "build-manifest.json"), + dmgPath: NodePath.join(output, "LastCode.dmg"), + dmgSha256: sha, + }); + expect(() => verifyLocalBuild(result, req, root, root)).not.toThrow(); + expect(() => verifyLocalBuild(result, request({ commit: "c".repeat(40) }), root, root)).toThrow( + "complete verified", + ); + NodeFS.rmSync(NodePath.join(output, "LastCode.dmg")); + expect(() => verifyLocalBuild(result, req, root, root)).toThrow("complete"); + }); + it("runs the canonical helper and verifies the selected target before and after", async () => { + const calls: string[][] = []; + const build = await runLocalBuild("/repo", { + git: () => commit, + readRequest: () => request(), + writeRequest: () => {}, + execute: (_command, args) => { + calls.push([...args]); + return { + code: 0, + stdout: `LASTCODE_LOCAL_UPDATE_RESULT=${JSON.stringify(buildPayload())}`, + stderr: "", + }; + }, + verifyResult: () => {}, + }); + expect(build.outputDir).toBe("/builds/x"); + expect(calls[0]?.some((value) => value.endsWith("/scripts/lastcode-local-update.mjs"))).toBe( + true, + ); + expect(calls[0]).toContain("--checkpoint"); + }); + it("turns child failures and mismatched results into actionable errors", async () => { + await expect( + runLocalBuild("/repo", { + git: () => commit, + readRequest: () => request(), + writeRequest: () => {}, + execute: () => ({ code: 1, stdout: "", stderr: "compiler failed" }), + verifyResult: () => {}, + }), + ).rejects.toThrow("compiler failed"); + await expect( + runLocalBuild("/repo", { + git: () => commit, + readRequest: () => request(), + writeRequest: () => {}, + execute: () => ({ + code: 0, + stdout: `LASTCODE_LOCAL_UPDATE_RESULT=${JSON.stringify(buildPayload({ checkpointTag: "other" }))}`, + stderr: "", + }), + verifyResult: () => {}, + }), + ).rejects.toThrow("expected"); + }); + it("bounds captured stdout and stderr from the owned child", async () => { + const result = await executeBuild( + process.execPath, + ["-e", "process.stdout.write('x'.repeat(100000)); process.stderr.write('y'.repeat(100000));"], + process.cwd(), + ); + expect(result.code).toBe(0); + expect(result.stdout.length).toBeLessThanOrEqual(64 * 1024); + expect(result.stderr.length).toBeLessThanOrEqual(64 * 1024); + }); + it("rejects a moved target before execution and after successful helper completion", async () => { + let executions = 0; + const deps: LocalBuildDeps = { + git: () => "b".repeat(40), + readRequest: () => request(), + writeRequest: () => {}, + execute: () => { + executions++; + return { + code: 0, + stdout: `LASTCODE_LOCAL_UPDATE_RESULT=${JSON.stringify(buildPayload())}`, + stderr: "", + }; + }, + verifyResult: () => {}, + }; + await expect(runLocalBuild("/repo", deps)).rejects.toThrow("moved"); + expect(executions).toBe(0); + let resolutions = 0; + await expect( + runLocalBuild("/repo", { + ...deps, + git: () => (resolutions++ === 0 ? commit : "b".repeat(40)), + }), + ).rejects.toThrow("changed during"); + expect(executions).toBe(1); + }); + + it("cancels its owned child when aborted after a readiness receipt", async () => { + const server = NodeNet.createServer(); + server.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing fixture port"); + const controller = new AbortController(); + const connected = new Promise((resolve) => server.once("connection", resolve)); + const running = executeBuild( + process.execPath, + ["-e", `require('node:net').connect(${address.port}, '127.0.0.1');`], + process.cwd(), + controller.signal, + ); + const rejection = expect(running).rejects.toThrow("fixture cancelled"); + try { + const socket = await connected; + const closed = new Promise((resolve) => socket.once("close", () => resolve())); + controller.abort(new Error("fixture cancelled")); + await rejection; + await closed; + } finally { + controller.abort(new Error("fixture cancelled")); + server.close(); + await new Promise((resolve) => server.once("close", resolve)); + } + }); +}); diff --git a/scripts/lastcode-build-local-package.ts b/scripts/lastcode-build-local-package.ts new file mode 100644 index 0000000000000..8d726e1d9f59e --- /dev/null +++ b/scripts/lastcode-build-local-package.ts @@ -0,0 +1,349 @@ +#!/usr/bin/env node + +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- Host-side local packaging. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import { acquirePortableLock } from "./lastcode-lock.mjs"; + +import { lastCodeAction } from "./lib/lastcode-action-kit.ts"; +import { resolveExistingBuild } from "./lastcode-local-update.mjs"; + +const TAG = /^lastcode\/(?:checkpoint|revision)\/v\d+\.\d+\.\d+-nightly\.\d{8}\.\d+(?:\.\d+)?$/u; +const TOKEN = /^local-[0-9a-f-]{36}$/u; +const RESULT_PREFIX = "LASTCODE_LOCAL_UPDATE_RESULT="; + +export type LocalBuildRequest = { + readonly schemaVersion: 1; + readonly tag: string; + readonly commit: string; + readonly requestToken: string; +}; +export type LocalBuildResult = { + readonly schemaVersion: 1; + readonly status: "built"; + readonly checkpointTag: string; + readonly outputDir: string; + readonly manifestPath: string; + readonly dmgPath: string; + readonly dmgSha256: string; +}; +export type LocalBuildDeps = { + readonly git: (root: string, args: ReadonlyArray) => string; + readonly writeRequest: (request: LocalBuildRequest) => void; + readonly readRequest: () => LocalBuildRequest; + readonly execute: ( + command: string, + args: ReadonlyArray, + cwd: string, + ) => + | { readonly code: number; readonly stdout: string; readonly stderr: string } + | Promise<{ readonly code: number; readonly stdout: string; readonly stderr: string }>; + readonly verifyResult?: ( + result: LocalBuildResult, + request: LocalBuildRequest, + root: string, + ) => void; + readonly progress?: (input: { + readonly state: "working" | "waiting"; + readonly phase: string; + readonly summary: string; + }) => void; +}; + +export function requestPath(repoRoot: string, git: LocalBuildDeps["git"] = defaultGit): string { + return NodePath.resolve( + repoRoot, + git(repoRoot, ["rev-parse", "--git-path", "lastcode-actions/build-local-package.json"]), + ); +} +function defaultGit(root: string, args: ReadonlyArray): string { + const r = NodeChildProcess.spawnSync("git", args, { + cwd: root, + encoding: "utf8", + timeout: 30_000, + maxBuffer: 64 * 1024, + }); + if (r.status !== 0) throw new Error(r.error?.message || r.stderr?.trim() || "git failed"); + return r.stdout.trim(); +} +function exactTag(tag: string): string { + if (!TAG.test(tag)) + throw new Error( + "Expected an exact lastcode/checkpoint/... or lastcode/revision/... nightly tag.", + ); + return tag; +} +export function selectLocalBuild( + repoRoot: string, + tag: string, + deps: Pick, + token = `local-${NodeCrypto.randomUUID()}`, +): LocalBuildRequest { + const selected = exactTag(tag); + const commit = deps.git(repoRoot, ["rev-parse", `${selected}^{commit}`]); + if (!/^[0-9a-f]{40}$/u.test(commit)) + throw new Error("Selected build returned an invalid commit."); + if (!TOKEN.test(token)) throw new Error("Invalid local build request token."); + const request = { schemaVersion: 1 as const, tag: selected, commit, requestToken: token }; + deps.writeRequest(request); + return request; +} +function validateRequest(value: unknown): LocalBuildRequest { + if (value === null || typeof value !== "object") + throw new Error("Local build selection is invalid."); + const request = value as Partial; + if ( + request.schemaVersion !== 1 || + typeof request.tag !== "string" || + !TAG.test(request.tag) || + typeof request.commit !== "string" || + !/^[0-9a-f]{40}$/u.test(request.commit) || + typeof request.requestToken !== "string" || + !TOKEN.test(request.requestToken) + ) + throw new Error("Local build selection is invalid."); + return request as LocalBuildRequest; +} +export function parseBuildResult(stdout: string): LocalBuildResult { + const lines = stdout.split(/\r?\n/u).filter((value) => value.startsWith(RESULT_PREFIX)); + const line = lines[0]; + if (!line || lines.length !== 1) + throw new Error("The local build helper did not return exactly one build result."); + const result: unknown = JSON.parse(line.slice(RESULT_PREFIX.length)); + if ( + result === null || + typeof result !== "object" || + (result as Record).schemaVersion !== 1 || + (result as Record).status !== "built" || + typeof (result as Record).checkpointTag !== "string" || + typeof (result as Record).outputDir !== "string" || + typeof (result as Record).manifestPath !== "string" || + typeof (result as Record).dmgPath !== "string" || + typeof (result as Record).dmgSha256 !== "string" || + !/^[a-f0-9]{64}$/u.test((result as Record).dmgSha256!) + ) + throw new Error("The local build helper returned an invalid build result."); + return result as LocalBuildResult; +} +export async function runLocalBuild( + repoRoot: string, + deps: LocalBuildDeps, +): Promise { + const request = validateRequest(deps.readRequest()); + exactTag(request.tag); + const before = deps.git(repoRoot, ["rev-parse", `${request.tag}^{commit}`]); + if (before !== request.commit) + throw new Error(`Selected tag ${request.tag} moved or no longer points to ${request.commit}.`); + deps.progress?.({ state: "working", phase: "build", summary: `Building ${request.tag}` }); + const result = await deps.execute( + process.execPath, + [ + NodePath.join(import.meta.dirname, "lastcode-local-update.mjs"), + "build", + "--repo", + repoRoot, + "--home", + NodeOS.homedir(), + "--checkpoint", + request.tag, + ], + repoRoot, + ); + if (result.code !== 0) + throw new Error(result.stderr.trim() || `Local build failed with exit code ${result.code}.`); + const built = parseBuildResult(result.stdout); + if (built.checkpointTag !== request.tag) + throw new Error(`Build result targeted ${built.checkpointTag}, expected ${request.tag}.`); + (deps.verifyResult ?? verifyLocalBuild)(built, request, repoRoot); + const after = deps.git(repoRoot, ["rev-parse", `${request.tag}^{commit}`]); + if (after !== request.commit) + throw new Error(`Selected tag ${request.tag} changed during the build.`); + return built; +} + +export function verifyLocalBuild( + built: LocalBuildResult, + request: LocalBuildRequest, + repoRoot: string, + home = NodeOS.homedir(), +): void { + const verified = resolveExistingBuild({ + repoRoot, + outputRoot: NodePath.join(home, ".lastcode", "local-updates", "artifacts"), + checkpointTag: request.tag, + checkpointCommit: request.commit, + }); + if ( + !verified || + verified.outputDir !== built.outputDir || + built.manifestPath !== verified.manifestPath || + built.dmgPath !== verified.dmgPath || + built.dmgSha256 !== verified.dmgSha256 + ) + throw new Error("Build result does not describe a complete verified artifact."); +} + +/** Serialize selection and consumption so concurrent launches cannot reuse a request. */ +export function withRequestLock(path: string, operation: () => T): T { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + const release = acquirePortableLock( + NodePath.dirname(path), + "build-local-package.lock", + "local build selection", + ); + try { + return operation(); + } finally { + release(); + } +} +export function writeSelection(path: string, request: LocalBuildRequest): void { + withRequestLock(path, () => { + if (NodeFS.existsSync(path)) { + throw new Error( + `A local build selection is already pending at ${path}. Complete or explicitly discard that selection before selecting another tag.`, + ); + } + const temporary = `${path}.${NodeCrypto.randomUUID()}.tmp`; + try { + NodeFS.writeFileSync(temporary, `${JSON.stringify(validateRequest(request), null, 2)}\n`, { + mode: 0o600, + }); + NodeFS.renameSync(temporary, path); + } finally { + NodeFS.rmSync(temporary, { force: true }); + } + }); +} +export function consumeSelection(path: string): LocalBuildRequest { + return withRequestLock(path, () => { + if (!NodeFS.existsSync(path)) + throw new Error( + "Select an exact tag before running Build Local Package; each selection permits one attempt.", + ); + const request = validateRequest(JSON.parse(NodeFS.readFileSync(path, "utf8"))); + NodeFS.rmSync(path); + return request; + }); +} + +/** Own a child process group; cancellation only signals processes spawned for this build. */ +export async function executeBuild( + command: string, + args: ReadonlyArray, + cwd: string, + signal?: AbortSignal, +) { + signal?.throwIfAborted(); + const child = NodeChildProcess.spawn(command, args, { + cwd, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout = (stdout + chunk).slice(-64 * 1024); + }); + child.stderr.on("data", (chunk: string) => { + stderr = (stderr + chunk).slice(-64 * 1024); + }); + const cancel = () => { + if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) { + try { + process.kill(-child.pid, "SIGTERM"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + }; + signal?.addEventListener("abort", cancel, { once: true }); + if (signal?.aborted) cancel(); + try { + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (value) => resolve(value ?? 1)); + }); + signal?.throwIfAborted(); + return { code, stdout, stderr }; + } finally { + signal?.removeEventListener("abort", cancel); + } +} +async function main(argv: ReadonlyArray): Promise { + const command = argv[0]; + const repoRoot = process.cwd(); + if (command === "select" && argv.length === 3 && argv[1] === "--tag") { + selectLocalBuild(repoRoot, argv[2]!, { + git: defaultGit, + writeRequest: (request) => writeSelection(requestPath(repoRoot), request), + }); + return; + } + if (command !== "run" || argv.length !== 1) + throw new Error( + "Usage: lastcode-build-local-package.ts select --tag | lastcode-build-local-package.ts run", + ); + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone build Action has no Effect runtime. + if (NodeOS.platform() !== "darwin" || NodeOS.arch() !== "arm64") + throw new Error("Build Local Package requires an Apple Silicon macOS host."); + const request = consumeSelection(requestPath(repoRoot)); + const canonicalRoot = NodePath.resolve(import.meta.dirname, ".."); + const controller = new AbortController(); + const interrupt = () => { + process.exitCode = 130; + controller.abort(new Error("Local build cancelled.")); + }; + const terminate = () => { + process.exitCode = 143; + controller.abort(new Error("Local build cancelled.")); + }; + process.once("SIGINT", interrupt); + process.once("SIGTERM", terminate); + try { + const built = await runLocalBuild(canonicalRoot, { + git: defaultGit, + readRequest: () => request, + writeRequest: () => {}, + execute: (cmd, args, cwd) => executeBuild(cmd, args, cwd, controller.signal), + progress: (value) => lastCodeAction.progress(value), + }); + lastCodeAction.result({ + outcome: "success", + reason: "built", + summary: `Local package ${built.checkpointTag} is ready`, + subject: { type: "local-build", id: built.checkpointTag, revision: request.commit }, + facts: { + outputDir: built.outputDir, + dmgPath: built.dmgPath, + manifestPath: built.manifestPath, + dmgSha256: built.dmgSha256, + }, + }); + } finally { + process.removeListener("SIGINT", interrupt); + process.removeListener("SIGTERM", terminate); + } +} +if (import.meta.main) { + try { + await main(process.argv.slice(2)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[build-local] ${message}`); + if (process.argv[2] !== "run") process.exitCode = 1; + lastCodeAction.result({ + outcome: "attention", + reason: process.exitCode === 130 || process.exitCode === 143 ? "cancelled" : "build-failed", + summary: message.slice(0, 280), + facts: { + status: process.exitCode === 130 || process.exitCode === 143 ? "cancelled" : "failed", + }, + }); + } +} diff --git a/scripts/lastcode-checkpoint-supervisor.mjs b/scripts/lastcode-checkpoint-supervisor.mjs index 533695e23540e..b296818eb6067 100644 --- a/scripts/lastcode-checkpoint-supervisor.mjs +++ b/scripts/lastcode-checkpoint-supervisor.mjs @@ -440,6 +440,7 @@ export function checkpointFailureMessage(failure, fingerprint) { "The bounded service logs are ~/.lastcode/automation/nightly-checkpoint.stdout.log and nightly-checkpoint.stderr.log.", "Use this thread for the recovery; do not create a new maintenance thread and do not only report the alert.", "For an already-started service run, use the resumable Wait for Checkpoint action as described in docs/lastcode/release.md instead of sleep/status loops. It only observes completion and does not start or repair the service.", + "If an authorized recovery requires a local Apple Silicon build, follow the Resumable local builds section in docs/lastcode/release.md and use Build Local Package (lc-build-local-package), then end the turn instead of polling build logs. A pending prior Action continuation must be delivered before starting another Action.", "If the authorized recovery includes a LastCode PR, read .agents/skills/lastcode-pr/SKILL.md. When CI or review is passive and no current finding needs judgement, call list_project_actions, launch the eligible Wait for PR action with run_project_action_and_resume (prefer lc-wait-for-pr), and end the turn immediately without polling. This reminder does not authorize creating or merging a PR.", ] .filter(Boolean) diff --git a/scripts/lastcode-local-update.mjs b/scripts/lastcode-local-update.mjs index c976e684b19ea..99c9ccf63485f 100644 --- a/scripts/lastcode-local-update.mjs +++ b/scripts/lastcode-local-update.mjs @@ -76,6 +76,7 @@ export function localBuildFailureMessage(failure, fingerprint) { `Retained build worktree: ${failure.worktreePath}`, "Inspect the retained worktree and build log, address the concrete failure, and leave a working up-to-date LastCode build.", "Use this persistent maintenance thread for the recovery; do not create a new thread and do not only report the alert.", + "For an authorized replacement build, read the Resumable local builds section in docs/lastcode/release.md, select the exact installable tag, and launch the eligible Build Local Package action (lc-build-local-package) with run_project_action_and_resume. End the turn immediately instead of running the helper directly or polling logs. If a prior Action continuation is pending, end the turn for its delivery before launching another Action.", ].join("\n"); } diff --git a/t3.json b/t3.json index 1c82a1151516d..343bcb4a9835e 100644 --- a/t3.json +++ b/t3.json @@ -32,6 +32,12 @@ "command": "mise exec node@24.13.1 -- node scripts/lastcode-local-ci.ts --quick", "icon": "test" }, + { + "id": "lc-build-local-package", + "name": "Build Local Package", + "command": "mise exec node@24.13.1 -- node \"$T3CODE_PROJECT_ROOT/scripts/lastcode-build-local-package.ts\" run", + "icon": "build" + }, { "id": "lc-build-intel-package", "name": "Build Intel package (macOS)",