diff --git a/.env.example b/.env.example index 92d807d82..562580ab6 100644 --- a/.env.example +++ b/.env.example @@ -77,10 +77,11 @@ # ── Personal Alpha Canary redeployment ── # Source-run `pnpm start:web` only. When enabled, the authenticated owner sees -# Settings controls that compare the running commit with origin/alpha and can -# run `scripts/start-huabu.sh alpha --non-interactive` on this host. The script -# updates the checkout in place and may leave the service offline on failure; -# this is a personal-development convenience, not a production deployer. +# Settings controls that persist an exact branch from fixed remote origin, +# defaulting to alpha when unconfigured, and can run +# `scripts/start-huabu.sh --non-interactive` on this host. The script +# updates the checkout in place and may leave the service offline on failure. +# This is a personal-development convenience, not a production deployer. # HUABU_CANARY_REDEPLOY_ENABLED=1 # Non-interactive redeployment waits up to 300 seconds by default. # HUABU_CANARY_READINESS_TIMEOUT_SECONDS=300 diff --git a/README.md b/README.md index fedbb5232..18d54db9d 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Then run `pnpm start:web`. Huabu rejects a non-loopback bind when allowed hosts Huabu currently serves HTTP. Use a trusted private network or terminate HTTPS with deployment infrastructure such as Caddy, Nginx, Tailscale Serve, or a cloud load balancer. Do not put a Basic Auth deployment on an untrusted network without transport encryption. -For a personal Alpha Canary started from a repository checkout, set `HUABU_CANARY_REDEPLOY_ENABLED=1` before `pnpm start:web`. The authenticated owner can then compare the running commit with `origin/alpha` and invoke the checked-in `scripts/start-huabu.sh alpha --non-interactive` redeployment from Settings instead of connecting through SSH. This helper updates the checkout in place and does not provide rollback or service recovery; see [Alpha Canary deployment](docs/architecture/canary-deployment.md). +For a personal Alpha Canary started from a repository checkout, set `HUABU_CANARY_REDEPLOY_ENABLED=1` before `pnpm start:web`. The authenticated owner can configure an exact branch from fixed remote `origin` in Settings, compare it with the running commit, and invoke the checked-in `scripts/start-huabu.sh --non-interactive` redeployment instead of connecting through SSH. An empty configuration uses `alpha`. This helper updates the checkout in place and does not provide rollback or service recovery; see [Alpha Canary deployment](docs/architecture/canary-deployment.md). ### Local quality checks (optional) diff --git a/apps/server/src/modules/security/canary-redeploy.route.test.ts b/apps/server/src/modules/security/canary-redeploy.route.test.ts index 2f9d445ea..8910b6d7f 100644 --- a/apps/server/src/modules/security/canary-redeploy.route.test.ts +++ b/apps/server/src/modules/security/canary-redeploy.route.test.ts @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import Fastify from 'fastify'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -10,10 +14,14 @@ import type { FastifyInstance } from 'fastify'; describe('Canary redeployment routes', () => { let app: FastifyInstance; + let dataDir: string; const originalEnabled = process.env.HUABU_CANARY_REDEPLOY_ENABLED; + const originalDataDir = process.env.HUABU_DATA_DIR; beforeEach(async () => { delete process.env.HUABU_CANARY_REDEPLOY_ENABLED; + dataDir = mkdtempSync(join(tmpdir(), 'huabu-canary-route-')); + process.env.HUABU_DATA_DIR = dataDir; app = Fastify({ logger: false }); await app.register(canaryRedeployRoutes, { prefix: '/api/deployment/canary', @@ -27,6 +35,12 @@ describe('Canary redeployment routes', () => { } else { process.env.HUABU_CANARY_REDEPLOY_ENABLED = originalEnabled; } + if (originalDataDir === undefined) { + delete process.env.HUABU_DATA_DIR; + } else { + process.env.HUABU_DATA_DIR = originalDataDir; + } + rmSync(dataDir, { recursive: true, force: true }); }); it('lets the local owner inspect a disabled capability', async () => { @@ -39,6 +53,7 @@ describe('Canary redeployment routes', () => { available: false, reason: 'disabled', branch: 'alpha', + configuredBranch: null, }); }); @@ -63,7 +78,27 @@ describe('Canary redeployment routes', () => { const unavailable = await app.inject({ method: 'POST', url: '/api/deployment/canary/redeploy', - payload: {}, + payload: { expectedBranch: 'alpha' }, + }); + expect(unavailable.statusCode).toBe(503); + expect(unavailable.json()).toMatchObject({ + code: 'canary_redeploy_unavailable', + }); + }); + + it('validates branch configuration before capability checks', async () => { + const malformed = await app.inject({ + method: 'PUT', + url: '/api/deployment/canary/config', + payload: { branch: '' }, + }); + expect(malformed.statusCode).toBe(400); + expect(malformed.json()).toMatchObject({ code: 'validation_failed' }); + + const unavailable = await app.inject({ + method: 'PUT', + url: '/api/deployment/canary/config', + payload: { branch: 'x/alpha' }, }); expect(unavailable.statusCode).toBe(503); expect(unavailable.json()).toMatchObject({ diff --git a/apps/server/src/modules/security/canary-redeploy.route.ts b/apps/server/src/modules/security/canary-redeploy.route.ts index 4442cded5..f08214e2d 100644 --- a/apps/server/src/modules/security/canary-redeploy.route.ts +++ b/apps/server/src/modules/security/canary-redeploy.route.ts @@ -2,45 +2,109 @@ // Licensed under the MIT license. import { + canaryCheckRequestSchema, + canaryRedeployConfigUpdateSchema, canaryRedeployRequestSchema, type ApiResult, + type CanaryCheckRequest, + type CanaryRedeployConfigUpdate, type CanaryRedeployRequest, type CanaryRedeployStatusResponse, } from '@huabu/shared'; import { + CanaryRedeployError, checkCanaryRemote, getCanaryRedeployStatus, requestCanaryRedeploy, + setCanaryRedeployConfig, } from './canary-redeploy.js'; import { isOwnerRequest } from './owner.js'; -import type { FastifyPluginAsync } from 'fastify'; +import type { FastifyPluginAsync, FastifyReply } from 'fastify'; + +function sendCanaryError( + reply: FastifyReply, + error: unknown, + fallback: string, +) { + if (error instanceof CanaryRedeployError) { + const status = + error.code === 'operation_in_progress' || error.code === 'stale_branch' + ? 409 + : error.code === 'branch_invalid' + ? 400 + : error.code === 'branch_unavailable' + ? 422 + : error.code === 'config_invalid' + ? 500 + : 503; + return reply.status(status).send({ + message: error.message, + code: `canary_${error.code}`, + }); + } + return reply.status(500).send({ + message: fallback, + code: 'canary_internal_error', + }); +} const canaryRedeployRoutes: FastifyPluginAsync = async (app) => { + app.addHook('preHandler', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: 'Forbidden: Canary redeployment requires owner authorization', + }); + } + }); + app.get<{ Reply: ApiResult }>( '/', async (request, reply) => { - if (!isOwnerRequest(request)) { - return reply.status(403).send({ - message: - 'Forbidden: Canary redeployment requires owner authorization', - }); + try { + return await getCanaryRedeployStatus(); + } catch (error) { + request.log.error({ err: error }, 'Unable to read Canary status'); + return sendCanaryError( + reply, + error, + 'Unable to load Canary redeployment status', + ); } - return getCanaryRedeployStatus(); }, ); - app.post<{ - Body: CanaryRedeployRequest; + app.put<{ + Body: CanaryRedeployConfigUpdate; Reply: ApiResult; - }>('/check', async (request, reply) => { - if (!isOwnerRequest(request)) { - return reply.status(403).send({ - message: 'Forbidden: Canary redeployment requires owner authorization', + }>('/config', async (request, reply) => { + const parsed = canaryRedeployConfigUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + message: + parsed.error.issues[0]?.message ?? + 'Invalid Canary branch configuration', + code: 'validation_failed', }); } - const parsed = canaryRedeployRequestSchema.safeParse(request.body); + try { + return await setCanaryRedeployConfig(parsed.data); + } catch (error) { + request.log.warn({ err: error }, 'Unable to save Canary branch'); + return sendCanaryError( + reply, + error, + 'Unable to save Canary branch configuration', + ); + } + }); + + app.post<{ + Body: CanaryCheckRequest; + Reply: ApiResult; + }>('/check', async (request, reply) => { + const parsed = canaryCheckRequestSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ message: @@ -52,10 +116,7 @@ const canaryRedeployRoutes: FastifyPluginAsync = async (app) => { return await checkCanaryRemote(); } catch (error) { request.log.warn({ err: error }, 'Canary update check failed'); - return reply.status(502).send({ - message: 'Unable to resolve origin/alpha', - code: 'canary_check_failed', - }); + return sendCanaryError(reply, error, 'Unable to check Canary branch'); } }); @@ -63,11 +124,6 @@ const canaryRedeployRoutes: FastifyPluginAsync = async (app) => { Body: CanaryRedeployRequest; Reply: ApiResult; }>('/redeploy', async (request, reply) => { - if (!isOwnerRequest(request)) { - return reply.status(403).send({ - message: 'Forbidden: Canary redeployment requires owner authorization', - }); - } const parsed = canaryRedeployRequestSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ @@ -77,21 +133,15 @@ const canaryRedeployRoutes: FastifyPluginAsync = async (app) => { }); } try { - const status = await requestCanaryRedeploy(); + const status = await requestCanaryRedeploy(parsed.data.expectedBranch); return reply.status(202).send(status); } catch (error) { - const message = error instanceof Error ? error.message : ''; - if (message === 'Canary redeployment is already in progress') { - return reply.status(409).send({ - message, - code: 'canary_redeploy_in_progress', - }); - } request.log.error({ err: error }, 'Unable to start Canary redeployment'); - return reply.status(503).send({ - message: 'Canary redeployment is unavailable', - code: 'canary_redeploy_unavailable', - }); + return sendCanaryError( + reply, + error, + 'Canary redeployment is unavailable', + ); } }); }; diff --git a/apps/server/src/modules/security/canary-redeploy.test.ts b/apps/server/src/modules/security/canary-redeploy.test.ts index e1ef186a5..009f43e84 100644 --- a/apps/server/src/modules/security/canary-redeploy.test.ts +++ b/apps/server/src/modules/security/canary-redeploy.test.ts @@ -18,8 +18,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { checkCanaryRemote, getCanaryRedeployStatus, + requestCanaryRedeploy, resetCanaryRedeployStateForTest, resolveCanaryCapability, + setCanaryRedeployConfig, writeCanaryRedeployResult, } from './canary-redeploy.js'; @@ -104,15 +106,89 @@ describe('Canary redeployment service', () => { expect(status).toMatchObject({ available: true, branch: 'alpha', + configuredBranch: null, updateAvailable: false, runningSha: status.remoteSha, }); expect(status.checkedAt).toEqual(expect.any(Number)); }); + it('persists and checks one exact nested origin branch', async () => { + execFileSync('git', ['branch', 'x/alpha'], { cwd: root }); + execFileSync('git', ['push', 'origin', 'x/alpha'], { cwd: root }); + + await expect( + setCanaryRedeployConfig({ branch: 'x/alpha' }), + ).resolves.toMatchObject({ + branch: 'x/alpha', + configuredBranch: 'x/alpha', + updateAvailable: false, + }); + await expect(checkCanaryRemote()).resolves.toMatchObject({ + branch: 'x/alpha', + configuredBranch: 'x/alpha', + }); + + expect( + JSON.parse( + readFileSync(join(dataDir, 'canary-redeploy-config.json'), 'utf8'), + ), + ).toEqual({ version: 1, branch: 'x/alpha' }); + }); + + it('uses alpha only for an explicit reset and rejects invalid or unavailable refs', async () => { + await expect( + setCanaryRedeployConfig({ branch: '-bad' }), + ).rejects.toMatchObject({ code: 'branch_invalid' }); + await expect( + setCanaryRedeployConfig({ branch: 'HEAD' }), + ).rejects.toMatchObject({ code: 'branch_invalid' }); + await expect( + setCanaryRedeployConfig({ branch: 'missing' }), + ).rejects.toMatchObject({ code: 'branch_unavailable' }); + await expect( + setCanaryRedeployConfig({ branch: null }), + ).resolves.toMatchObject({ + branch: 'alpha', + configuredBranch: null, + }); + }); + + it('fails explicitly for malformed persisted configuration', async () => { + writeFileSync( + join(dataDir, 'canary-redeploy-config.json'), + '{"version":1,"branch":"bad..branch"}', + ); + await expect(getCanaryRedeployStatus()).rejects.toMatchObject({ + code: 'config_invalid', + }); + }); + + it('rejects stale confirmations and configuration changes during a persistent redeploy', async () => { + await expect(requestCanaryRedeploy('x/alpha')).rejects.toMatchObject({ + code: 'stale_branch', + }); + + writeFileSync( + join(dataDir, 'canary-redeploy-status.json'), + JSON.stringify({ + state: 'running', + branch: 'alpha', + startedAt: 10, + runnerPid: process.pid, + }), + ); + await expect( + setCanaryRedeployConfig({ branch: null }), + ).rejects.toMatchObject({ + code: 'operation_in_progress', + }); + }); + it('persists only the bounded redeployment result contract', async () => { await writeCanaryRedeployResult({ state: 'failed', + branch: 'x/alpha', startedAt: 10, completedAt: 20, exitCode: 1, @@ -122,6 +198,7 @@ describe('Canary redeployment service', () => { await expect(getCanaryRedeployStatus()).resolves.toMatchObject({ redeploy: { state: 'failed', + branch: 'x/alpha', exitCode: 1, }, }); @@ -143,7 +220,7 @@ describe('Canary redeployment service', () => { ); const result = spawnSync( process.execPath, - [runner, hook, statusPath, logPath, '100'], + [runner, hook, statusPath, logPath, '100', 'x/alpha'], { encoding: 'utf8' }, ); @@ -151,10 +228,42 @@ describe('Canary redeployment service', () => { const status = readFileSync(statusPath, 'utf8'); expect(JSON.parse(status)).toMatchObject({ state: 'failed', + branch: 'x/alpha', startedAt: 100, exitCode: 7, }); expect(status).not.toContain('private-output'); + expect(readFileSync(logPath, 'utf8')).toContain('Redeploying x/alpha'); expect(readFileSync(logPath, 'utf8')).toContain('private-output'); }); + + it('rejects malformed runner branch arguments before executing the hook', () => { + const marker = join(dataDir, 'unexpected-hook-run'); + const hook = join(root, 'marker-hook.sh'); + writeFileSync(hook, `#!/usr/bin/env bash\ntouch '${marker}'\n`); + chmodSync(hook, 0o755); + const runner = join( + process.cwd(), + '..', + '..', + 'scripts', + 'canary-redeploy-runner.mjs', + ); + + const result = spawnSync( + process.execPath, + [ + runner, + hook, + join(dataDir, 'invalid-status.json'), + join(dataDir, 'invalid.log'), + '100', + '-bad', + ], + { encoding: 'utf8' }, + ); + + expect(result.status).toBe(2); + expect(() => readFileSync(marker)).toThrow(); + }); }); diff --git a/apps/server/src/modules/security/canary-redeploy.ts b/apps/server/src/modules/security/canary-redeploy.ts index d66e47deb..df2fbbdfa 100644 --- a/apps/server/src/modules/security/canary-redeploy.ts +++ b/apps/server/src/modules/security/canary-redeploy.ts @@ -1,26 +1,43 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { execFile, spawn } from 'node:child_process'; +import { execFile, execFileSync, spawn } from 'node:child_process'; import { accessSync, constants, existsSync } from 'node:fs'; import { access, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; +import { z } from 'zod'; + import { + canaryBranchSchema, canaryRedeployResultSchema, canaryRedeployStatusResponseSchema, + type CanaryBranch, + type CanaryRedeployConfigUpdate, type CanaryRedeployResult, type CanaryRedeployStatusResponse, } from '@huabu/shared'; import { getDataDir } from '../../data-dir.js'; +import { atomicWriteJson, readJsonStrict } from '../../utils/fs.js'; import { getLogger } from '../../utils/logger.js'; const execFileAsync = promisify(execFile); const log = getLogger('canary-redeploy'); const SHA_PATTERN = /^[0-9a-f]{40}$/; -const BRANCH = 'alpha' as const; +const DEFAULT_BRANCH = 'alpha'; + +const configRecordSchema = z + .object({ + version: z.literal(1), + branch: canaryBranchSchema.nullable(), + }) + .strict(); + +const legacyRedeployResultSchema = canaryRedeployResultSchema.omit({ + branch: true, +}); interface CanaryCapability { available: boolean; @@ -36,12 +53,32 @@ interface StoredRedeployResult { runnerPid: number | null; } -let cachedRemote: - | { remoteSha: string; checkedAt: number } - | { remoteSha: null; checkedAt: number } - | null = null; +type CanaryOperation = 'check' | 'configure' | 'redeploy'; + +let cachedRemote: { + branch: CanaryBranch; + remoteSha: string; + checkedAt: number; +} | null = null; +let activeOperation: CanaryOperation | null = null; let redeployRequestInFlight = false; +export class CanaryRedeployError extends Error { + constructor( + readonly code: + | 'branch_invalid' + | 'branch_unavailable' + | 'config_invalid' + | 'operation_in_progress' + | 'redeploy_unavailable' + | 'stale_branch', + message: string, + ) { + super(message); + this.name = 'CanaryRedeployError'; + } +} + function enabled(env: NodeJS.ProcessEnv): boolean { return env.HUABU_CANARY_REDEPLOY_ENABLED === '1'; } @@ -113,6 +150,10 @@ export function resolveCanaryCapability( }; } +export function canaryConfigPath(): string { + return join(getDataDir(), 'canary-redeploy-config.json'); +} + export function canaryStatusPath(): string { return join(getDataDir(), 'canary-redeploy-status.json'); } @@ -121,6 +162,43 @@ export function canaryLogPath(): string { return join(getDataDir(), 'logs', 'canary-redeploy.log'); } +function readCanaryConfig(): { + branch: CanaryBranch; + configuredBranch: CanaryBranch | null; +} { + let stored: unknown; + try { + stored = readJsonStrict(canaryConfigPath()); + } catch { + throw new CanaryRedeployError( + 'config_invalid', + 'Stored Canary branch configuration is unreadable', + ); + } + if (stored === null) { + return { branch: DEFAULT_BRANCH, configuredBranch: null }; + } + const parsed = configRecordSchema.safeParse(stored); + if (!parsed.success) { + throw new CanaryRedeployError( + 'config_invalid', + 'Stored Canary branch configuration is invalid', + ); + } + try { + validateBranchFormat(parsed.data.branch ?? DEFAULT_BRANCH); + } catch { + throw new CanaryRedeployError( + 'config_invalid', + 'Stored Canary branch configuration is invalid', + ); + } + return { + branch: parsed.data.branch ?? DEFAULT_BRANCH, + configuredBranch: parsed.data.branch, + }; +} + async function readRedeployResult(): Promise { let parsed: unknown; try { @@ -130,9 +208,16 @@ async function readRedeployResult(): Promise { if (code === 'ENOENT') return null; throw error; } - const result = canaryRedeployResultSchema.safeParse(parsed); - if (!result.success) { - throw new Error('Canary redeployment status is invalid'); + const current = canaryRedeployResultSchema.safeParse(parsed); + let result: CanaryRedeployResult; + if (current.success) { + result = current.data; + } else { + const legacy = legacyRedeployResultSchema.safeParse(parsed); + if (!legacy.success) { + throw new Error('Canary redeployment status is invalid'); + } + result = { ...legacy.data, branch: DEFAULT_BRANCH }; } const runnerPid = parsed && @@ -142,7 +227,7 @@ async function readRedeployResult(): Promise { Number(parsed.runnerPid) > 0 ? Number(parsed.runnerPid) : null; - return { result: result.data, runnerPid }; + return { result, runnerPid }; } function processIsRunning(pid: number): boolean { @@ -154,6 +239,108 @@ function processIsRunning(pid: number): boolean { } } +async function redeployIsActive(): Promise { + const stored = await readRedeployResult(); + if ( + stored?.result.state === 'succeeded' || + stored?.result.state === 'failed' + ) { + redeployRequestInFlight = false; + return false; + } + const persistentRunnerActive = Boolean( + stored?.result.state === 'running' && + stored.runnerPid !== null && + processIsRunning(stored.runnerPid), + ); + return ( + persistentRunnerActive || + (redeployRequestInFlight && + (stored?.result.state === 'requested' || + stored?.result.state === 'running')) + ); +} + +async function beginOperation(operation: CanaryOperation): Promise { + if (activeOperation) { + throw new CanaryRedeployError( + 'operation_in_progress', + 'Another Canary operation is already in progress', + ); + } + activeOperation = operation; + try { + if (await redeployIsActive()) { + throw new CanaryRedeployError( + 'operation_in_progress', + 'Canary redeployment is already in progress', + ); + } + } catch (error) { + activeOperation = null; + throw error; + } +} + +function validateBranchFormat(branch: CanaryBranch): void { + if (branch.startsWith('-')) { + throw new CanaryRedeployError( + 'branch_invalid', + `Invalid Canary branch: ${branch}`, + ); + } + try { + execFileSync('git', ['check-ref-format', '--branch', branch], { + stdio: 'ignore', + timeout: 5_000, + }); + execFileSync('git', ['check-ref-format', `refs/heads/${branch}`], { + stdio: 'ignore', + timeout: 5_000, + }); + } catch { + throw new CanaryRedeployError( + 'branch_invalid', + `Invalid Canary branch: ${branch}`, + ); + } +} + +async function resolveRemoteBranch( + repoRoot: string, + branch: CanaryBranch, +): Promise { + validateBranchFormat(branch); + const fullRef = `refs/heads/${branch}`; + let stdout: string; + try { + ({ stdout } = await execFileAsync( + 'git', + ['ls-remote', '--exit-code', '--refs', 'origin', fullRef], + { + cwd: repoRoot, + encoding: 'utf8', + timeout: 10_000, + maxBuffer: 64 * 1024, + }, + )); + } catch { + throw new CanaryRedeployError( + 'branch_unavailable', + `Unable to resolve origin/${branch}`, + ); + } + const fields = stdout.trim().split(/\s+/); + const remoteSha = validSha(fields[0]); + if (!remoteSha || fields[1] !== fullRef || fields.length !== 2) { + throw new CanaryRedeployError( + 'branch_unavailable', + `Unable to resolve origin/${branch}`, + ); + } + return remoteSha; +} + export async function writeCanaryRedeployResult( result: CanaryRedeployResult, ): Promise { @@ -170,6 +357,7 @@ export async function writeCanaryRedeployResult( export async function getCanaryRedeployStatus(): Promise { const capability = resolveCanaryCapability(); + const config = readCanaryConfig(); const storedRedeploy = await readRedeployResult(); let redeploy = storedRedeploy?.result ?? null; if ( @@ -180,6 +368,7 @@ export async function getCanaryRedeployStatus(): Promise { + const capability = resolveCanaryCapability(); + if (!capability.available || !capability.repoRoot) { + throw new CanaryRedeployError( + 'redeploy_unavailable', + 'Canary redeployment is unavailable', + ); + } + await beginOperation('configure'); + try { + const branch = update.branch ?? DEFAULT_BRANCH; + const remoteSha = await resolveRemoteBranch(capability.repoRoot, branch); + atomicWriteJson(canaryConfigPath(), { + version: 1, + branch: update.branch, + }); + cachedRemote = { branch, remoteSha, checkedAt: Date.now() }; + return await getCanaryRedeployStatus(); + } finally { + activeOperation = null; + } +} + export async function checkCanaryRemote(): Promise { const capability = resolveCanaryCapability(); if (!capability.available || !capability.repoRoot) { return getCanaryRedeployStatus(); } - - const { stdout } = await execFileAsync( - 'git', - ['ls-remote', 'origin', 'refs/heads/alpha'], - { - cwd: capability.repoRoot, - encoding: 'utf8', - timeout: 10_000, - maxBuffer: 64 * 1024, - }, - ); - const remoteSha = validSha(stdout.trim().split(/\s+/)[0]); - if (!remoteSha) { - throw new Error('origin/alpha did not resolve to a commit'); + await beginOperation('check'); + try { + const { branch } = readCanaryConfig(); + const remoteSha = await resolveRemoteBranch(capability.repoRoot, branch); + cachedRemote = { branch, remoteSha, checkedAt: Date.now() }; + return await getCanaryRedeployStatus(); + } finally { + activeOperation = null; } - cachedRemote = { remoteSha, checkedAt: Date.now() }; - return getCanaryRedeployStatus(); } -export async function requestCanaryRedeploy(): Promise { +export async function requestCanaryRedeploy( + expectedBranch: CanaryBranch, +): Promise { const capability = resolveCanaryCapability(); if ( !capability.available || @@ -238,59 +447,76 @@ export async function requestCanaryRedeploy(): Promise { - redeployRequestInFlight = false; - log.error({ err: error }, 'Canary redeploy runner failed to start'); - void writeCanaryRedeployResult({ - state: 'failed', - startedAt, - completedAt: Date.now(), - exitCode: -1, - message: 'Unable to start redeploy runner', - }).catch((statusError: unknown) => { - log.error( - { err: statusError }, - 'Unable to persist Canary runner start failure', + await beginOperation('redeploy'); + let branch: CanaryBranch | null = null; + let startedAt: number | null = null; + try { + branch = readCanaryConfig().branch; + if (expectedBranch !== branch) { + throw new CanaryRedeployError( + 'stale_branch', + `Canary branch changed from ${expectedBranch} to ${branch}; review and confirm again`, ); + } + const remoteSha = await resolveRemoteBranch(capability.repoRoot, branch); + cachedRemote = { branch, remoteSha, checkedAt: Date.now() }; + await access(capability.scriptPath, constants.X_OK); + startedAt = Date.now(); + await writeCanaryRedeployResult({ + state: 'requested', + branch, + startedAt, }); - }); - child.unref(); - redeployRequestInFlight = true; - return getCanaryRedeployStatus(); + + const child = spawn( + process.execPath, + [ + capability.runnerPath, + capability.scriptPath, + canaryStatusPath(), + canaryLogPath(), + String(startedAt), + branch, + ], + { + cwd: capability.repoRoot, + detached: true, + stdio: 'ignore', + env: process.env, + }, + ); + child.once('error', (error) => { + redeployRequestInFlight = false; + log.error({ err: error }, 'Canary redeploy runner failed to start'); + void writeCanaryRedeployResult({ + state: 'failed', + branch: branch ?? DEFAULT_BRANCH, + startedAt: startedAt ?? Date.now(), + completedAt: Date.now(), + exitCode: -1, + message: 'Unable to start redeploy runner', + }).catch((statusError: unknown) => { + log.error( + { err: statusError }, + 'Unable to persist Canary runner start failure', + ); + }); + }); + child.unref(); + redeployRequestInFlight = true; + return await getCanaryRedeployStatus(); + } finally { + activeOperation = null; + } } export function resetCanaryRedeployStateForTest(): void { cachedRemote = null; + activeOperation = null; redeployRequestInFlight = false; } diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index 2cabc6501..52d205e4b 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -17,6 +17,7 @@ export const routes = { // ── Deployment ──────────────────────────────────────────────────── deploymentReadiness: '/deployment/readiness', canaryRedeployStatus: '/deployment/canary', + canaryRedeployConfig: '/deployment/canary/config', canaryRedeployCheck: '/deployment/canary/check', canaryRedeploy: '/deployment/canary/redeploy', agentDefaults: '/agent/defaults', diff --git a/apps/web/src/api/deployment.ts b/apps/web/src/api/deployment.ts index d279386d1..e3c02f2da 100644 --- a/apps/web/src/api/deployment.ts +++ b/apps/web/src/api/deployment.ts @@ -5,6 +5,7 @@ import { apiFetch } from './_client'; import { routes } from './_routes'; import type { + CanaryRedeployConfigUpdate, CanaryRedeployStatusResponse, DeploymentReadinessResponse, } from '@huabu/shared'; @@ -25,14 +26,26 @@ export function checkCanaryRedeploy(): Promise { return apiFetch(routes.canaryRedeployCheck, { method: 'POST', json: {}, - fallbackMessage: 'Failed to check origin/alpha', + fallbackMessage: 'Failed to check the Canary branch', }); } -export function requestCanaryRedeploy(): Promise { +export function updateCanaryRedeployConfig( + update: CanaryRedeployConfigUpdate, +): Promise { + return apiFetch(routes.canaryRedeployConfig, { + method: 'PUT', + json: update, + fallbackMessage: 'Failed to save the Canary branch', + }); +} + +export function requestCanaryRedeploy( + expectedBranch: string, +): Promise { return apiFetch(routes.canaryRedeploy, { method: 'POST', - json: {}, + json: { expectedBranch }, fallbackMessage: 'Failed to start Canary redeployment', }); } diff --git a/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx b/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx index 5606e68fd..484a20d72 100644 --- a/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx +++ b/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx @@ -52,7 +52,7 @@ export function MoveSelectionPanel({ const nameRef = useRef(null); const [selectedDestination, setSelectedDestination] = useState(''); const [newSpaceTitle, setNewSpaceTitle] = useState(''); - const [createSourcePreview, setCreateSourcePreview] = useState(true); + const [createSourcePreview, setCreateSourcePreview] = useState(false); const creatingNewSpace = selectedDestination === NEW_SPACE_DESTINATION; const destinationCanvasId = options.some( (option) => option.value === selectedDestination, diff --git a/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx b/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx index 688f7317c..4d9801cd4 100644 --- a/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx +++ b/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx @@ -287,8 +287,9 @@ describe('MoveSelectionPopover', () => { const trigger = await openPanel(); const checkbox = document.querySelector('[type="checkbox"]'); - act(() => checkbox?.click()); expect(checkbox?.checked).toBe(false); + act(() => checkbox?.click()); + expect(checkbox?.checked).toBe(true); act(() => document.body.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true }), @@ -300,7 +301,7 @@ describe('MoveSelectionPopover', () => { ); expect( document.querySelector('[type="checkbox"]')?.checked, - ).toBe(true); + ).toBe(false); }); it('locks submission and keeps the captured selection while the move is pending', async () => { @@ -331,7 +332,7 @@ describe('MoveSelectionPopover', () => { 'source', expect.objectContaining({ selectedNodeIds: ['node-selected'], - createSourcePreview: true, + createSourcePreview: false, }), ); await act(async () => finish?.(moveResult)); @@ -423,7 +424,7 @@ describe('MoveSelectionPopover', () => { ); expect(document.querySelector('[role="dialog"]')).not.toBeNull(); expect(document.querySelector('[type="checkbox"]')).toBe(checkbox); - expect(checkbox?.checked).toBe(false); + expect(checkbox?.checked).toBe(true); }); it.each(Object.entries(knownErrors))( @@ -661,7 +662,7 @@ describe('MoveSelectionPopover', () => { expect(moveCanvasSelection).toHaveBeenCalledWith('source', { selectedNodeIds: ['node-selected'], destination: { kind: 'new', title: 'New destination' }, - createSourcePreview: true, + createSourcePreview: false, expectedSourceVersion: 3, }); expect(useWorkspaceStore.getState().spaceTitles).toEqual( @@ -708,6 +709,11 @@ describe('MoveSelectionPopover', () => { document.querySelectorAll('[role="option"]'), ).find((button) => button.textContent === 'Destination'); act(() => destination?.click()); + act(() => + document + .querySelector('input[type="checkbox"]') + ?.click(), + ); const move = Array.from(document.querySelectorAll('button')).find( (button) => button.textContent === 'moveSelection.confirm', ); @@ -721,7 +727,7 @@ describe('MoveSelectionPopover', () => { }); }); - it('defaults the source Preview checkbox on and allows disabling it', async () => { + it('defaults the source Preview checkbox off and allows enabling it', async () => { listCanvases.mockResolvedValue({ canvases: [{ canvasId: 'destination', title: 'Destination' }], }); @@ -747,8 +753,8 @@ describe('MoveSelectionPopover', () => { const checkbox = document.querySelector( 'input[type="checkbox"]', ); - expect(checkbox?.checked).toBe(true); - act(() => checkbox?.click()); expect(checkbox?.checked).toBe(false); + act(() => checkbox?.click()); + expect(checkbox?.checked).toBe(true); }); }); diff --git a/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx b/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx index 2e4346328..8b852f61a 100644 --- a/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx +++ b/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx @@ -15,6 +15,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; const mocks = vi.hoisted(() => ({ getStatus: vi.fn(), check: vi.fn(), + save: vi.fn(), redeploy: vi.fn(), toast: vi.fn(), t: (key: string) => key, @@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/api/deployment', () => ({ getCanaryRedeployStatus: mocks.getStatus, checkCanaryRedeploy: mocks.check, + updateCanaryRedeployConfig: mocks.save, requestCanaryRedeploy: mocks.redeploy, })); vi.mock('@/components/Common/Toast', () => ({ toast: mocks.toast })); @@ -40,6 +42,7 @@ const availableStatus: CanaryRedeployStatusResponse = { available: true, reason: 'available', branch: 'alpha', + configuredBranch: null, runningSha: 'a'.repeat(40), remoteSha: 'b'.repeat(40), updateAvailable: true, @@ -56,9 +59,14 @@ beforeEach(() => { root = createRoot(container); mocks.getStatus.mockResolvedValue(availableStatus); mocks.check.mockResolvedValue(availableStatus); + mocks.save.mockResolvedValue({ + ...availableStatus, + branch: 'x/alpha', + configuredBranch: 'x/alpha', + }); mocks.redeploy.mockResolvedValue({ ...availableStatus, - redeploy: { state: 'requested', startedAt: 2 }, + redeploy: { state: 'requested', branch: 'alpha', startedAt: 2 }, }); }); @@ -103,9 +111,36 @@ describe('CanaryRedeploySettings', () => { }); expect(mocks.redeploy).toHaveBeenCalledOnce(); + expect(mocks.redeploy).toHaveBeenCalledWith('alpha'); expect(mocks.toast).toHaveBeenCalledWith('settings.canaryRedeployStarted', { tone: 'info', duration: 10_000, }); }); + + it('persists a configured branch and uses an empty value for the alpha default', async () => { + await renderSettings(); + const input = container.querySelector('input'); + expect(input?.placeholder).toBe('alpha'); + expect(input?.value).toBe(''); + + act(() => { + const setValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + setValue?.call(input, 'x/alpha'); + input?.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => button('settings.canaryBranchSave').click()); + + expect(mocks.save).toHaveBeenCalledWith({ branch: 'x/alpha' }); + expect(input?.value).toBe('x/alpha'); + + act(() => button('settings.canaryRedeployAction').click()); + await act(async () => { + button('settings.canaryConfirmAction').click(); + }); + expect(mocks.redeploy).toHaveBeenCalledWith('x/alpha'); + }); }); diff --git a/apps/web/src/components/Settings/CanaryRedeploySettings.tsx b/apps/web/src/components/Settings/CanaryRedeploySettings.tsx index 4474d893a..22c37f33f 100644 --- a/apps/web/src/components/Settings/CanaryRedeploySettings.tsx +++ b/apps/web/src/components/Settings/CanaryRedeploySettings.tsx @@ -1,16 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { checkCanaryRedeploy, getCanaryRedeployStatus, requestCanaryRedeploy, + updateCanaryRedeployConfig, } from '@/api/deployment'; import { Button } from '@/components/Common/Button'; import { Modal } from '@/components/Common/Modal'; +import { TextInput } from '@/components/Common/TextInput'; import { toast } from '@/components/Common/Toast'; import { SettingRow } from '@/components/Settings/Common/SettingRow'; @@ -22,11 +24,14 @@ function shortSha(sha: string | null): string { export function CanaryRedeploySettings() { const { t } = useTranslation(); + const branchInputId = useId(); const [status, setStatus] = useState( null, ); + const [branchDraft, setBranchDraft] = useState(''); const [loading, setLoading] = useState(true); const [checking, setChecking] = useState(false); + const [saving, setSaving] = useState(false); const [requesting, setRequesting] = useState(false); const [confirming, setConfirming] = useState(false); const confirmRef = useRef(null); @@ -40,8 +45,8 @@ export function CanaryRedeploySettings() { if (showToast) { toast( next.updateAvailable - ? t('settings.canaryUpdateAvailable') - : t('settings.canaryUpToDate'), + ? t('settings.canaryUpdateAvailable', { branch: next.branch }) + : t('settings.canaryUpToDate', { branch: next.branch }), { tone: next.updateAvailable ? 'info' : 'success' }, ); } @@ -65,6 +70,7 @@ export function CanaryRedeploySettings() { .then((initial) => { if (!active) return; setStatus(initial); + setBranchDraft(initial.configuredBranch ?? ''); if (initial.available) void check(false); }) .catch((error: unknown) => { @@ -85,13 +91,37 @@ export function CanaryRedeploySettings() { }; }, [check, t]); + const saveBranch = useCallback(async () => { + setSaving(true); + try { + const next = await updateCanaryRedeployConfig({ + branch: branchDraft.trim() || null, + }); + setStatus(next); + setBranchDraft(next.configuredBranch ?? ''); + toast(t('settings.canaryBranchSaved', { branch: next.branch }), { + tone: 'success', + }); + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.canaryBranchSaveFailed'), + { tone: 'danger' }, + ); + } finally { + setSaving(false); + } + }, [branchDraft, t]); + const redeploy = useCallback(async () => { + if (!status) return; setRequesting(true); try { - const next = await requestCanaryRedeploy(); + const next = await requestCanaryRedeploy(status.branch); setStatus(next); setConfirming(false); - toast(t('settings.canaryRedeployStarted'), { + toast(t('settings.canaryRedeployStarted', { branch: next.branch }), { tone: 'info', duration: 10_000, }); @@ -105,7 +135,7 @@ export function CanaryRedeploySettings() { } finally { setRequesting(false); } - }, [t]); + }, [status, t]); if (loading || !status?.available) return null; @@ -115,9 +145,12 @@ export function CanaryRedeploySettings() { const redeployInProgress = status.redeploy?.state === 'requested' || status.redeploy?.state === 'running'; + const busy = checking || saving || requesting || redeployInProgress; const description = t('settings.canaryDescription', { + branch: status.branch, running: shortSha(status.runningSha), remote: shortSha(status.remoteSha), + redeployBranch: status.redeploy?.branch ?? status.branch, outcome, }); @@ -133,7 +166,7 @@ export function CanaryRedeploySettings() { tone="neutral" size="sm" onClick={() => void check(true)} - disabled={checking || requesting} + disabled={busy} > {checking ? t('settings.canaryChecking') @@ -144,9 +177,41 @@ export function CanaryRedeploySettings() { tone="warning" size="sm" onClick={() => setConfirming(true)} - disabled={checking || requesting || redeployInProgress} + disabled={busy} + > + {t('settings.canaryRedeployAction', { branch: status.branch })} + + + + +
+ setBranchDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !busy) void saveBranch(); + }} + /> +
@@ -155,8 +220,10 @@ export function CanaryRedeploySettings() { onClose={() => { if (!requesting) setConfirming(false); }} - title={t('settings.canaryConfirmTitle')} - description={t('settings.canaryConfirmDescription')} + title={t('settings.canaryConfirmTitle', { branch: status.branch })} + description={t('settings.canaryConfirmDescription', { + branch: status.branch, + })} initialFocusRef={confirmRef} closeOnBackdropClick={!requesting} closeOnEscape={!requesting} @@ -181,7 +248,9 @@ export function CanaryRedeploySettings() { > {requesting ? t('settings.canaryStarting') - : t('settings.canaryConfirmAction')} + : t('settings.canaryConfirmAction', { + branch: status.branch, + })} } diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 074a8ffad..1c5422cb7 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -376,19 +376,25 @@ "credentialStoreReadOnly": "Credential storage is read-only. Set HUABU_SECRET_KEY and restart Huabu to save credentials or use OAuth.", "remoteTransportUnverified": "This remote connection uses operator-managed transport. Use HTTPS or a trusted private network.", "canaryRedeploy": "Alpha Canary redeployment", - "canaryDescription": "Running {{running}} · origin/alpha {{remote}} · Last result: {{outcome}}", + "canaryDescription": "Running {{running}} · origin/{{branch}} {{remote}} · Last result for {{redeployBranch}}: {{outcome}}", "canaryCheck": "Check for update", "canaryChecking": "Checking…", - "canaryUpdateAvailable": "A newer Alpha revision is available", - "canaryUpToDate": "This Canary matches origin/alpha", - "canaryCheckFailed": "Unable to check origin/alpha", + "canaryUpdateAvailable": "A newer {{branch}} revision is available", + "canaryUpToDate": "This Canary matches origin/{{branch}}", + "canaryCheckFailed": "Unable to check the Canary branch", "canaryStatusFailed": "Unable to load Canary redeployment status", - "canaryRedeployAction": "Redeploy Alpha", - "canaryConfirmTitle": "Redeploy the Alpha Canary?", - "canaryConfirmDescription": "Huabu will run the repository redeployment script. This page may disconnect, and a failed deployment may require SSH repair.", - "canaryConfirmAction": "Redeploy and restart", + "canaryBranch": "Canary branch", + "canaryBranchDescription": "Leave empty to use alpha. The branch must exist on origin.", + "canaryBranchSave": "Save", + "canaryBranchSaving": "Saving…", + "canaryBranchSaved": "Canary branch set to {{branch}}", + "canaryBranchSaveFailed": "Unable to save the Canary branch", + "canaryRedeployAction": "Redeploy {{branch}}", + "canaryConfirmTitle": "Redeploy {{branch}} to the Alpha Canary?", + "canaryConfirmDescription": "Huabu will run the repository redeployment script for origin/{{branch}}. This page may disconnect, and a failed deployment may require SSH repair.", + "canaryConfirmAction": "Redeploy {{branch}} and restart", "canaryStarting": "Starting…", - "canaryRedeployStarted": "Redeployment started. Huabu may disconnect while it restarts.", + "canaryRedeployStarted": "Redeployment of {{branch}} started. Huabu may disconnect while it restarts.", "canaryRedeployFailed": "Unable to start Canary redeployment", "canaryNeverRedeployed": "not run", "canaryState_requested": "requested", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index be029bcc0..2471c988a 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -376,19 +376,25 @@ "credentialStoreReadOnly": "凭据存储为只读。请设置 HUABU_SECRET_KEY 并重启 Huabu,以保存凭据或使用 OAuth。", "remoteTransportUnverified": "此远程连接使用运维方管理的传输。请使用 HTTPS 或可信私有网络。", "canaryRedeploy": "Alpha Canary 重新部署", - "canaryDescription": "运行版本 {{running}} · origin/alpha {{remote}} · 最近结果:{{outcome}}", + "canaryDescription": "运行版本 {{running}} · origin/{{branch}} {{remote}} · {{redeployBranch}} 最近结果:{{outcome}}", "canaryCheck": "检查更新", "canaryChecking": "检查中…", - "canaryUpdateAvailable": "存在更新的 Alpha 版本", - "canaryUpToDate": "当前 Canary 与 origin/alpha 一致", - "canaryCheckFailed": "无法检查 origin/alpha", + "canaryUpdateAvailable": "存在更新的 {{branch}} 版本", + "canaryUpToDate": "当前 Canary 与 origin/{{branch}} 一致", + "canaryCheckFailed": "无法检查 Canary 分支", "canaryStatusFailed": "无法加载 Canary 重新部署状态", - "canaryRedeployAction": "重新部署 Alpha", - "canaryConfirmTitle": "重新部署 Alpha Canary?", - "canaryConfirmDescription": "Huabu 将运行仓库内的重新部署脚本。页面可能断开连接,部署失败后可能需要通过 SSH 手工修复。", - "canaryConfirmAction": "重新部署并重启", + "canaryBranch": "Canary 分支", + "canaryBranchDescription": "留空时使用 alpha。该分支必须存在于 origin。", + "canaryBranchSave": "保存", + "canaryBranchSaving": "保存中…", + "canaryBranchSaved": "Canary 分支已设为 {{branch}}", + "canaryBranchSaveFailed": "无法保存 Canary 分支", + "canaryRedeployAction": "重新部署 {{branch}}", + "canaryConfirmTitle": "将 {{branch}} 重新部署到 Alpha Canary?", + "canaryConfirmDescription": "Huabu 将针对 origin/{{branch}} 运行仓库内的重新部署脚本。页面可能断开连接,部署失败后可能需要通过 SSH 手工修复。", + "canaryConfirmAction": "重新部署 {{branch}} 并重启", "canaryStarting": "正在启动…", - "canaryRedeployStarted": "已启动重新部署,Huabu 重启期间可能断开连接。", + "canaryRedeployStarted": "已开始重新部署 {{branch}},Huabu 重启期间可能断开连接。", "canaryRedeployFailed": "无法启动 Canary 重新部署", "canaryNeverRedeployed": "尚未运行", "canaryState_requested": "已请求", diff --git a/docs/architecture/api-design.md b/docs/architecture/api-design.md index 515f2fd38..b24f12ceb 100644 --- a/docs/architecture/api-design.md +++ b/docs/architecture/api-design.md @@ -126,6 +126,10 @@ Question `conversationTitleSource` is also server-owned and excluded from ordina `POST /api/canvas/:canvasId/move-selection` validates its params and body with the shared Move schemas. Errors expose only a bounded `MOVE_*` code and fixed English message. `MOVE_AGENT_CLOSE_FAILED`, `MOVE_AGENT_REHOME_FAILED`, and unexpected `MOVE_FAILED` return HTTP 500; eligibility/history and destination conflicts retain their existing 4xx behavior. Agenetes `rehome_unknown_outcome` and failed compensation become HTTP 500 `MOVE_OUTCOME_UNKNOWN` before cleanup decisions. No raw cause, namespace, thread identity, artifact name, or arbitrary exception message is serialized. Server diagnostics retain the operation phase, allowlisted upstream error code, failure category, and compensation/cleanup outcome; original causes remain internal and are not dumped into logs. Web Move presentation localizes the code and uses a safe generic fallback rather than displaying unknown error messages. +## Canary branch configuration + +`GET /api/deployment/canary` returns the effective bounded `branch`, nullable `configuredBranch`, branch-bound remote check state, and a redeployment result carrying its own captured branch. `PUT /api/deployment/canary/config` accepts `{ branch: string | null }`, where null clears the override and resolves to `alpha`; the Server validates the full Git ref and exact fixed-`origin` availability before persisting. `POST /api/deployment/canary/check` retains a strict empty body. `POST /api/deployment/canary/redeploy` accepts `{ expectedBranch }` only as a freshness guard and rejects a mismatch rather than treating the request as a target selector. All bodies use the canonical schemas in `deployment.ts`, all routes remain owner-only, and operation conflicts are explicit HTTP 409 responses. + ## Conversation titles [`conversation-title.ts`](../../packages/shared/src/types/api/conversation-title.ts) defines the shared schemas and inferred types for `ConversationTitle { title, source }`, batch queries, and manual renames. `POST /api/agent/threads/titles/query` validates `{ canvasId, threadIds }` (at most 100 thread IDs; an empty batch is valid) and returns `{ titles }` keyed by thread ID. `PUT /api/agent/threads/:threadId/title?canvasId=...` validates params, query, and a trimmed non-empty `{ title }` of at most 120 characters, returning the effective title or `404 thread_not_found` when no writable Question or durable thread exists. diff --git a/docs/architecture/canary-deployment.md b/docs/architecture/canary-deployment.md index 1f15fe613..bb1654fcd 100644 --- a/docs/architecture/canary-deployment.md +++ b/docs/architecture/canary-deployment.md @@ -1,10 +1,10 @@ # Alpha Canary Deployment -> Personal-development deployment workflow for the long-lived `alpha` branch. This is not the stable release or promotion path. +> Personal-development deployment workflow whose source branch defaults to `alpha`. This is not the stable release or promotion path. ## Branch and authorization model -`main` is the stable branch. `alpha` is the rolling integration branch used by the personal Canary. Issue branches start from `origin/alpha` and target `alpha`; promotion from `alpha` to `main` remains a separate reviewed action. +`main` is the stable branch. `alpha` is the rolling integration branch and the compatibility-default source for the personal Canary. An owner may configure another branch, such as `x/alpha`, for one development deployment without changing the repository's issue-branch or promotion policy. Issue branches still start from `origin/alpha` and target `alpha`; promotion from `alpha` to `main` remains a separate reviewed action. Canary use is additional end-to-end evidence only. It does not replace pull-request CI, review, documentation, release validation, or authorization to promote or publish. @@ -12,40 +12,44 @@ Canary use is additional end-to-end evidence only. It does not replace pull-requ The supported helper runs from a source checkout through `pnpm start:web`. `scripts/start-web.mjs` captures the startup commit in `HUABU_DEPLOYED_SHA` and exports the resolved checkout root as `HUABU_REPO_ROOT` before loading the bundled Server. -Setting `HUABU_CANARY_REDEPLOY_ENABLED=1` enables an owner-only Settings surface. Opening Settings compares the captured startup commit with the current `origin/alpha` SHA using the fixed command `git ls-remote origin refs/heads/alpha`. The result identifies a different branch head; it does not independently attest CI status. +Setting `HUABU_CANARY_REDEPLOY_ENABLED=1` enables an owner-only Settings surface. The selected branch is persisted as an application-global versioned record under `HUABU_DATA_DIR`; a missing record or explicit cleared value resolves to `alpha`. Malformed stored configuration fails explicitly rather than silently using the default. -The owner may confirm `Redeploy Alpha`. The HTTP request carries an empty body and cannot select a command, path, branch, SHA, or arguments. The Server launches a detached runner with the fixed executable and arguments: +Opening Settings compares the captured startup commit with the exact configured remote ref using shell-free `git ls-remote --exit-code --refs origin refs/heads/`. Branch syntax is checked as a bounded full Git branch ref and option-like leading `-` values are rejected. A configured ref that is invalid or unavailable fails explicitly and never falls back to `alpha`. The result identifies a different branch head; it does not independently attest CI status. + +The owner confirms the effective branch displayed by Settings. The request carries that branch only as a freshness guard; the Server rejects it if it no longer matches persisted configuration, so the request cannot independently select a deployment target. The Server launches a detached runner with the fixed executable and bounded arguments: ```text -/scripts/start-huabu.sh alpha --non-interactive +/scripts/start-huabu.sh --non-interactive ``` -The runner persists `requested`, `running`, `succeeded`, or `failed` state under `HUABU_DATA_DIR`, appends a local log, and survives the current Server process exiting. It waits briefly before invoking the script so the Server can flush HTTP 202; that response means only that the runner started. Success means the script exited zero after its bounded readiness probe. +The runner persists the captured branch with `requested`, `running`, `succeeded`, or `failed` state under `HUABU_DATA_DIR`, appends a local log, and survives the current Server process exiting. It waits briefly before invoking the script so the Server can flush HTTP 202; that response means only that the runner started. Success means the script exited zero after its bounded readiness probe. + +Only one check, configuration write, or redeployment admission may run at a time. A configuration write is rejected while a check or persisted runner is active, concurrent operations receive an explicit conflict, and an admitted redeployment cannot be retargeted. Persisted result status retains its captured branch so a later configuration can never make an older outcome appear to belong to another branch. ## Script behavior -`scripts/start-huabu.sh` derives the repository root from its own tracked path, so the checkout may live anywhere. Direct operator use accepts a branch argument; the UI invocation is always fixed to `alpha`. +`scripts/start-huabu.sh` derives the repository root from its own tracked path, so the checkout may live anywhere. Direct operator and Settings use both accept exactly one validated branch argument. -The script requires a clean checkout, stops listeners on ports 3001–3005, removes the previous `app` tmux session, checks out and fast-forwards the selected branch, installs locked dependencies, and starts `pnpm start:web` in a new `app` session. Interactive use immediately tails `/tmp/huabu-app.log` while startup continues. `--non-interactive` instead waits for readiness and exits when it succeeds or when the configurable `HUABU_CANARY_READINESS_TIMEOUT_SECONDS` window expires; the default is 300 seconds. +The script requires a clean checkout, stops listeners on ports 3001–3005, removes the previous `app` tmux session, fetches the exact `refs/heads/` from fixed remote `origin` into its matching remote-tracking ref, checks out or creates the matching local branch, and fast-forwards it without rewriting divergent work. It then installs locked dependencies and starts `pnpm start:web` in a new `app` session. Interactive use immediately tails `/tmp/huabu-app.log` while startup continues. `--non-interactive` instead waits for readiness and exits when it succeeds or when the configurable `HUABU_CANARY_READINESS_TIMEOUT_SECONDS` window expires; the default is 300 seconds. The script intentionally preserves the existing personal-development tradeoff: it updates one checkout in place and stops the old service before pull, install, and build complete. A failed redeployment can leave the Canary offline, and the port-range stop can affect another process using those ports. There is no rollback, immutable release directory, service preservation, self-restart supervisor, systemd unit, container deployment, or automatic installation. Inspect the persisted runner status and log, then repair manually through SSH when needed. ## Security boundary -Status, check, and redeploy routes require the existing single-owner boundary: loopback access or successful HTTP Basic Auth. Possession of the RFS connection token does not authorize redeployment. +Status, branch configuration, check, and redeploy routes require the existing single-owner boundary: loopback access or successful HTTP Basic Auth. Possession of the RFS connection token does not authorize configuration or redeployment. -The feature is disabled by default and unavailable in packaged Desktop mode. The Server resolves one repository-owned script and supplies one fixed argument array without a shell. Browser input never reaches process spawning. Status responses are bounded and exclude environment values, credentials, repository paths, and raw command output. +The feature is disabled by default and unavailable in packaged Desktop mode. The Server resolves one repository-owned script and supplies one fixed argument array without a shell. The validated effective branch occupies one fixed argument slot; browser input cannot alter the executable, remote, repository path, option set, or argument count. Status responses are bounded and exclude environment values, credentials, repository paths, and raw command output. Remote browser access continues to require the bind, allowed-host, Basic Auth, and operator-managed HTTPS or trusted-private-network controls in [deployment security](./deployment-security.md). ## Code entry points -| File | Responsibility | -| ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| [`scripts/start-huabu.sh`](../../scripts/start-huabu.sh) | Path-independent tmux redeployment and readiness probe. | -| [`scripts/canary-redeploy-runner.mjs`](../../scripts/canary-redeploy-runner.mjs) | Detached execution, persistent result state, and local logging. | -| [`scripts/start-web.mjs`](../../scripts/start-web.mjs) | Captures repository root and deployed SHA for the standalone Server. | -| [`packages/shared/src/types/api/deployment.ts`](../../packages/shared/src/types/api/deployment.ts) | Canary status and action wire contracts. | -| [`apps/server/src/modules/security/canary-redeploy.ts`](../../apps/server/src/modules/security/canary-redeploy.ts) | Capability resolution, remote SHA check, status persistence, and fixed runner launch. | -| [`apps/server/src/modules/security/canary-redeploy.route.ts`](../../apps/server/src/modules/security/canary-redeploy.route.ts) | Owner-only status, check, and redeploy endpoints. | -| [`apps/web/src/components/Settings/CanaryRedeploySettings.tsx`](../../apps/web/src/components/Settings/CanaryRedeploySettings.tsx) | Settings status, check action, and confirmed redeploy action. | +| File | Responsibility | +| ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| [`scripts/start-huabu.sh`](../../scripts/start-huabu.sh) | Path-independent tmux redeployment and readiness probe. | +| [`scripts/canary-redeploy-runner.mjs`](../../scripts/canary-redeploy-runner.mjs) | Detached execution, persistent result state, and local logging. | +| [`scripts/start-web.mjs`](../../scripts/start-web.mjs) | Captures repository root and deployed SHA for the standalone Server. | +| [`packages/shared/src/types/api/deployment.ts`](../../packages/shared/src/types/api/deployment.ts) | Canary branch, status, configuration, and action wire contracts. | +| [`apps/server/src/modules/security/canary-redeploy.ts`](../../apps/server/src/modules/security/canary-redeploy.ts) | Configuration persistence, exact remote checks, concurrency, status, and runner launch. | +| [`apps/server/src/modules/security/canary-redeploy.route.ts`](../../apps/server/src/modules/security/canary-redeploy.route.ts) | Owner-only status, branch configuration, check, and redeploy endpoints. | +| [`apps/web/src/components/Settings/CanaryRedeploySettings.tsx`](../../apps/web/src/components/Settings/CanaryRedeploySettings.tsx) | Settings branch editor, status, check action, and branch-bound confirmed redeploy action. | diff --git a/docs/architecture/deployment-security.md b/docs/architecture/deployment-security.md index 593a2fb84..47d955b04 100644 --- a/docs/architecture/deployment-security.md +++ b/docs/architecture/deployment-security.md @@ -19,6 +19,8 @@ The global Agent Change Review configuration follows the same owner boundary. `G The Utility Agent (external Profile or explicit Built-In Pi) and external functional-model preference follow the owner boundary through `GET` and `PUT /api/agent/defaults`. The recent conversational Agent is browser-local UI state, not a Server credential or authorization setting. Neither preference grants new tool permissions or changes native harness approval policy. +The optional personal Canary helper follows the same owner boundary for status, branch configuration, remote checks, and redeployment. Its persisted branch is a bounded Git ref name, not an executable input surface: the Server checks only the exact `refs/heads/` on fixed remote `origin`, launches one repository-owned runner without a shell, and supplies only the validated branch in a fixed argument position. Invalid, unavailable, stale, and concurrently changed values fail explicitly without falling back to `alpha`; see [Alpha Canary deployment](./canary-deployment.md). + Optional submission-time Ink OCR is an explicit outbound data boundary. Configuring an Azure AI Vision endpoint and key through Settings > General or `VISION_ENDPOINT` / `VISION_KEY` opts the Server into sending a transient raster containing only the selected Ink strokes to that resource when the owner submits an Ink Query. Settings sends newly entered keys to the owner-authorized Server for secure storage; reads never return a plaintext key, and the browser never calls Azure directly. Both reads and writes at `/api/integrations/ink-ocr/config` require owner authorization. Only Azure AI Vision's Image Analysis Read protocol is supported. Successful OCR evidence persists both in the structured envelope at `AgentSubmission.content.focus.selection.inkRecognition` and in the canonical inputs at `AgentSubmission.rendered`. Normal provider diagnostics record only outcome, duration, HTTP status, raster dimensions, node count, and line count; they exclude credentials, endpoint values, image bytes, and recognized text. Prompt debugging is a separate local retention surface: when enabled, it writes the assembled prompt, including OCR evidence subject to the diagnostic's text truncation. When `HUABU_DEBUG_PROMPT` is unset, it defaults to enabled outside production and disabled in production; an explicit value overrides that default. Set `HUABU_DEBUG_PROMPT=off` to disable these additional prompt logs. This does not disable normal conversation persistence or remove previously written data. Operators are responsible for the persisted conversation, local debug artifacts, and the configured Azure resource's data-processing and retention policy. diff --git a/docs/architecture/space-preview.md b/docs/architecture/space-preview.md index ff03fb6e3..9359c812d 100644 --- a/docs/architecture/space-preview.md +++ b/docs/architecture/space-preview.md @@ -59,7 +59,7 @@ Gesture-driven zoom-through remains deferred; viewport zoom, responsive layout, Ordinary Spaces expose Add Space Shortcut from the Canvas toolbar's Add Content dropdown. World omits this action because its shortcut membership is server-managed. -Moving content between Spaces can also create an ordinary source-owned `spacePreview` breadcrumb when the default-enabled Move option remains selected. It occupies the moved set's former absolute top-left and uses compact automatic sizing, independent of the moved content's footprint. It is created in the same source executor batch that deletes the moved roots, and a later move to the same target creates another breadcrumb rather than reusing one at a different historical location. Disabling the option leaves no breadcrumb and does not change boundary-edge removal or compensation. +Moving content between Spaces can also create an ordinary source-owned `spacePreview` breadcrumb when the user explicitly enables the default-off Move option. The choice resets to off each time the Move panel opens. When enabled, the shortcut occupies the moved set's former absolute top-left and uses compact automatic sizing, independent of the moved content's footprint. It is created in the same source executor batch that deletes the moved roots, and a later move to the same target creates another breadcrumb rather than reusing one at a different historical location. Leaving the option disabled creates no breadcrumb and does not change boundary-edge removal or compensation. ## Development design comparison diff --git a/packages/shared/src/types/api/deployment.test.ts b/packages/shared/src/types/api/deployment.test.ts new file mode 100644 index 000000000..a9bac9e35 --- /dev/null +++ b/packages/shared/src/types/api/deployment.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { + canaryRedeployConfigUpdateSchema, + canaryRedeployRequestSchema, + canaryRedeployStatusResponseSchema, +} from './deployment.js'; + +describe('Canary deployment contracts', () => { + it('accepts a nested branch and an explicit default reset', () => { + expect( + canaryRedeployConfigUpdateSchema.parse({ branch: 'x/alpha' }), + ).toEqual({ branch: 'x/alpha' }); + expect(canaryRedeployConfigUpdateSchema.parse({ branch: null })).toEqual({ + branch: null, + }); + }); + + it('requires a bounded expected branch for redeployment', () => { + expect( + canaryRedeployRequestSchema.parse({ expectedBranch: 'x/alpha' }), + ).toEqual({ expectedBranch: 'x/alpha' }); + expect(canaryRedeployRequestSchema.safeParse({}).success).toBe(false); + expect( + canaryRedeployRequestSchema.safeParse({ + expectedBranch: 'x'.repeat(256), + }).success, + ).toBe(false); + }); + + it('keeps current and historical branch identities in status', () => { + expect( + canaryRedeployStatusResponseSchema.parse({ + available: true, + reason: 'available', + branch: 'x/alpha', + configuredBranch: 'x/alpha', + runningSha: null, + remoteSha: null, + updateAvailable: null, + checkedAt: null, + redeploy: { + state: 'succeeded', + branch: 'alpha', + startedAt: 1, + completedAt: 2, + exitCode: 0, + }, + }), + ).toMatchObject({ + branch: 'x/alpha', + redeploy: { branch: 'alpha' }, + }); + }); +}); diff --git a/packages/shared/src/types/api/deployment.ts b/packages/shared/src/types/api/deployment.ts index 7be0e18ac..4bf9194c6 100644 --- a/packages/shared/src/types/api/deployment.ts +++ b/packages/shared/src/types/api/deployment.ts @@ -45,8 +45,12 @@ export const canaryRedeployStateSchema = z.enum([ ]); export type CanaryRedeployState = z.infer; +export const canaryBranchSchema = z.string().trim().min(1).max(255); +export type CanaryBranch = z.infer; + export const canaryRedeployResultSchema = z.object({ state: canaryRedeployStateSchema, + branch: canaryBranchSchema, startedAt: z.number().int().nonnegative(), completedAt: z.number().int().nonnegative().optional(), exitCode: z.number().int().optional(), @@ -62,7 +66,8 @@ export const canaryRedeployStatusResponseSchema = z.object({ 'repository-unavailable', 'script-unavailable', ]), - branch: z.literal('alpha'), + branch: canaryBranchSchema, + configuredBranch: canaryBranchSchema.nullable(), runningSha: z .string() .regex(/^[0-9a-f]{40}$/) @@ -79,5 +84,21 @@ export type CanaryRedeployStatusResponse = z.infer< typeof canaryRedeployStatusResponseSchema >; -export const canaryRedeployRequestSchema = z.object({}).strict(); +export const canaryCheckRequestSchema = z.object({}).strict(); +export type CanaryCheckRequest = z.infer; + +export const canaryRedeployConfigUpdateSchema = z + .object({ + branch: canaryBranchSchema.nullable(), + }) + .strict(); +export type CanaryRedeployConfigUpdate = z.infer< + typeof canaryRedeployConfigUpdateSchema +>; + +export const canaryRedeployRequestSchema = z + .object({ + expectedBranch: canaryBranchSchema, + }) + .strict(); export type CanaryRedeployRequest = z.infer; diff --git a/scripts/canary-redeploy-runner.mjs b/scripts/canary-redeploy-runner.mjs index 9818381a3..42a61877a 100755 --- a/scripts/canary-redeploy-runner.mjs +++ b/scripts/canary-redeploy-runner.mjs @@ -5,18 +5,30 @@ import { createWriteStream } from 'node:fs'; import { mkdir, rename, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; -const [scriptPath, statusPath, logPath, startedAtValue] = process.argv.slice(2); +const [scriptPath, statusPath, logPath, startedAtValue, branch] = + process.argv.slice(2); const startedAt = Number(startedAtValue); const RESPONSE_GRACE_MS = 1500; +const branchIsValid = + typeof branch === 'string' && + branch.length <= 255 && + !branch.startsWith('-') && + spawnSync('git', ['check-ref-format', '--branch', branch], { + stdio: 'ignore', + }).status === 0 && + spawnSync('git', ['check-ref-format', `refs/heads/${branch}`], { + stdio: 'ignore', + }).status === 0; if ( !scriptPath || !statusPath || !logPath || !Number.isSafeInteger(startedAt) || - startedAt < 0 + startedAt < 0 || + !branchIsValid ) { process.exitCode = 2; } else { @@ -32,14 +44,19 @@ if ( await rename(temporaryPath, statusPath); } - await writeStatus({ state: 'running', startedAt, runnerPid: process.pid }); + await writeStatus({ + state: 'running', + branch, + startedAt, + runnerPid: process.pid, + }); const log = createWriteStream(logPath, { flags: 'a', mode: 0o600 }); - log.write(`\n[${new Date().toISOString()}] Redeploying alpha\n`); + log.write(`\n[${new Date().toISOString()}] Redeploying ${branch}\n`); await new Promise((resolveDelay) => setTimeout(resolveDelay, RESPONSE_GRACE_MS), ); - const child = spawn(scriptPath, ['alpha', '--non-interactive'], { + const child = spawn(scriptPath, [branch, '--non-interactive'], { stdio: ['ignore', 'pipe', 'pipe'], env: process.env, }); @@ -69,6 +86,7 @@ if ( const succeeded = result.exitCode === 0; const status = { state: succeeded ? 'succeeded' : 'failed', + branch, startedAt, completedAt: Date.now(), exitCode: result.exitCode, diff --git a/scripts/start-huabu.sh b/scripts/start-huabu.sh index e764fa0b5..7fe5e7f9c 100755 --- a/scripts/start-huabu.sh +++ b/scripts/start-huabu.sh @@ -41,9 +41,10 @@ main() { echo "HUABU_CANARY_READINESS_TIMEOUT_SECONDS must be a positive integer." >&2 return 2 fi - if [[ ! "$branch_name" =~ ^[A-Za-z0-9._/-]+$ ]] || + if (( ${#branch_name} > 255 )) || [[ "$branch_name" == -* ]] || - [[ "$branch_name" == *..* ]]; then + ! git check-ref-format --branch "$branch_name" >/dev/null 2>&1 || + ! git check-ref-format "refs/heads/$branch_name" >/dev/null 2>&1; then echo "Invalid branch name: $branch_name" >&2 return 2 fi @@ -117,9 +118,16 @@ main() { tmux kill-session -t "$tmux_session" fi - echo "==> Updating $branch_name in $huabu_dir" - git -C "$huabu_dir" checkout "$branch_name" - git -C "$huabu_dir" pull --ff-only origin "$branch_name" + local branch_ref="refs/heads/$branch_name" + local remote_ref="refs/remotes/origin/$branch_name" + echo "==> Updating $branch_name from origin in $huabu_dir" + git -C "$huabu_dir" fetch --no-tags origin "$branch_ref:$remote_ref" + if git -C "$huabu_dir" show-ref --verify --quiet "$branch_ref"; then + git -C "$huabu_dir" checkout "$branch_name" + git -C "$huabu_dir" merge --ff-only "$remote_ref" + else + git -C "$huabu_dir" checkout -b "$branch_name" --track "$remote_ref" + fi echo "==> Installing dependencies" pnpm --dir "$huabu_dir" install --frozen-lockfile