Compare commits

..

No commits in common. "main" and "codex/production-48-runtime-snapshot" have entirely different histories.

15 changed files with 100 additions and 765 deletions

View File

@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
import Image from "next/image";
import { toast } from "sonner";
import { requestLongTaskResult } from "@/lib/client/long-task";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatBeijingTime } from "@/lib/utils";
@ -32,7 +32,7 @@ type Pagination = {
totalPages: number;
};
function buildGalleryDownloadFilename(url: string, alt: string, contentType: string) {
function buildGalleryDownloadFilename(url: string, alt: string) {
const rawUrl = String(url || "").trim();
const rawAlt = String(alt || "").trim();
const safeBase =
@ -58,7 +58,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
}
}
return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`;
return `${safeBase}-${Date.now()}.${extension}`;
}
export default function GalleryPage() {
@ -103,7 +103,7 @@ export default function GalleryPage() {
};
const handleDownload = async (url: string, alt: string) => {
const originalUrl = String(url || "").trim();
const originalUrl = inferOriginalImageUrl(String(url || "").trim());
if (!originalUrl) {
toast.error("图片地址无效");
return;
@ -112,14 +112,8 @@ export default function GalleryPage() {
try {
toast.info("正在准备下载...");
// Validate the supplied URL before any legacy thumbnail inference can
// remove signed storage transformation parameters.
buildImageProxyUrl(originalUrl, { delivery: "original" });
const parsed = new URL(originalUrl, window.location.origin);
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
const response = await fetch(
buildImageProxyUrl(source, { delivery: "original" })
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
@ -128,7 +122,7 @@ export default function GalleryPage() {
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
link.download = buildGalleryDownloadFilename(originalUrl, alt);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@ -136,7 +130,14 @@ export default function GalleryPage() {
toast.success("图片已开始下载");
} catch (error) {
console.error("Download failed:", error);
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
const fallbackLink = document.createElement("a");
fallbackLink.href = originalUrl;
fallbackLink.target = "_blank";
fallbackLink.rel = "noopener noreferrer";
document.body.appendChild(fallbackLink);
fallbackLink.click();
document.body.removeChild(fallbackLink);
toast.error("下载失败,已为你打开原图,可直接另存为");
}
};

View File

@ -127,15 +127,13 @@ function buildImageResponse(
etag: string;
vary?: string;
cacheStatus?: "HIT" | "MISS";
cacheControl?: string;
attachment?: boolean;
}
) {
const headers: Record<string, string> = {
"Content-Type": params.contentType,
"Content-Length": String(body.length),
ETag: params.etag,
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
};
if (params.vary) {
headers.Vary = params.vary;
@ -143,11 +141,6 @@ function buildImageResponse(
if (params.cacheStatus) {
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
}
if (params.attachment) {
headers["Content-Disposition"] = "attachment";
headers["X-Content-Type-Options"] = "nosniff";
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
}
return new NextResponse(body, {
status: 200,
headers,
@ -292,16 +285,8 @@ export async function GET(request: NextRequest) {
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
const requestedFormat = searchParams.get("f");
const forceProxy = searchParams.get("proxy") === "1";
const original = searchParams.get("original") === "1";
if (original && (requestedWidth || requestedQuality || requestedFormat)) {
return NextResponse.json(
{ error: "Original images cannot be transformed" },
{ status: 400 }
);
}
if (!forceProxy && !original) {
if (!forceProxy) {
const directImageTarget = resolveDirectImageTarget(
request,
target,
@ -314,12 +299,10 @@ export async function GET(request: NextRequest) {
}
}
const cacheKey = original
? `${target.toString()}|original-v1`
: `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
const etag = buildEtag(cacheKey);
const ifNoneMatch = request.headers.get("if-none-match");
if (!original && ifNoneMatch && ifNoneMatch === etag) {
if (ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, {
status: 304,
headers: {
@ -329,7 +312,7 @@ export async function GET(request: NextRequest) {
});
}
const cached = original ? null : getCachedImageResponse(cacheKey);
const cached = getCachedImageResponse(cacheKey);
if (cached) {
return buildImageResponse(cached.body, {
contentType: cached.contentType,
@ -344,7 +327,7 @@ export async function GET(request: NextRequest) {
upstreamResponse = await fetch(target.toString(), {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
},
});
} catch (error) {
@ -365,14 +348,6 @@ export async function GET(request: NextRequest) {
}
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
if (original) {
return buildImageResponse(originalBuffer, {
contentType,
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
cacheControl: "private, no-store",
attachment: true,
});
}
const isSvg = contentType.toLowerCase().includes("image/svg");
const isGif = contentType.toLowerCase().includes("image/gif");

View File

@ -1,6 +1,5 @@
"use client";
import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
@ -1918,7 +1917,6 @@ type CanvasTypewriterTextProps = {
className?: string;
renderMarkdown?: boolean;
preserveMarkdownTables?: boolean;
isMarkdownStreaming?: boolean;
enablePdfDownload?: boolean;
pdfFileName?: string;
pdfSiteLogo?: string | null;
@ -2359,6 +2357,20 @@ function getMarkdownTableBlockRanges(content: string): MarkdownTableBlockRange[]
return ranges;
}
function advanceTypewriterCursor(
contentLength: number,
targetCursor: number,
tableRanges: MarkdownTableBlockRange[]
) {
const clampedCursor = Math.min(contentLength, targetCursor);
for (const range of tableRanges) {
if (clampedCursor >= range.start && clampedCursor < range.end) {
return Math.min(contentLength, range.end);
}
}
return clampedCursor;
}
function CanvasTypewriterCursor() {
return (
<span
@ -2375,7 +2387,6 @@ function CanvasTypewriterText({
className,
renderMarkdown = false,
preserveMarkdownTables = false,
isMarkdownStreaming = false,
enablePdfDownload = false,
pdfFileName,
pdfSiteLogo,
@ -2402,15 +2413,12 @@ function CanvasTypewriterText({
const tableRanges = preserveMarkdownTables
? getMarkdownTableBlockRanges(content)
: [];
const atomicRanges = renderMarkdown
? [...tableRanges, ...scanMarkdownImageStreaming(content).ranges]
: tableRanges;
activeRef.current = true;
onActiveChange?.(typingKey, true);
setDisplayText("");
const timer = window.setInterval(() => {
cursor = advancePastAtomicRanges(content.length, cursor + 1, atomicRanges);
cursor = advanceTypewriterCursor(content.length, cursor + 1, tableRanges);
if (cursor >= content.length) {
setDisplayText(content);
window.clearInterval(timer);
@ -2431,7 +2439,7 @@ function CanvasTypewriterText({
onActiveChange?.(typingKey, false);
}
};
}, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, renderMarkdown, typingKey]);
}, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, typingKey]);
const showCursor = enabled && Boolean(content) && displayText.length < content.length;
@ -2440,7 +2448,6 @@ function CanvasTypewriterText({
<div className="w-full min-w-0">
<Response
className={className}
isMarkdownStreaming={isMarkdownStreaming}
enablePdfDownload={enablePdfDownload}
pdfFileName={pdfFileName}
pdfSiteLogo={pdfSiteLogo}
@ -16570,10 +16577,6 @@ export function CanvasChatPanel({
(isClawdMode || isClawdChatMessage(message));
const isLatestClawAssistantMessage =
message.id === latestClawAssistantMessageId;
const isMessageMarkdownStreaming =
message.role === "assistant" &&
index === renderableVisibleMessages.length - 1 &&
(isLoading || (isClawAssistantMessage && isClawdSubmitting));
const openClawArtifacts = isClawAssistantMessage
? getOpenClawMessageArtifacts(message)
: [];
@ -16901,7 +16904,6 @@ export function CanvasChatPanel({
<Response
key={key}
className="break-words [overflow-wrap:anywhere]"
isMarkdownStreaming={isMessageMarkdownStreaming}
>
{text}
</Response>
@ -16963,7 +16965,6 @@ export function CanvasChatPanel({
className="break-words [overflow-wrap:anywhere]"
renderMarkdown
preserveMarkdownTables={shouldTypeClawAssistantMessage}
isMarkdownStreaming={isMessageMarkdownStreaming}
enablePdfDownload={message.role === "assistant" && !shouldAnimateAnalysis}
pdfFileName={`Claw分析报告-${message.id}`}
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
@ -16986,7 +16987,6 @@ export function CanvasChatPanel({
className="break-words [overflow-wrap:anywhere]"
renderMarkdown
preserveMarkdownTables={shouldTypeClawAssistantMessage}
isMarkdownStreaming={isMessageMarkdownStreaming}
enablePdfDownload={message.role === "assistant" && !shouldAnimatePlainText}
pdfFileName={`Claw分析报告-${message.id}`}
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}

View File

@ -16,8 +16,6 @@ import {
} from "@/components/elements/openclaw-chart-card";
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
import {
Collapsible,
CollapsibleContent,
@ -28,7 +26,6 @@ import { cn } from "@/lib/utils";
type ResponseProps = ComponentProps<typeof Streamdown> & {
collapsibleImageAnalysis?: boolean;
isMarkdownStreaming?: boolean;
enablePdfDownload?: boolean;
pdfFileName?: string;
pdfSiteLogo?: string | null;
@ -365,6 +362,43 @@ function normalizeMarkdownContent(value: string) {
.trim();
}
function splitMarkdownImages(content: string) {
const segments: Array<
| { type: "text"; value: string }
| { type: "image"; alt?: string; src: string }
> = [];
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = imageRegex.exec(content)) !== null) {
if (match.index > lastIndex) {
segments.push({
type: "text",
value: content.slice(lastIndex, match.index),
});
}
segments.push({
type: "image",
alt: match[1],
src: match[2],
});
lastIndex = imageRegex.lastIndex;
}
if (lastIndex < content.length) {
segments.push({
type: "text",
value: content.slice(lastIndex),
});
}
return segments;
}
function isLikelyImageUrl(value?: string | null) {
return IMAGE_URL_REGEX.test(String(value || "").trim());
}
@ -763,7 +797,6 @@ function ResponseReportTableDownloadButton({
export const Response = ({
className,
collapsibleImageAnalysis = false,
isMarkdownStreaming = false,
enablePdfDownload = false,
pdfFileName,
pdfSiteLogo,
@ -1028,7 +1061,7 @@ export const Response = ({
const rawText =
typeof props.children === "string"
? normalizeMarkdownContent(stableStreamingMarkdown(props.children, isMarkdownStreaming))
? normalizeMarkdownContent(props.children)
: null;
const shouldShowPdfDownload =
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
@ -1058,7 +1091,7 @@ export const Response = ({
);
};
if (rawText !== null) {
if (rawText) {
const analysisSection = collapsibleImageAnalysis
? extractImageAnalysisSection(rawText)
: null;

View File

@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
import React, { useState, useEffect } from "react";
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
import { toast } from "sonner";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { buildImageProxyUrl } from "@/lib/image-delivery";
const ImageAnnotator = dynamic(
() => import("./image-annotator").then((module) => module.ImageAnnotator),
@ -237,24 +237,34 @@ export function ImagePreview({
toast.error("下载失败,请重试");
return;
}
const filenameBase = `generated-image-${Date.now()}`;
const filename = `generated-image-${Date.now()}.jpg`;
try {
const response = await fetch(
buildImageProxyUrl(safeOriginalUrl, { delivery: "original" })
buildImageProxyUrl(safeOriginalUrl, { delivery: "proxy" })
);
if (!response.ok) {
throw new Error(`下载失败(${response.status})`);
}
const blob = await response.blob();
const extension = getImageDownloadExtension(blob.type);
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
const url = window.URL.createObjectURL(blob);
triggerBrowserDownload(url, filename);
window.URL.revokeObjectURL(url);
toast.success("下载成功");
} catch (error) {
console.error("下载失败:", error);
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
try {
triggerBrowserDownload(safeOriginalUrl, filename);
toast.message("代理下载失败,已切换为原图直链下载");
} catch (fallbackError) {
console.error("原图直链下载失败:", fallbackError);
try {
triggerBrowserDownload(safeOriginalUrl, undefined, { openInNewTab: true });
toast.message("已打开原图,可直接另存为");
} catch (openError) {
console.error("打开原图失败:", openError);
toast.error("下载失败,请重试");
}
}
}
};

View File

@ -60,22 +60,10 @@ python scripts/production48.py rollback --release <发布ID>
`activate` 会重新核对生产版本和任务状态,拒绝在最近一小时仍有未结束生成任务时切换。
更早的历史挂起记录只写入发布回执,不会自动修改数据库中的状态。
它保存旧 PM2 配置和启动快照,然后仅重建 `ai-chatbot` 这一项 PM2 定义,
让新版本的工作目录、可执行路径和环境变量全部生效,不改变其他应用,
它保存旧 PM2 配置和启动快照,然后仅更新 `ai-chatbot` 的工作目录并重启这个应用,
因此会有短暂切换窗口。健康检查同时核对进程目录、构建 ID、首页和路由。
检查失败自动恢复旧配置并再次验活;通过后执行 `pm2 save`,让重启后使用已验收版本。
PM2 的普通 restart 会保留旧工作目录和程序路径,因此不能用它替代本流程的单应用定义重建。
若一次切换已经明确回滚并验证原版本健康,修复发布工具后,可保留已校验制品并显式重试:
```powershell
python scripts/production48.py activate --release <发布ID> --retry-rolled-back
```
只有日志阶段为 `rolled-back` 且当前工作目录/构建目录仍匹配原版本才允许重试。
旧尝试日志另外保留。运行制品的 `sourceCommit` 始终指向当时实际构建的 main 提交,
后续工具修复不改写这个来源;`operatorCommit` 单独记录执行切换的维护工具版本。
## 运行资料与边界
每个发布目录包含构建机按锁文件安装的完整运行依赖和 public 源码资源。

View File

@ -1,113 +0,0 @@
export type MarkdownImageSegment =
| { type: "text"; value: string }
| { type: "image"; alt?: string; src: string };
// Markdown destinations may include balanced parentheses and backslash escapes.
function readDestination(content: string, open: number) {
if (content[open] !== "(") return null;
let depth = 1;
for (let index = open + 1; index < content.length; index += 1) {
const character = content[index];
if (character === "\\") {
index += 1;
continue;
}
if (character === "(") depth += 1;
if (character === ")" && --depth === 0) {
const raw = content.slice(open + 1, index);
if (!/^https?:\/\//i.test(raw) || /\s/.test(raw)) return null;
return { url: raw.replace(/\\([()\\])/g, "$1"), end: index + 1 };
}
}
return null;
}
function readImage(content: string, start: number) {
if (!content.startsWith("![", start)) return null;
let depth = 1;
for (let index = start + 2; index < content.length; index += 1) {
const character = content[index];
if (character === "\\") {
index += 1;
continue;
}
if (character === "[") depth += 1;
if (character !== "]" || --depth !== 0) continue;
const destination = readDestination(content, index + 1);
if (!destination) return null;
return {
alt: content.slice(start + 2, index).replace(/\\([\[\]\\])/g, "$1"),
src: destination.url,
end: destination.end,
};
}
return null;
}
export function splitMarkdownImages(content: string): MarkdownImageSegment[] {
const segments: MarkdownImageSegment[] = [];
let textStart = 0;
let cursor = 0;
while (cursor < content.length) {
// Leave Markdown examples in code spans and fences untouched.
if (content[cursor] === "`") {
let endOfRun = cursor + 1;
while (content[endOfRun] === "`") endOfRun += 1;
const marker = content.slice(cursor, endOfRun);
const close = content.indexOf(marker, endOfRun);
cursor = close < 0 ? content.length : close + marker.length;
continue;
}
const lineStart = cursor === 0 || content[cursor - 1] === "\n";
if (lineStart) {
const fence = content.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
if (fence) {
const marker = fence[1];
const closing = new RegExp(`^ {0,3}~{${marker.length},}\\s*$`);
let line = cursor + fence[0].length;
while (line < content.length) {
const next = content.indexOf("\n", line);
const end = next < 0 ? content.length : next;
if (closing.test(content.slice(line, end))) {
line = next < 0 ? end : end + 1;
break;
}
line = next < 0 ? end : end + 1;
}
cursor = line;
continue;
}
}
if (content[cursor] === "\\") {
cursor += 2;
continue;
}
const wrapped = content.startsWith("[![", cursor);
const image = readImage(content, cursor + (wrapped ? 1 : 0));
if (!image) {
cursor += 1;
continue;
}
let end = image.end;
if (wrapped) {
const outer = content[end] === "]" ? readDestination(content, end + 1) : null;
// Preserve incomplete, unsafe and different-target links as Markdown so
// Streamdown applies its normal link sanitization and click behavior.
if (!outer || outer.url !== image.src) {
cursor = outer ? outer.end : end;
continue;
}
end = outer.end;
}
if (cursor > textStart) {
segments.push({ type: "text", value: content.slice(textStart, cursor) });
}
segments.push({ type: "image", alt: image.alt, src: image.src });
cursor = end;
textStart = end;
}
if (textStart < content.length) {
segments.push({ type: "text", value: content.slice(textStart) });
}
return segments;
}

View File

@ -1,146 +0,0 @@
/** A half-open span containing a complete Markdown link or image. */
export type MarkdownImageRange = { start: number; end: number };
type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" };
// This scanner recognizes syntax only; rendering and URL safety remain the
// responsibility of the existing Markdown renderer and image splitter.
function readDestination(text: string, start: number, allowWhitespace = false): Parsed {
if (start >= text.length) return { kind: "pending" };
if (text[start] !== "(") return { kind: "invalid" };
let depth = 1;
for (let i = start + 1; i < text.length; i += 1) {
const char = text[i];
if (char === "\\") {
if (i + 1 >= text.length) return { kind: "pending" };
i += 1;
continue;
}
if (char === "\n" || char === "\r" || (!allowWhitespace && /\s/.test(char))) return { kind: "invalid" };
if (char === "(") depth += 1;
if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 };
}
return { kind: "pending" };
}
function readImage(text: string, start: number, wrapped: boolean): Parsed {
const imageStart = start + (wrapped ? 1 : 0);
if (imageStart + 1 >= text.length) return { kind: "pending" };
if (text[imageStart] !== "!" || text[imageStart + 1] !== "[") return { kind: "invalid" };
let depth = 1;
for (let i = imageStart + 2; i < text.length; i += 1) {
const char = text[i];
if (char === "\n" || char === "\r") return { kind: "invalid" };
if (char === "\\") {
if (i + 1 >= text.length) return { kind: "pending" };
i += 1;
continue;
}
if (char === "[") depth += 1;
if (char !== "]" || --depth !== 0) continue;
const inner = readDestination(text, i + 1);
if (inner.kind !== "complete" || !wrapped) return inner;
if (inner.end >= text.length) return { kind: "pending" };
if (text[inner.end] !== "]") return { kind: "invalid" };
return readDestination(text, inner.end + 1);
}
return { kind: "pending" };
}
function readLink(text: string, start: number): Parsed {
let depth = 1;
for (let i = start + 1; i < text.length; i += 1) {
const char = text[i];
// An unfinished bracket on an earlier line must not hide later output.
if (char === "\n" || char === "\r") return { kind: "invalid" };
if (char === "\\") {
if (i + 1 >= text.length) return { kind: "pending" };
i += 1;
continue;
}
if (char === "[") depth += 1;
if (char !== "]" || --depth !== 0) continue;
if (text[i + 1] === "[") {
for (let j = i + 2; j < text.length; j += 1) {
if (text[j] === "\n" || text[j] === "\r") return { kind: "invalid" };
if (text[j] === "\\") {
if (j + 1 >= text.length) return { kind: "pending" };
j += 1;
continue;
}
if (text[j] === "]") return { kind: "complete", end: j + 1 };
}
return { kind: "pending" };
}
return readDestination(text, i + 1, true);
}
return { kind: "pending" };
}
/** Locate complete Markdown links/images and an incomplete trailing construct.
* Code spans/fences and escaped markers remain normal text. */
export function scanMarkdownImageStreaming(text: string): {
ranges: MarkdownImageRange[];
pendingStart: number | null;
} {
const ranges: MarkdownImageRange[] = [];
let cursor = 0;
while (cursor < text.length) {
if (text[cursor] === "\\") { cursor += 2; continue; }
if (text[cursor] === "`") {
let end = cursor + 1;
while (text[end] === "`") end += 1;
const marker = text.slice(cursor, end);
const close = text.indexOf(marker, end);
cursor = close < 0 ? text.length : close + marker.length;
continue;
}
if (cursor === 0 || text[cursor - 1] === "\n") {
const fence = text.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
if (fence) {
const closing = new RegExp(`^ {0,3}~{${fence[1].length},}\\s*$`);
let line = cursor + fence[0].length;
while (line < text.length) {
const next = text.indexOf("\n", line);
const end = next < 0 ? text.length : next;
if (closing.test(text.slice(line, end))) { line = next < 0 ? end : end + 1; break; }
line = next < 0 ? end : end + 1;
}
cursor = line;
continue;
}
}
const char = text[cursor];
if (char !== "[" && char !== "!") { cursor += 1; continue; }
const wrapped = char === "[";
const parsed = wrapped && text[cursor + 1] !== "!"
? readLink(text, cursor)
: readImage(text, cursor, wrapped);
if (parsed.kind === "pending") return { ranges, pendingStart: cursor };
if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; }
else cursor += 1;
}
return { ranges, pendingStart: null };
}
/** During active delivery, withhold only unfinished link/image syntax at the tail. */
export function stableStreamingMarkdown(text: string, streaming: boolean): string {
if (!streaming) return text;
const { pendingStart } = scanMarkdownImageStreaming(text);
return pendingStart === null ? text : text.slice(0, pendingStart);
}
export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) {
let position = Math.min(length, target);
// Overlapping table/link ranges can require more than one jump.
for (let changed = true; changed;) {
changed = false;
for (const range of ranges) {
if (position > range.start && position < range.end) {
position = Math.min(length, range.end);
changed = true;
}
}
}
return position;
}

View File

@ -6,24 +6,9 @@ export type ImageProxyOptions = {
width?: number;
quality?: number;
format?: ImageProxyFormat;
delivery?: "display" | "proxy" | "original";
delivery?: "display" | "proxy";
};
/** Preserve the actual response format when naming an original download. */
export function getImageDownloadExtension(contentType: string) {
const mime = contentType.split(";")[0]?.trim().toLowerCase();
const extensions: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/avif": "avif",
"image/gif": "gif",
"image/svg+xml": "svg",
};
return extensions[mime] || "";
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
@ -156,40 +141,11 @@ function resolveAliyunOssDisplayUrl(
}
export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string {
let source = String(url || "").trim();
// Some messages retain our preview URL instead of its source URL. Rebuild
// that local proxy request without the preview's resize/format parameters.
if (options.delivery === "original") {
const base = typeof window === "undefined" ? "https://image-proxy.local" : window.location.origin;
try {
const parsed = new URL(source, base);
if (parsed.origin === base && parsed.pathname === IMAGE_PROXY_PATH) {
const nestedSource = parsed.searchParams.get("url") || "";
if (isAbsoluteHttpUrl(nestedSource)) source = nestedSource;
}
} catch {
// Leave malformed and non-URL sources to the existing handling below.
}
try {
const parsed = new URL(source);
if ([...parsed.searchParams.keys()].some((key) => /^(?:imageview2(?:\/|$)|x-oss-process$)/i.test(key))) {
throw new Error("图片链接包含处理参数,无法确认原图");
}
} catch (error) {
if (error instanceof Error && error.message === "图片链接包含处理参数,无法确认原图") throw error;
}
}
const source = String(url || "").trim();
if (isBypassUrl(source)) {
return source;
}
// An original download must go through the same-origin byte-preserving
// proxy even for trusted storage hosts: a direct fetch may fail CORS.
if (options.delivery === "original") {
const params = new URLSearchParams({ url: source, original: "1" });
return `${IMAGE_PROXY_PATH}?${params.toString()}`;
}
const trustedDirectStorageUrl = resolveTrustedDirectStorageDisplayUrl(
source,
options

View File

@ -226,13 +226,11 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["status", "jobs", "build", "stage", "activate", "rollback"])
parser.add_argument("--release")
parser.add_argument("--retry-rolled-back", action="store_true")
parser.add_argument("--key", type=Path, default=Path.home() / ".ssh/45.192.99.48.pem")
parser.add_argument("--jump-key", type=Path, default=Path.home() / ".ssh/youyidian_prod_ed25519")
parser.add_argument("--known-hosts", type=Path, default=Path.home() / ".ssh/known_hosts")
parser.add_argument("--receipts", type=Path, default=ROOT / ".git/releases")
args = parser.parse_args()
require(not args.retry_rolled_back or args.action == "activate", "Retry flag only applies to activation")
if args.action in {"stage", "activate", "rollback"}:
require(args.release and re.fullmatch(r"\d{8}T\d{6}Z-[0-9a-f]{12}", args.release), "--release is required")
args.receipts = args.receipts.resolve()
@ -243,10 +241,6 @@ def main():
stage(connections, args)
else:
kwargs = {"release": args.release} if args.release else {}
if args.action == "activate":
kwargs["operator-commit"] = git("rev-parse", "HEAD")
if args.retry_rolled_back:
kwargs["retry"] = "rolled-back"
result = connections.helper(args.action, **kwargs)
if args.release:
write_json(args.receipts / args.release / (args.action + ".json"), result)

View File

@ -384,34 +384,21 @@ def healthy(cwd, build_id):
def restart(config):
apps = json.loads(config.read_text())["apps"]
require(len(apps) == 1 and apps[0]["name"] == NAME, "Refusing a multi-application PM2 definition")
current = [app for app in json.loads(run(["pm2", "jlist"])) if app["name"] == NAME]
require(len(current) <= 1, "Duplicate ai-chatbot definitions; operator required")
# PM2 restart updates env but retains the old executable/cwd. Recreate only
# this name so the new immutable release's process definition really applies.
if current:
run(["pm2", "delete", NAME], timeout=150)
run(["pm2", "start", str(config), "--only", NAME], timeout=150)
run(["pm2", "startOrRestart", str(config), "--only", NAME, "--update-env"], timeout=150)
def activate(release_id, retry=False, operator_commit=None):
def activate(release_id):
destination = release_path(release_id)
manifest = json.loads((destination / "release.json").read_text())
staged = json.loads((destination / "staged.json").read_text())
app, current, dist = app_state()
require(str(current) == staged["previousCwd"] and dist == staged["previousDistDir"],
"Production changed after staging")
journal = destination / "activation.json"
prior = json.loads(journal.read_text()) if journal.exists() else None
require(not prior or (retry and prior.get("phase") == "rolled-back"),
"Already activated; inspect status before explicitly retrying a rolled-back attempt")
require(not (destination / "activation.json").exists(), "Already activated; inspect status")
verify_runtime(manifest, destination)
jobs = job_status()
require(not any(row["recent"] for rows in jobs.values() for row in rows),
"Recent active generation jobs: defer activation")
if prior:
json_write(destination / f"activation-history-{time.time_ns()}.json", prior)
before = ecosystem(app, current, dist)
previous_build = (current / dist / "BUILD_ID").read_text().strip()
json_write(destination / "previous.pm2.json", before)
@ -423,7 +410,7 @@ def activate(release_id, retry=False, operator_commit=None):
"previousBuildId": previous_build, "previousCwd": str(current),
"activatedAtUtc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"historicalTaskStates": jobs, "artifactSha256": staged["artifactSha256"],
"phase": "activating", "operatorCommit": operator_commit}
"phase": "activating"}
json_write(destination / "activation.json", receipt)
try:
restart(destination / "candidate.pm2.json")
@ -466,8 +453,6 @@ def main():
parser.add_argument("--release")
parser.add_argument("--digest")
parser.add_argument("--workspace", type=Path)
parser.add_argument("--retry", choices=["rolled-back"])
parser.add_argument("--operator-commit")
args = parser.parse_args()
os.umask(0o077)
if args.action in {"pack", "smoke"}:
@ -484,8 +469,7 @@ def main():
if args.action == "stage":
result = stage(args.release, args.digest)
elif args.action == "activate":
result = activate(args.release, retry=args.retry == "rolled-back",
operator_commit=args.operator_commit)
result = activate(args.release)
else:
result = rollback(args.release)
print(json.dumps(result))

View File

@ -1,72 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { splitMarkdownImages } from "../../lib/canvas/markdown-image-segments";
const image = "https://example.com/folder/test.jpg";
test("SKU output consumes the entire redundant linked-image wrapper", () => {
const text = `生成图\n[![第2行](${image})](${image})\n下载完成`;
assert.deepEqual(splitMarkdownImages(text), [
{ type: "text", value: "生成图\n" },
{ type: "image", alt: "第2行", src: image },
{ type: "text", value: "\n下载完成" },
]);
});
test("several linked and ordinary images do not leave broken wrapper text", () => {
const parts = splitMarkdownImages(`[![a](${image})](${image})\n![b](https://example.com/b.png)`);
assert.deepEqual(parts, [
{ type: "image", alt: "a", src: image },
{ type: "text", value: "\n" },
{ type: "image", alt: "b", src: "https://example.com/b.png" },
]);
});
test("different outer links and ordinary links are preserved verbatim", () => {
const nested = `[![thumbnail](${image})](https://example.com/full.jpg)`;
const input = `[文档](https://example.com/doc) ${nested} ![plain](https://example.com/next.jpg)`;
assert.deepEqual(splitMarkdownImages(input), [
{ type: "text", value: `[文档](https://example.com/doc) ${nested} ` },
{ type: "image", alt: "plain", src: "https://example.com/next.jpg" },
]);
});
test("supports balanced and escaped parentheses and nested/escaped alt text", () => {
const url = "https://example.com/a_(1)/x.jpg";
assert.deepEqual(splitMarkdownImages(`[![a [b] \\] c](${url})](${url})`), [
{ type: "image", alt: "a [b] ] c", src: url },
]);
assert.deepEqual(splitMarkdownImages("![a](https://example.com/a\\(1\\).jpg)"), [
{ type: "image", alt: "a", src: "https://example.com/a(1).jpg" },
]);
});
test("does not extract examples in inline and fenced code", () => {
const input = `\`![code](${image})\`\n\`\`\`md\n[![a](${image})](${image})\n\`\`\`\n~~~md\n![c](${image})\n~~~\n![actual](${image})`;
const parts = splitMarkdownImages(input);
assert.equal(parts.filter((part) => part.type === "image").length, 1);
assert.equal(parts[0].type, "text");
assert.ok(parts[0].type === "text" && parts[0].value.includes("![code]"));
assert.deepEqual(parts[1], { type: "image", alt: "actual", src: image });
});
test("preserves unsafe, incomplete, and escaped markup for the existing renderer", () => {
for (const content of [
`[![x](${image})](javascript:alert(1))`,
`[![x](${image})](`,
`\\![x](${image})`,
"![x](data:image/png;base64,AAAA)",
]) assert.deepEqual(splitMarkdownImages(content), [{ type: "text", value: content }]);
});
test("retains existing thumbnail metadata in alt text", () => {
for (const alt of [
"thumb|||https://example.com/original.jpg|||",
"thumb$$https://example.com/original.jpg$$",
"thumb|https://example.com/original.jpg|",
]) {
assert.deepEqual(splitMarkdownImages(`![${alt}](${image})`), [
{ type: "image", alt, src: image },
]);
}
});

View File

@ -1,104 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { advancePastAtomicRanges, scanMarkdownImageStreaming, stableStreamingMarkdown } from "../../lib/canvas/markdown-image-streaming";
import { splitMarkdownImages } from "../../lib/canvas/markdown-image-segments";
const url = "https://scqny.jiansun.vip/ps-skill-api/results/job_test/01-%E6%A0%B7%E4%BE%8B.jpg";
const wrapped = `[![01-样例.jpg](${url})](${url})`;
const other = `[![缩略图](${url})](https://example.com/original.jpg)`;
test("every live network prefix withholds the entire unfinished linked image", () => {
const before = "## 生成图\n";
for (let n = 1; n < wrapped.length; n += 1) {
assert.equal(stableStreamingMarkdown(before + wrapped.slice(0, n), true), before, `prefix ${n}`);
}
assert.equal(stableStreamingMarkdown(before + wrapped, true), before + wrapped);
// An interrupted / completed response keeps malformed original text visible.
for (const suffix of ["[", "[!", "[![name](https://example.com/a.jpg)]("]) {
assert.equal(stableStreamingMarkdown(before + suffix, false), before + suffix);
}
});
test("every synthetic typewriter cursor exposes complete image markup or none", () => {
const content = `生成图\n${wrapped}\n${other}\n可编辑文件`;
const ranges = scanMarkdownImageStreaming(content).ranges;
assert.equal(ranges.length, 2);
let cursor = 0;
while (cursor < content.length) {
cursor = advancePastAtomicRanges(content.length, cursor + 1, ranges);
const visible = content.slice(0, cursor);
for (const range of ranges) {
assert.ok(cursor <= range.start || cursor >= range.end, `split at ${cursor}`);
}
if (cursor >= ranges[0].end) {
assert.ok(splitMarkdownImages(visible).some((item) => item.type === "image"));
}
}
assert.equal(cursor, content.length);
assert.deepEqual(splitMarkdownImages(other), [{ type: "text", value: other }]);
});
test("handles ordinary images, URL brackets, escaped text and code safely", () => {
const simple = "![图](https://example.com/a_(1).jpg)";
for (let n = 1; n < simple.length; n += 1) {
assert.equal(stableStreamingMarkdown("前文 " + simple.slice(0, n), true), "前文 ", `prefix ${n}`);
}
const literal = "`![code](https://example.com/a` and \\![literal] and [ordinary](https://example.com)";
assert.equal(stableStreamingMarkdown(literal, true), literal);
const fence = "~~~md\n[![sample](https://example.com\n~~~\n";
assert.equal(stableStreamingMarkdown(fence, true), fence);
assert.deepEqual(scanMarkdownImageStreaming(fence + simple).ranges, [{ start: fence.length, end: fence.length + simple.length }]);
const invalid = "![bad](https://example.com/a b) and later text";
assert.equal(stableStreamingMarkdown(invalid, true), invalid);
});
test("does not change normal links or security-sensitive complete markup", () => {
const values = ["[普通链接](https://example.com/a)", "[![x](https://example.com/x.jpg)](javascript:alert(1))", other];
for (const value of values) assert.equal(stableStreamingMarkdown(value, true), value);
});
test("coalesces overlapping typewriter table and image ranges without looping", () => {
const ranges = [{ start: 0, end: 20 }, { start: 10, end: 35 }, { start: 35, end: 40 }];
assert.equal(advancePastAtomicRanges(50, 1, ranges), 35);
assert.equal(advancePastAtomicRanges(50, 36, ranges), 40);
assert.equal(advancePastAtomicRanges(50, 50, ranges), 50);
});
test("SKU result links are atomic across every typewriter cursor and live fragment", () => {
const links = [
"[beike-sku-template2-batch.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/795c72cb-batch.json)",
"[beike-sku-template2-report.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/af588253-report.json)",
];
const text = `生成图\n${wrapped}\n可编辑文件\n- ${links[0]}\n- ${links[1]}\n完成`;
const ranges = scanMarkdownImageStreaming(text).ranges;
assert.equal(ranges.length, 3);
for (const link of links) {
const start = text.indexOf(link);
for (let n = 1; n < link.length; n += 1) {
const prefix = text.slice(0, start + n);
assert.equal(stableStreamingMarkdown(prefix, true), text.slice(0, start), `link prefix ${n}`);
}
assert.equal(stableStreamingMarkdown(text.slice(0, start + link.length), true), text.slice(0, start + link.length));
}
let cursor = 0;
while (cursor < text.length) {
cursor = advancePastAtomicRanges(text.length, cursor + 1, ranges);
assert.ok(ranges.every((range) => cursor <= range.start || cursor >= range.end), `split at ${cursor}`);
}
assert.equal(cursor, text.length);
});
test("ordinary brackets, code, escaped markers and unfinished final links remain available", () => {
for (const value of ["普通文字", "[状态] 已完成", "[引用][reference]", "\\[转义](https://example.com)", "`[code](https://example.com`", "~~~md\n[example](https://example.com\n~~~\n"]) {
assert.equal(stableStreamingMarkdown(value, true), value);
assert.equal(stableStreamingMarkdown(value, false), value);
}
for (const partial of ["[状态", "[文件](https://example.com/", "[文件]"]) {
assert.equal(stableStreamingMarkdown(partial, false), partial);
assert.equal(stableStreamingMarkdown(partial + "\n普通文字", true), partial + "\n普通文字");
}
const titleLink = '[文档](https://example.com/a "标题")';
assert.deepEqual(scanMarkdownImageStreaming(titleLink).ranges, [{ start: 0, end: titleLink.length }]);
const unsafeLink = "[危险链接](javascript:alert(1))";
assert.equal(stableStreamingMarkdown(unsafeLink, false), unsafeLink);
});

View File

@ -1,8 +1,5 @@
import { expect, test } from "@playwright/test";
import { NextRequest } from "next/server";
import sharp from "sharp";
import { GET } from "@/app/api/image-proxy/route";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { buildImageProxyUrl } from "@/lib/image-delivery";
test.describe("Qiniu image delivery", () => {
test("uses Qiniu image processing for direct canvas previews", () => {
@ -41,119 +38,4 @@ test.describe("Qiniu image delivery", () => {
});
expect(result).toContain("/api/image-proxy?");
});
test("original downloads use an explicit same-origin proxy even for trusted signed CDN URLs", () => {
const source = "https://yybb.top/uploads/result.png?token=example&expires=123";
const result = new URL(buildImageProxyUrl(source, { delivery: "original" }), "https://app.example");
expect(result.pathname).toBe("/api/image-proxy");
expect(result.searchParams.get("url")).toBe(source);
expect(result.searchParams.get("original")).toBe("1");
expect(result.searchParams.has("w")).toBe(false);
expect(buildImageProxyUrl("data:image/png;base64,AA==", { delivery: "original" })).toBe("data:image/png;base64,AA==");
expect(buildImageProxyUrl("blob:example", { delivery: "original" })).toBe("blob:example");
expect(buildImageProxyUrl("/api/canvas/file/1", { delivery: "original" })).toBe("/api/canvas/file/1");
const nested = `/api/image-proxy?url=${encodeURIComponent(source)}&w=320&f=webp`;
expect(new URL(buildImageProxyUrl(nested, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(source);
expect(buildImageProxyUrl(nested, { delivery: "original" })).not.toContain("w=320");
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { origin: "https://ai.yydd.ai" } } });
try {
const sameOrigin = `https://ai.yydd.ai${nested}`;
expect(new URL(buildImageProxyUrl(sameOrigin, { delivery: "original" }), "https://ai.yydd.ai").searchParams.get("url")).toBe(source);
const external = `https://other.example${nested}`;
expect(new URL(buildImageProxyUrl(external, { delivery: "original" }), "https://ai.yydd.ai").searchParams.get("url")).toBe(external);
} finally {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("original proxy preserves JPEG bytes and ICC and does not reuse transformed or stale responses", async () => {
const source = "https://public.example/original.jpg?signature=abc";
const jpeg = await sharp({ create: { width: 12, height: 12, channels: 3, background: "#345678" } })
.withIccProfile("srgb").jpeg().toBuffer();
const newer = await sharp({ create: { width: 12, height: 12, channels: 3, background: "#123456" } })
.withIccProfile("srgb").jpeg().toBuffer();
expect((await sharp(jpeg).metadata()).icc).toBeTruthy();
const previousFetch = globalThis.fetch;
let body = jpeg;
globalThis.fetch = async () => new Response(body, { headers: { "Content-Type": "image/jpeg" } });
try {
const transformedUrl = new URL("http://localhost/api/image-proxy");
transformedUrl.searchParams.set("url", source);
transformedUrl.searchParams.set("proxy", "1");
const transformed = await GET(new NextRequest(transformedUrl));
expect(Buffer.from(await transformed.arrayBuffer()).equals(jpeg)).toBe(false);
const rawUrl = new URL("http://localhost/api/image-proxy");
rawUrl.searchParams.set("url", source);
rawUrl.searchParams.set("original", "1");
const first = await GET(new NextRequest(rawUrl));
const downloaded = Buffer.from(await first.arrayBuffer());
expect(downloaded.equals(jpeg)).toBe(true);
expect((await sharp(downloaded).metadata()).icc).toEqual((await sharp(jpeg).metadata()).icc);
expect(first.headers.get("content-type")).toBe("image/jpeg");
expect(first.headers.get("cache-control")).toBe("private, no-store");
expect(first.headers.get("content-disposition")).toBe("attachment");
expect(first.headers.get("x-content-type-options")).toBe("nosniff");
body = newer;
const next = await GET(new NextRequest(rawUrl, { headers: { "If-None-Match": first.headers.get("etag") || "" } }));
expect(next.status).toBe(200);
expect(Buffer.from(await next.arrayBuffer()).equals(newer)).toBe(true);
} finally {
globalThis.fetch = previousFetch;
}
});
test("rejects processed storage URLs without removing signatures", () => {
const qiniu = "https://yybb.top/a.jpg?imageView2/2/w/320&token=signed";
const aliyun = "https://bucket.oss-cn.aliyuncs.com/a.jpg?x-oss-process=image%2Fresize%2Cw_320&signature=signed";
for (const source of [qiniu, aliyun, `/api/image-proxy?url=${encodeURIComponent(qiniu)}&w=300`]) {
expect(() => buildImageProxyUrl(source, { delivery: "original" })).toThrow("无法确认原图");
}
const signed = "https://yybb.top/a.jpg?token=example%2Fvalue&expires=123";
expect(new URL(buildImageProxyUrl(signed, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(signed);
});
test("original proxy rejects private hosts, conflicting transforms and non-image content", async () => {
const previousFetch = globalThis.fetch;
let fetchCount = 0;
globalThis.fetch = async () => { fetchCount++; return new Response("not an image", { headers: { "Content-Type": "text/html" } }); };
try {
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=http%3A%2F%2F192.168.1.1%2Fsecret&original=1"))).status).toBe(403);
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1&w=200"))).status).toBe(400);
expect(fetchCount).toBe(0);
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1"))).status).toBe(400);
} finally {
globalThis.fetch = previousFetch;
}
});
test("original proxy returns PNG and WebP data without format conversion", async () => {
const originalFetch = globalThis.fetch;
try {
for (const format of ["png", "webp"] as const) {
const image = sharp({ create: { width: 4, height: 4, channels: 4, background: "#11447780" } });
const data = await (format === "png" ? image.png() : image.webp()).toBuffer();
globalThis.fetch = async () => new Response(data, { headers: { "Content-Type": `image/${format}` } });
const url = new URL("http://localhost/api/image-proxy");
url.searchParams.set("url", `https://public.example/original.${format}`);
url.searchParams.set("original", "1");
const response = await GET(new NextRequest(url));
expect(response.headers.get("content-type")).toBe(`image/${format}`);
expect(Buffer.from(await response.arrayBuffer()).equals(data)).toBe(true);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("download filename extension follows the original image MIME", () => {
expect(getImageDownloadExtension("image/png")).toBe("png");
expect(getImageDownloadExtension("image/jpeg; charset=binary")).toBe("jpg");
expect(getImageDownloadExtension("image/webp")).toBe("webp");
expect(getImageDownloadExtension("image/avif")).toBe("avif");
expect(getImageDownloadExtension("application/octet-stream")).toBe("");
});
});

View File

@ -222,59 +222,6 @@ class ActivationTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "Rollback health check failed"):
host.activate(self.release_id)
def test_explicit_retry_preserves_the_failed_attempt(self):
host.json_write(self.target / "activation.json", {"phase": "rolled-back", "previousBuildId": "old-build"})
with patch.object(host, "healthy", return_value=True):
result = host.activate(self.release_id, retry=True, operator_commit="tool-commit")
self.assertEqual(result["phase"], "active")
self.assertEqual(result["operatorCommit"], "tool-commit")
history = list(self.target.glob("activation-history-*.json"))
self.assertEqual(len(history), 1)
self.assertEqual(json.loads(history[0].read_text())["phase"], "rolled-back")
def test_retry_cannot_replace_an_active_attempt(self):
host.json_write(self.target / "activation.json", {"phase": "active"})
with self.assertRaisesRegex(RuntimeError, "Already activated"):
host.activate(self.release_id, retry=True)
self.restart.assert_not_called()
def test_failed_process_start_also_restores_previous_definition(self):
self.restart.side_effect = [RuntimeError("candidate start failed"), None]
with patch.object(host, "healthy", return_value=True):
with self.assertRaisesRegex(RuntimeError, "candidate start failed"):
host.activate(self.release_id)
self.assertEqual(self.restart.call_args_list[-1].args[0].name, "previous.pm2.json")
class ProcessDefinitionTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.config = Path(self.temporary.name) / "candidate.json"
self.config.write_text(json.dumps({"apps": [{"name": "ai-chatbot", "cwd": "/new/release"}]}))
def tearDown(self):
self.temporary.cleanup()
def test_recreates_only_target_application(self):
with patch.object(host, "run", side_effect=[json.dumps([{"name": "ai-chatbot"}, {"name": "other-service"}]), "", ""]) as run:
host.restart(self.config)
self.assertEqual([call.args[0] for call in run.call_args_list], [
["pm2", "jlist"], ["pm2", "delete", "ai-chatbot"],
["pm2", "start", str(self.config), "--only", "ai-chatbot"]])
def test_can_restore_after_failed_candidate_left_no_process(self):
with patch.object(host, "run", side_effect=["[]", ""]) as run:
host.restart(self.config)
self.assertEqual(len(run.call_args_list), 2)
self.assertEqual(run.call_args_list[-1].args[0][1], "start")
def test_refuses_definitions_that_include_other_applications(self):
self.config.write_text(json.dumps({"apps": [{"name": "ai-chatbot"}, {"name": "other"}]}))
with patch.object(host, "run") as run:
with self.assertRaisesRegex(RuntimeError, "multi-application"):
host.restart(self.config)
run.assert_not_called()
class HelperTests(unittest.TestCase):
def test_production_e2e_switch_blocks_release(self):