From 7df9187a127a25de8892908329b171c5cbb9ad67 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 05:32:18 +0200 Subject: [PATCH 01/26] =?UTF-8?q?docs:=20=E7=99=BB=E8=AE=B0=20events=20?= =?UTF-8?q?=E9=95=BF=E8=BD=AE=E8=AF=A2=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/agentrun-runtime-reliability.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/MDTODO/agentrun-runtime-reliability.md b/docs/MDTODO/agentrun-runtime-reliability.md index 54e1df06..05551c4e 100644 --- a/docs/MDTODO/agentrun-runtime-reliability.md +++ b/docs/MDTODO/agentrun-runtime-reliability.md @@ -131,3 +131,7 @@ ## R7 [completed] 将 Artificer 固化为所有执行型子代理任务的强制默认执行面:用户只说“子代理”时必须使用 Artificer;只有用户明确指定原生子代理,或 Artificer 经有界 readiness/真实派单证据确认故障不可用时,才允许原生子代理执行;禁止主代理因方便、速度、空闲槽或历史习惯自行降级,并要求 Artificer 恢复后立即停止原生扩展并交接,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R7_Task_Report.md)。 + +## R8 [in_progress] + +解决 [UniDesk #2103](https://github.com/pikasTech/unidesk/issues/2103):将 `agentrun events` 的主工作流改为保留 `--after-seq` 分片游标并使用 `--expect `、`--timeout ` 的 manager/API 长轮询;达到期望新增事件数立即返回,超时返回已观察事件及明确 timeout/next cursor,禁止 CLI 本地高频忙轮询或无输出,并同步 CLI、manager、测试与 `unidesk-code-queue` skill 文档,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R8_Task_Report.md)。 From 968ffa3ecf2ed095be1e004fce796ba36e7e217f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 05:47:52 +0200 Subject: [PATCH 02/26] feat(agentrun): add events long polling --- .agents/skills/unidesk-code-queue/SKILL.md | 9 +- .../references/resources.md | 9 +- scripts/src/agentrun-events-long-poll.test.ts | 185 ++++++++++++++++++ scripts/src/agentrun/config.ts | 1 + scripts/src/agentrun/entry.ts | 9 +- scripts/src/agentrun/render.ts | 11 ++ scripts/src/agentrun/resource-actions.ts | 74 ++++++- scripts/src/agentrun/rest-bridge.ts | 24 ++- scripts/src/agentrun/utils.ts | 2 + 9 files changed, 307 insertions(+), 17 deletions(-) create mode 100644 scripts/src/agentrun-events-long-poll.test.ts diff --git a/.agents/skills/unidesk-code-queue/SKILL.md b/.agents/skills/unidesk-code-queue/SKILL.md index fcf551fb..fdfd19aa 100644 --- a/.agents/skills/unidesk-code-queue/SKILL.md +++ b/.agents/skills/unidesk-code-queue/SKILL.md @@ -14,7 +14,7 @@ bun scripts/cli.ts agentrun get tasks --limit 20 bun scripts/cli.ts agentrun describe task/ bun scripts/cli.ts agentrun describe task/ --input -o json bun scripts/cli.ts agentrun describe aipodspec/ -bun scripts/cli.ts agentrun events run/ --limit 20 +bun scripts/cli.ts agentrun events run/ --after-seq 0 --expect 10 --timeout 120s --limit 20 bun scripts/cli.ts agentrun events run/ --detail-seq -o json bun scripts/cli.ts agentrun logs session/ --tail 120 bun scripts/cli.ts agentrun result run/ --command @@ -53,6 +53,13 @@ AipodSpec/Artificer、task manifest 和 queue 渐进披露见 [references/resour - `unidesk-trans` required skill、runner tools bundle、`unidesk-ssh` SecretRef 和 endpoint presence 缺一项即归为装配失败; - Secret 输出只显示对象、key、presence/fingerprint 与 `valuesPrinted=false`。 - 默认输出保持低噪声表格/摘要;机器消费显式 `-o json|yaml` 或 `--raw`。 +- 主代理观察 active run 时默认使用单次长轮询: + - `--after-seq` 是可继续拉取的增量游标; + - `--expect ` 是等待条件; + - `--timeout ` 默认 `120s`,达到 `expect` 或目标 run/command 终态时提前返回; + - timeout 到期返回当前事件,即使 0 条也明确披露 `timedOut`、`observed` 和 `nextAfterSeq`; + - `--limit` 只控制输出预算,不得承担等待条件; + - CLI 只发起一个 manager-held 请求,禁止本地高频短轮询或忙轮询。 - 读取 task 的可重建输入: - 使用 `agentrun describe task/ --input -o json`; - 该入口只投影 task create 字段、AipodSpec identity、资源引用和脱敏 provenance; diff --git a/.agents/skills/unidesk-code-queue/references/resources.md b/.agents/skills/unidesk-code-queue/references/resources.md index 514937ea..3b6c0f99 100644 --- a/.agents/skills/unidesk-code-queue/references/resources.md +++ b/.agents/skills/unidesk-code-queue/references/resources.md @@ -62,6 +62,13 @@ Artificer 内部执行边界: `agentrun events` 是高频渐进披露入口: +- active run 的推荐观察入口是: + - `bun scripts/cli.ts agentrun events run/ --after-seq --expect 10 --timeout 120s --limit 20`; + - manager 在单个请求内等待 after-seq 后新增事件达到 `expect`,或目标 run/command 进入终态; + - 未达到条件时最多等待 `timeout`,默认 `120s`; + - timeout 的部分返回和空返回都必须明确披露 `timedOut`、`observed` 与 `nextAfterSeq`; + - 后续继续使用返回的 `nextAfterSeq`,不要恢复高频短轮询; + - `limit` 是独立输出预算,不能替代 `expect`; - 默认 human、`-o json` 和 `-o yaml` 最多返回 20 条同构事件摘要,并用一条 lookahead 披露 `hasMore` 与 `nextAfterSeq`; - tool call 只展示 identity、seq/type/status、commandId、工具、命令首行、exit/duration、output bytes/hash、truncation 与 `outputOmitted`; - 默认不得展开 stdout、stderr、skill 正文或完整 payload,也不得通过提高全局 stdout 阈值掩盖 dump; @@ -72,7 +79,7 @@ Artificer 内部执行边界: - durable `resolutionAuthority` 保持原事实或显式脱敏,基于其他字段的非权威判断只进入独立 inference 字段; - 标准 Git URL 移除 userinfo、query 和 fragment,SCP-like 与未知 remote 只披露不可逆 fingerprint; - credential value 和 URL credential 固定脱敏,`valuesPrinted=false`; -- `--detail-seq` 与 `--after-seq`、`--limit`、`--full`、`--raw` 互斥,并在请求 manager 前 fail closed; +- `--detail-seq` 与 `--after-seq`、`--expect`、`--timeout`、`--limit`、`--full`、`--raw` 互斥,并在请求 manager 前 fail closed; - 完整事件继续使用显式 `--full`、`--raw`,或按 `afterSeq=item.seq-1 --limit 1 --full` 下钻; - 该 CLI 只渲染 AgentRun durable facts,不删除事件、不按正文去重,也不改变 Kafka/SSE 事件链。 diff --git a/scripts/src/agentrun-events-long-poll.test.ts b/scripts/src/agentrun-events-long-poll.test.ts new file mode 100644 index 00000000..9b421f2f --- /dev/null +++ b/scripts/src/agentrun-events-long-poll.test.ts @@ -0,0 +1,185 @@ +import { rmSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; + +import { runAgentRunCommand } from "./agentrun"; + +const RUN_ID = "run_long_poll_fixture"; +const agentRunClientYaml = [ + "version: 1", + "kind: AgentRunConfig", + "metadata:", + " name: agentrun", + "manager:", + " baseUrl: __BASE_URL__", + " timeoutMs: 15000", + "auth:", + " env: HWLAB_API_KEY", + " file: /tmp/hwlab-api-key.env", + " header: Authorization", + " scheme: Bearer", + "client:", + " role: render-only", + " transport: direct-http", + " sessionPolicy:", + " tenantId: unidesk", + " projectId: test", + " providerId: NC01", + " backendProfile: codex", + " workspaceRef:", + " kind: opaque", + " executionPolicy:", + " sandbox: workspace-write", + " approval: never", + " timeoutMs: 7200000", + " network: enabled", + " secretScope:", + " allowCredentialEcho: false", +].join("\n"); + +function renderedText(value: Awaited>): string { + if (!("renderedText" in value) || typeof value.renderedText !== "string") throw new Error("expected rendered AgentRun CLI output"); + return value.renderedText; +} + +function event(seq: number): Record { + return { + id: `evt_${seq}`, + seq, + type: "backend_status", + payload: { commandId: "cmd_fixture", phase: `phase-${seq}`, message: `event-${seq}` }, + }; +} + +async function withManager( + handler: (request: Request) => Response | Promise, + action: () => Promise, +): Promise { + const server = Bun.serve({ port: 0, fetch: handler }); + const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG; + const previousKey = process.env.HWLAB_API_KEY; + const configPath = `/tmp/unidesk-agentrun-events-long-poll-${process.pid}-${Date.now()}.yaml`; + process.env.AGENTRUN_CLIENT_CONFIG = configPath; + process.env.HWLAB_API_KEY = "fixture-secret"; + await Bun.write(configPath, agentRunClientYaml.replace("__BASE_URL__", server.url.href.replace(/\/$/u, ""))); + try { + await action(); + } finally { + server.stop(true); + rmSync(configPath, { force: true }); + if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG; + else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig; + if (previousKey === undefined) delete process.env.HWLAB_API_KEY; + else process.env.HWLAB_API_KEY = previousKey; + } +} + +describe.serial("AgentRun events manager-held long polling", () => { + test("forwards default 120s timeout and returns as soon as expect is reached", async () => { + let requests = 0; + await withManager(async (request) => { + requests += 1; + const url = new URL(request.url); + expect(url.pathname).toBe(`/api/v1/runs/${RUN_ID}/events`); + expect(url.searchParams.get("afterSeq")).toBe("100"); + expect(url.searchParams.get("expect")).toBe("2"); + expect(url.searchParams.get("timeoutMs")).toBe("120000"); + expect(url.searchParams.get("limit")).toBe("21"); + await Promise.all([Bun.sleep(10), Bun.sleep(20)]); + return Response.json({ + ok: true, + data: { + items: [event(101), event(102)], + timedOut: false, + observed: 2, + nextAfterSeq: 102, + completionReason: "expect-reached", + }, + }); + }, async () => { + const result = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--after-seq", "100", "--expect", "2", "-o", "json"]); + expect(result.ok).toBe(true); + const payload = JSON.parse(renderedText(result)) as Record; + expect(payload).toMatchObject({ count: 2, expect: 2, timeoutMs: 120000, timedOut: false, observed: 2, nextAfterSeq: 102, completionReason: "expect-reached" }); + expect(requests).toBe(1); + }); + }); + + test("renders partial and empty timeout responses with an explicit cursor", async () => { + for (const fixture of [ + { items: [event(201)], observed: 1, nextAfterSeq: 201 }, + { items: [], observed: 0, nextAfterSeq: 200 }, + ]) { + await withManager((request) => { + const url = new URL(request.url); + expect(url.searchParams.get("timeoutMs")).toBe("3000"); + return Response.json({ ok: true, data: { ...fixture, timedOut: true, completionReason: "timeout" } }); + }, async () => { + const result = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--after-seq", "200", "--expect", "10", "--timeout", "3s", "-o", "json"]); + const payload = JSON.parse(renderedText(result)) as Record; + expect(payload).toMatchObject({ timedOut: true, observed: fixture.observed, nextAfterSeq: fixture.nextAfterSeq, completionReason: "timeout" }); + }); + } + }); + + test("returns concurrent event arrivals in one manager request", async () => { + let requests = 0; + await withManager(async () => { + requests += 1; + const arrivals: Record[] = []; + await Promise.all([ + Bun.sleep(10).then(() => arrivals.push(event(301))), + Bun.sleep(20).then(() => arrivals.push(event(302))), + ]); + return Response.json({ ok: true, data: { items: arrivals, timedOut: false, observed: 2, nextAfterSeq: 302, completionReason: "expect-reached" } }); + }, async () => { + const result = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--after-seq", "300", "--expect", "2", "--timeout", "1m", "-o", "json"]); + const payload = JSON.parse(renderedText(result)) as { items?: Array<{ seq?: number }> }; + expect(payload.items?.map((item) => item.seq)).toEqual([301, 302]); + expect(requests).toBe(1); + }); + }); + + test("returns immediately when the target is terminal before expect is reached", async () => { + await withManager(() => Response.json({ + ok: true, + data: { + items: [], + timedOut: false, + observed: 0, + nextAfterSeq: 400, + completionReason: "target-completed", + }, + }), async () => { + const result = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--after-seq", "400", "--expect", "10", "--timeout", "120s", "-o", "json"]); + const payload = JSON.parse(renderedText(result)) as Record; + expect(payload).toMatchObject({ timedOut: false, observed: 0, nextAfterSeq: 400, completionReason: "target-completed" }); + }); + }); + + test("rejects invalid duration before request and preserves manager errors", async () => { + let requests = 0; + await withManager(() => { + requests += 1; + return Response.json({ ok: false, failureKind: "manager-unavailable", message: "event notifier unavailable" }, { status: 503 }); + }, async () => { + const invalid = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--expect", "2", "--timeout", "3minutes", "-o", "json"]); + expect(invalid.ok).toBe(false); + expect(renderedText(invalid)).toContain("--timeout must use an integer duration"); + expect(requests).toBe(0); + + const failed = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--expect", "2", "--timeout", "1s", "-o", "json"]); + expect(failed.ok).toBe(false); + expect(renderedText(failed)).toContain("event notifier unavailable"); + expect(requests).toBe(1); + }); + }); + + test("fails visibly when a manager ignores the long-poll response contract", async () => { + await withManager(() => Response.json({ ok: true, data: { items: [], nextAfterSeq: 500 } }), async () => { + const result = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--after-seq", "500", "--expect", "2", "--timeout", "1s", "-o", "json"]); + expect(result.ok).toBe(false); + expect(renderedText(result)).toContain("schema-mismatch"); + expect(renderedText(result)).toContain("must include boolean timedOut and non-negative observed fields"); + }); + }); +}); diff --git a/scripts/src/agentrun/config.ts b/scripts/src/agentrun/config.ts index 8ca1c052..6c1a436c 100644 --- a/scripts/src/agentrun/config.ts +++ b/scripts/src/agentrun/config.ts @@ -371,6 +371,7 @@ export function agentRunQuery(args: string[], names: string[]): string { const params = new URLSearchParams(); const keyMap: Record = { "after-seq": "afterSeq", + "timeout-ms": "timeoutMs", "backend-profile": "backendProfile", "command": "commandId", "command-id": "commandId", diff --git a/scripts/src/agentrun/entry.ts b/scripts/src/agentrun/entry.ts index 3955fcde..71f2f599 100644 --- a/scripts/src/agentrun/entry.ts +++ b/scripts/src/agentrun/entry.ts @@ -59,7 +59,7 @@ export function agentRunHelp(): unknown { "bun scripts/cli.ts agentrun get tasks -o json", "bun scripts/cli.ts agentrun describe task/", "bun scripts/cli.ts agentrun describe task/ --input -o json", - "bun scripts/cli.ts agentrun events run/ --after-seq 0 --limit 100", + "bun scripts/cli.ts agentrun events run/ --after-seq 0 --expect 10 --timeout 120s --limit 100", "bun scripts/cli.ts agentrun events run/ --detail-seq ", "bun scripts/cli.ts agentrun logs session/ --tail 100", "bun scripts/cli.ts agentrun result run/ --command ", @@ -292,9 +292,10 @@ export function agentRunHelpText(args: string[]): string { } if (verb === "events") { return [ - "Usage: bun scripts/cli.ts agentrun events run/ [--after-seq N] [--limit 100] [-o json|yaml] [--full|--raw]", + "Usage: bun scripts/cli.ts agentrun events run/ [--after-seq N] [--expect N [--timeout 120s]] [--limit 100] [-o json|yaml] [--full|--raw]", " bun scripts/cli.ts agentrun events run/ --detail-seq [-o json|yaml]", "", + "--expect waits in one manager-held request until N new events, target completion, or timeout; --timeout defaults to 120s. --limit remains an independent output budget.", "--detail-seq returns one bounded Secret-safe EventDetail projection. Use the separate --after-seq --limit 1 --full|--raw path only for explicit complete disclosure.", ].join("\n"); } @@ -390,7 +391,7 @@ export function agentRunHelpText(args: string[]): string { " bun scripts/cli.ts agentrun get tasks --queue commander --limit 20", " bun scripts/cli.ts agentrun describe task/", " bun scripts/cli.ts agentrun describe run/ --node NC01 --lane nc01-v02", - " bun scripts/cli.ts agentrun events run/ --after-seq 0 --limit 100", + " bun scripts/cli.ts agentrun events run/ --after-seq 0 --expect 10 --timeout 120s --limit 100", " bun scripts/cli.ts agentrun logs session/ --tail 100", "", "Use --raw on a resource command when you need the direct AgentRun REST envelope.", @@ -407,7 +408,7 @@ export function agentRunHelpText(args: string[]): string { " bun scripts/cli.ts agentrun describe task/", " bun scripts/cli.ts agentrun describe task/ --input -o json", " bun scripts/cli.ts agentrun describe run/ --node NC01 --lane nc01-v02", - " bun scripts/cli.ts agentrun events run/ --after-seq 0 --limit 100", + " bun scripts/cli.ts agentrun events run/ --after-seq 0 --expect 10 --timeout 120s --limit 100", " bun scripts/cli.ts agentrun events run/ --detail-seq ", " bun scripts/cli.ts agentrun logs session/ --tail 100", " bun scripts/cli.ts agentrun retry task/ --idempotency-key --reason --dry-run", diff --git a/scripts/src/agentrun/render.ts b/scripts/src/agentrun/render.ts index 452c641c..98a174d9 100644 --- a/scripts/src/agentrun/render.ts +++ b/scripts/src/agentrun/render.ts @@ -360,6 +360,13 @@ export interface EventLikePageDisclosure { hasMore: boolean; nextAfterSeq: number | string | null; detailCommand: string; + wait: { + expect: number; + timeoutMs: number; + timedOut: boolean; + observed: number; + completionReason: string | null; + } | null; } export function renderEventLike(command: string, raw: Record, options: AgentRunResourceOptions, kind: string, items: Record[], sourceName: string, pageDisclosure: EventLikePageDisclosure | null = null): RenderedCliResult { @@ -376,6 +383,7 @@ export function renderEventLike(command: string, raw: Record, o effectiveLimit: pageDisclosure.effectiveLimit, fetchedCount: pageDisclosure.fetchedCount, hasMore: pageDisclosure.hasMore, + ...(pageDisclosure.wait === null ? {} : pageDisclosure.wait), disclosure: { output: "omitted; metadata only", identity: "item.identity; detail --detail-seq item.seq", @@ -415,6 +423,9 @@ export function renderEventLike(command: string, raw: Record, o ])), ]; if (pageDisclosure !== null) lines.push(`Detail: ${pageDisclosure.detailCommand}`); + if (pageDisclosure?.wait !== null && pageDisclosure?.wait !== undefined) { + lines.push(`Wait: expect=${pageDisclosure.wait.expect} observed=${pageDisclosure.wait.observed} timedOut=${String(pageDisclosure.wait.timedOut)} timeoutMs=${pageDisclosure.wait.timeoutMs} nextAfterSeq=${String(nextAfterSeq ?? 0)}${pageDisclosure.wait.completionReason === null ? "" : ` reason=${pageDisclosure.wait.completionReason}`}`); + } if (nextAfterSeq !== undefined && nextAfterSeq !== null) lines.push(`Next: bun scripts/cli.ts ${nextPagedResourceCommand(command, String(nextAfterSeq), options.limit)}`); return renderedCliResult(raw.ok !== false, command, lines.join("\n")); } diff --git a/scripts/src/agentrun/resource-actions.ts b/scripts/src/agentrun/resource-actions.ts index 93931491..ac0458e1 100644 --- a/scripts/src/agentrun/resource-actions.ts +++ b/scripts/src/agentrun/resource-actions.ts @@ -178,6 +178,8 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions { commandId: null, sessionId: null, afterSeq: null, + expect: null, + timeoutMs: 120_000, eventDetailSeq: null, tail: null, fullText: false, @@ -196,7 +198,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions { mdtodoId: null, passthroughArgs: [], }; - const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--idempotency-key", "--node", "--lane", "--target", "--target-workspace", "--repo", "--ref", "--mdtodo-id"]); + const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--expect", "--timeout", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--idempotency-key", "--node", "--lane", "--target", "--target-workspace", "--repo", "--ref", "--mdtodo-id"]); const booleanFlags = new Set(["--full", "--raw", "--debug", "--input", "--unread", "--dry-run", "--full-text", "--prompt-stdin", "--stdin"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; @@ -243,7 +245,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions { ); } if (options.eventDetailSeq !== null) { - const conflictingOptions = ["--after-seq", "--limit", "--full", "--raw"] + const conflictingOptions = ["--after-seq", "--expect", "--timeout", "--limit", "--full", "--raw"] .filter((flag) => resourceOptionPresent(args, flag)); if (conflictingOptions.length > 0) { throw new AgentRunRestError( @@ -253,7 +255,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions { details: { conflictingOptions: ["--detail-seq", ...conflictingOptions], recoveryActions: [ - "Use --detail-seq without --after-seq, --limit, --full, or --raw for the bounded Secret-safe EventDetail projection.", + "Use --detail-seq without --after-seq, --expect, --timeout, --limit, --full, or --raw for the bounded Secret-safe EventDetail projection.", "Use --after-seq --limit 1 --full -o json or --raw only when explicitly requesting the complete durable event.", ], valuesPrinted: false, @@ -262,6 +264,18 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions { ); } } + if (resourceOptionPresent(args, "--timeout") && options.expect === null) { + throw new AgentRunRestError( + "validation-failed", + "--timeout requires --expect ; timeout controls the manager-held long poll and is not a client-side polling interval.", + { + details: { + recoveryActions: ["Use --expect [--timeout ] or remove --timeout for an immediate paged read."], + valuesPrinted: false, + }, + }, + ); + } return options; } @@ -278,6 +292,18 @@ function validateResourceOptionsForVerb(verb: AgentRunResourceVerb, options: Age }, ); } + if (options.expect !== null && verb !== "events") { + throw new AgentRunRestError( + "validation-failed", + "--expect and --timeout are supported only by agentrun events run/.", + { + details: { + recoveryActions: ["Use: bun scripts/cli.ts agentrun events run/ --after-seq --expect [--timeout 120s]."], + valuesPrinted: false, + }, + }, + ); + } } function resourceOptionPresent(args: string[], flag: string): boolean { @@ -308,6 +334,8 @@ export function applyResourceOption(options: AgentRunResourceOptions, flag: stri else if (flag === "--command" || flag === "--command-id") options.commandId = requiredValue(value, flag); else if (flag === "--session" || flag === "--session-id") options.sessionId = requiredValue(value, flag); else if (flag === "--after-seq") options.afterSeq = parseNonNegativeInt(value, "--after-seq", 0, Number.MAX_SAFE_INTEGER); + else if (flag === "--expect") options.expect = parsePositiveInt(value, "--expect"); + else if (flag === "--timeout") options.timeoutMs = parseDurationMs(value, "--timeout"); else if (flag === "--detail-seq") options.eventDetailSeq = parsePositiveInt(value, "--detail-seq"); else if (flag === "--tail") options.tail = parseNonNegativeInt(value, "--tail", 100, 1000); else if (flag === "--reason") options.reason = requiredValue(value, flag); @@ -330,6 +358,17 @@ function parsePositiveInt(raw: string | null, flag: string): number { return value; } +export function parseDurationMs(raw: string | null, flag: string): number { + if (raw === null || raw.length === 0) throw new Error(`${flag} requires a duration`); + const match = /^(\d+)(ms|s|m|h)$/u.exec(raw.trim()); + if (match === null) throw new Error(`${flag} must use an integer duration such as 500ms, 120s, 3m, or 1h`); + const amount = Number(match[1]); + const multiplier = match[2] === "ms" ? 1 : match[2] === "s" ? 1_000 : match[2] === "m" ? 60_000 : 3_600_000; + const value = amount * multiplier; + if (!Number.isSafeInteger(value) || value < 1 || value > 86_400_000) throw new Error(`${flag} must be between 1ms and 24h`); + return value; +} + export function parseNonNegativeInt(raw: string | null, flag: string, defaultValue: number, maxValue: number): number { if (raw === null || raw.length === 0) return defaultValue; const value = Number(raw); @@ -607,6 +646,7 @@ export async function resourceEvents(config: UniDeskConfig | null, command: stri const effectiveLimit = options.full || options.raw ? requestedLimit : Math.min(requestedLimit, 20); const requestLimit = options.full || options.raw ? effectiveLimit : Math.min(500, effectiveLimit + 1); const eventArgs = ["events", runId, "--after-seq", String(options.afterSeq ?? 0), "--limit", String(requestLimit)]; + if (options.expect !== null) eventArgs.push("--expect", String(options.expect), "--timeout-ms", String(options.timeoutMs)); const result = await runAgentRunRestCommand(config, "runs", eventArgs); if (options.raw) return renderMachine(command, result, "json", result.ok !== false); if (options.full) return renderMachine(command, result, options.output === "yaml" ? "yaml" : "json", result.ok !== false); @@ -616,7 +656,32 @@ export async function resourceEvents(config: UniDeskConfig | null, command: stri const hasMore = fetchedItems.length > visibleItems.length; const lastVisibleSeq = visibleItems.length === 0 ? null : nonNegativeIntegerOrNull(visibleItems.at(-1)?.seq); const responseNextAfterSeq = nonNegativeIntegerOrNull(record(innerData(result)).nextAfterSeq); - const nextAfterSeq = hasMore ? lastVisibleSeq : responseNextAfterSeq; + const nextAfterSeq = hasMore ? lastVisibleSeq : responseNextAfterSeq ?? options.afterSeq ?? 0; + const waitData = record(innerData(result)); + const observed = nonNegativeIntegerOrNull(waitData.observed); + if (options.expect !== null && (typeof waitData.timedOut !== "boolean" || observed === null)) { + throw new AgentRunRestError( + "schema-mismatch", + "AgentRun manager long-poll response must include boolean timedOut and non-negative observed fields.", + { + details: { + runId, + expect: options.expect, + timeoutMs: options.timeoutMs, + returnedTimedOutType: typeof waitData.timedOut, + returnedObserved: waitData.observed ?? null, + valuesPrinted: false, + }, + }, + ); + } + const waitDisclosure = options.expect === null ? null : { + expect: options.expect, + timeoutMs: options.timeoutMs, + timedOut: waitData.timedOut === true, + observed: observed ?? fetchedItems.length, + completionReason: stringOrNull(waitData.completionReason) ?? stringOrNull(waitData.reason), + }; const targetArgs = [ options.node === null ? null : `--node ${options.node}`, options.lane === null ? null : `--lane ${options.lane}`, @@ -629,6 +694,7 @@ export async function resourceEvents(config: UniDeskConfig | null, command: stri hasMore, nextAfterSeq, detailCommand, + wait: waitDisclosure, }); } diff --git a/scripts/src/agentrun/rest-bridge.ts b/scripts/src/agentrun/rest-bridge.ts index cc260486..b59a5ce4 100644 --- a/scripts/src/agentrun/rest-bridge.ts +++ b/scripts/src/agentrun/rest-bridge.ts @@ -523,7 +523,16 @@ export async function runAgentRunSessionsRest(action: string | undefined, id: st export async function runAgentRunRunsRest(action: string | undefined, id: string | undefined, args: string[]): Promise> { if (action === "create") return await agentRunRestRequest("agentrun runs create", "POST", "/api/v1/runs", await requiredJsonBody(args, "runs create")); if (action === "show" && id) return await agentRunRestRequest("agentrun runs show", "GET", `/api/v1/runs/${encodeURIComponent(id)}`); - if (action === "events" && id) return await agentRunRestRequest("agentrun runs events", "GET", `/api/v1/runs/${encodeURIComponent(id)}/events${agentRunQuery(args, ["after-seq", "limit"])}`); + if (action === "events" && id) { + const timeoutMs = Number(agentRunOption(args, "timeout-ms") ?? "0"); + return await agentRunRestRequest( + "agentrun runs events", + "GET", + `/api/v1/runs/${encodeURIComponent(id)}/events${agentRunQuery(args, ["after-seq", "expect", "timeout-ms", "limit"])}`, + undefined, + Number.isSafeInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs + 5_000 : undefined, + ); + } if (action === "result" && id) return await agentRunRestRequest("agentrun runs result", "GET", `/api/v1/runs/${encodeURIComponent(id)}/result${agentRunQuery(args, ["command-id", "command"])}`); if (action === "cancel" && id) return await agentRunRestRequest("agentrun runs cancel", "POST", `/api/v1/runs/${encodeURIComponent(id)}/cancel`, cancelBodyFromArgs(args)); throw new AgentRunRestError("validation-failed", `unsupported runs command: ${[action, id].filter(Boolean).join(" ") || "(empty)"}`); @@ -1157,14 +1166,15 @@ export async function sessionRunnerJobBody(args: string[], defaults: Record> { +export async function agentRunRestRequest(command: string, method: AgentRunHttpMethod, pathValue: string, body?: unknown, requestTimeoutMs?: number): Promise> { const clientConfig = readAgentRunClientConfig(); - if (activeAgentRunRestTarget !== null) return await agentRunLaneRestRequest(command, method, pathValue, body, clientConfig, activeAgentRunRestTarget); + const effectiveTimeoutMs = Math.max(clientConfig.manager.timeoutMs, requestTimeoutMs ?? 0); + if (activeAgentRunRestTarget !== null) return await agentRunLaneRestRequest(command, method, pathValue, body, clientConfig, activeAgentRunRestTarget, effectiveTimeoutMs); const auth = resolveAgentRunAuth(clientConfig); const bridgeBase = agentRunRestBridgeMetadata(clientConfig, auth, method, pathValue); const startedAt = Date.now(); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), clientConfig.manager.timeoutMs); + const timeout = setTimeout(() => controller.abort(), effectiveTimeoutMs); let response: Response; try { const headers: Record = { @@ -1178,7 +1188,7 @@ export async function agentRunRestRequest(command: string, method: AgentRunHttpM response = await fetch(new URL(pathValue, clientConfig.manager.baseUrl), init); } catch (error) { const timedOut = isAbortLikeError(error); - throw new AgentRunRestError(timedOut ? "agentrun-timeout" : "agentrun-connect-failed", timedOut ? `AgentRun server request timed out for ${method} ${pathValue} after ${clientConfig.manager.timeoutMs}ms` : `AgentRun server connection failed for ${method} ${pathValue}: ${error instanceof Error ? error.message : String(error)}`, { bridge: { ...bridgeBase, elapsedMs: Date.now() - startedAt } }); + throw new AgentRunRestError(timedOut ? "agentrun-timeout" : "agentrun-connect-failed", timedOut ? `AgentRun server request timed out for ${method} ${pathValue} after ${effectiveTimeoutMs}ms` : `AgentRun server connection failed for ${method} ${pathValue}: ${error instanceof Error ? error.message : String(error)}`, { bridge: { ...bridgeBase, elapsedMs: Date.now() - startedAt } }); } finally { clearTimeout(timeout); } @@ -1215,7 +1225,7 @@ export function isAbortLikeError(error: unknown): boolean { return error.name === "AbortError" || /abort|timed out|timeout/iu.test(error.message); } -export async function agentRunLaneRestRequest(command: string, method: AgentRunHttpMethod, pathValue: string, body: unknown, clientConfig: AgentRunClientConfig, target: AgentRunRestTarget): Promise> { +export async function agentRunLaneRestRequest(command: string, method: AgentRunHttpMethod, pathValue: string, body: unknown, clientConfig: AgentRunClientConfig, target: AgentRunRestTarget, requestTimeoutMs = clientConfig.manager.timeoutMs): Promise> { const bridgeBase = agentRunLaneRestBridgeMetadata(clientConfig, target, method, pathValue); const startedAt = Date.now(); const proxyBaseUrl = `http://127.0.0.1:${target.spec.runtime.managerPort}`; @@ -1225,7 +1235,7 @@ export async function agentRunLaneRestRequest(command: string, method: AgentRunH body: body ?? null, baseUrl: proxyBaseUrl, serviceBaseUrl: target.spec.runtime.internalBaseUrl, - timeoutMs: clientConfig.manager.timeoutMs, + timeoutMs: requestTimeoutMs, authEnv: clientConfig.auth.env, header: clientConfig.auth.header, scheme: clientConfig.auth.scheme, diff --git a/scripts/src/agentrun/utils.ts b/scripts/src/agentrun/utils.ts index 06f45530..b22068c7 100644 --- a/scripts/src/agentrun/utils.ts +++ b/scripts/src/agentrun/utils.ts @@ -76,6 +76,8 @@ export interface AgentRunResourceOptions { commandId: string | null; sessionId: string | null; afterSeq: number | null; + expect: number | null; + timeoutMs: number; eventDetailSeq: number | null; tail: number | null; fullText: boolean; From 82b48a00fafbeb7d324ea4b8a8cffb02fe4bc5ae Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 06:08:41 +0200 Subject: [PATCH 03/26] fix(agentrun): bound events timeout to 60m --- scripts/src/agentrun-events-long-poll.test.ts | 22 ++++++++++++++++++- scripts/src/agentrun/resource-actions.ts | 8 +++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/scripts/src/agentrun-events-long-poll.test.ts b/scripts/src/agentrun-events-long-poll.test.ts index 9b421f2f..97046357 100644 --- a/scripts/src/agentrun-events-long-poll.test.ts +++ b/scripts/src/agentrun-events-long-poll.test.ts @@ -164,7 +164,7 @@ describe.serial("AgentRun events manager-held long polling", () => { }, async () => { const invalid = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--expect", "2", "--timeout", "3minutes", "-o", "json"]); expect(invalid.ok).toBe(false); - expect(renderedText(invalid)).toContain("--timeout must use an integer duration"); + expect(renderedText(invalid)).toContain("--timeout must use a positive integer duration with ms, s, or m units"); expect(requests).toBe(0); const failed = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--expect", "2", "--timeout", "1s", "-o", "json"]); @@ -182,4 +182,24 @@ describe.serial("AgentRun events manager-held long polling", () => { expect(renderedText(result)).toContain("must include boolean timedOut and non-negative observed fields"); }); }); + + test("accepts 60m and rejects zero, values above 60m, and hour units before request", async () => { + let requests = 0; + await withManager((request) => { + requests += 1; + const url = new URL(request.url); + expect(url.searchParams.get("timeoutMs")).toBe("3600000"); + return Response.json({ ok: true, data: { items: [], timedOut: true, observed: 0, nextAfterSeq: 0, completionReason: "timeout" } }); + }, async () => { + const accepted = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--expect", "1", "--timeout", "60m", "-o", "json"]); + expect(accepted.ok).toBe(true); + expect(requests).toBe(1); + + for (const invalidDuration of ["0ms", "61m", "1h"]) { + const invalid = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--expect", "1", "--timeout", invalidDuration, "-o", "json"]); + expect(invalid.ok).toBe(false); + } + expect(requests).toBe(1); + }); + }); }); diff --git a/scripts/src/agentrun/resource-actions.ts b/scripts/src/agentrun/resource-actions.ts index ac0458e1..3bcf360f 100644 --- a/scripts/src/agentrun/resource-actions.ts +++ b/scripts/src/agentrun/resource-actions.ts @@ -360,12 +360,12 @@ function parsePositiveInt(raw: string | null, flag: string): number { export function parseDurationMs(raw: string | null, flag: string): number { if (raw === null || raw.length === 0) throw new Error(`${flag} requires a duration`); - const match = /^(\d+)(ms|s|m|h)$/u.exec(raw.trim()); - if (match === null) throw new Error(`${flag} must use an integer duration such as 500ms, 120s, 3m, or 1h`); + const match = /^(\d+)(ms|s|m)$/u.exec(raw.trim()); + if (match === null) throw new Error(`${flag} must use a positive integer duration with ms, s, or m units, such as 500ms, 120s, or 3m`); const amount = Number(match[1]); - const multiplier = match[2] === "ms" ? 1 : match[2] === "s" ? 1_000 : match[2] === "m" ? 60_000 : 3_600_000; + const multiplier = match[2] === "ms" ? 1 : match[2] === "s" ? 1_000 : 60_000; const value = amount * multiplier; - if (!Number.isSafeInteger(value) || value < 1 || value > 86_400_000) throw new Error(`${flag} must be between 1ms and 24h`); + if (!Number.isSafeInteger(value) || value < 1 || value > 3_600_000) throw new Error(`${flag} must be between 1ms and 60m`); return value; } From 3aebd2561c0b87053ae84841e1785c5f63f997dd Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 07:01:43 +0200 Subject: [PATCH 04/26] =?UTF-8?q?feat(pikaoa):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=8E=AF=E5=A2=83=E8=87=AA=E5=8A=A8=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E6=8E=A7=E5=88=B6=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/fixtures/pikaoa-test-target.yaml | 43 +- config/pikaoa.yaml | 49 ++- config/platform-infra/gitea.yaml | 50 ++- config/platform-infra/pipelines-as-code.yaml | 78 +++- docs/reference/pikaoa.md | 16 +- .../src/pikaoa-test-delivery-renderer.test.ts | 93 ++++ scripts/src/pikaoa-test-delivery-renderer.ts | 360 ++++++++++++++++ scripts/src/pikaoa-test-runtime-manifest.ts | 254 +++++++++++ scripts/src/pikaoa-test-target-async.test.ts | 54 +-- scripts/src/pikaoa-test-target.ts | 407 ++++++++---------- ...-infra-pipelines-as-code-bootstrap.test.ts | 8 +- ...-pipelines-as-code-source-artifact.test.ts | 43 ++ ...infra-pipelines-as-code-source-artifact.ts | 55 ++- .../src/platform-infra-pipelines-as-code.ts | 9 +- 14 files changed, 1202 insertions(+), 317 deletions(-) create mode 100644 scripts/src/pikaoa-test-delivery-renderer.test.ts create mode 100644 scripts/src/pikaoa-test-delivery-renderer.ts create mode 100644 scripts/src/pikaoa-test-runtime-manifest.ts diff --git a/config/fixtures/pikaoa-test-target.yaml b/config/fixtures/pikaoa-test-target.yaml index 37b81d12..30bfa952 100644 --- a/config/fixtures/pikaoa-test-target.yaml +++ b/config/fixtures/pikaoa-test-target.yaml @@ -30,12 +30,48 @@ testRuntime: mode: prebuilt-images repository: pikainc/pikaoa commitPolicy: cli-required + branch: master + worktreeRemote: https://github.com/pikainc/pikaoa.git + readUrl: https://gitea.example.invalid/mirrors/pikainc-pikaoa.git + snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-test01 images: api: registry.invalid/pikaoa/api:${commit} worker: registry.invalid/pikaoa/worker:${commit} web: registry.invalid/pikaoa/web:${commit} - migration: registry.invalid/pikaoa/migration:${commit} + initializer: registry.invalid/pikaoa/api:${commit} pullPolicy: IfNotPresent + ci: + lane: pikaoa-test + namespace: pikaoa-ci + pipeline: pikaoa-test-test01-pac + pipelineRunPrefix: pikaoa-test-test01 + serviceAccountName: pikaoa-test-test01-tekton-runner + workspaceSize: 1Gi + pipelineTimeout: 20m0s + toolsImage: registry.invalid/tools:latest + buildkitImage: registry.invalid/buildkit:rootless + build: + dockerfiles: + api: deploy/api.Dockerfile + worker: deploy/worker.Dockerfile + web: deploy/web.Dockerfile + images: + api: registry.invalid/pikaoa/api + worker: registry.invalid/pikaoa/worker + web: registry.invalid/pikaoa/web + networkMode: host + gitops: + readUrl: https://gitea.example.invalid/mirrors/pikainc-pikaoa.git + writeUrl: https://gitea.example.invalid/mirrors/pikainc-pikaoa.git + branch: test01-pikaoa-test-gitops + manifestPath: deploy/gitops/test01-test/resources.yaml + credential: + secretName: pac-gitea-pikaoa-test-test01 + tokenKey: token + username: unidesk-admin + author: + name: PikaOA Test Fixture + email: pikaoa-test-fixture@unidesk.local database: configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url name: pikaoa_test @@ -82,8 +118,9 @@ testRuntime: sourceRef: ./secrets/pikaoa-test.env sourceKey: PIKAOA_SESSION_SECRET targetKey: PIKAOA_SESSION_SECRET - migration: - jobName: pikaoa-migrate + initializer: + mode: fresh-database-only + jobName: pikaoa-init backoffLimit: 2 command: - /usr/local/bin/pikaoa diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index 78698329..c4f4f167 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -21,7 +21,7 @@ modules: - audit extensionContract: registration: module-descriptor - persistence: schema-owned-migrations + persistence: schema-owned-initialization api: versioned-http-routes events: transactional-outbox workers: named-consumer @@ -49,12 +49,48 @@ testRuntime: mode: prebuilt-images repository: pikainc/pikaoa commitPolicy: cli-required + branch: master + worktreeRemote: https://github.com/pikainc/pikaoa.git + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 images: api: 127.0.0.1:5000/pikaoa/api:${commit} worker: 127.0.0.1:5000/pikaoa/worker:${commit} web: 127.0.0.1:5000/pikaoa/web:${commit} - migration: 127.0.0.1:5000/pikaoa/api:${commit} + initializer: 127.0.0.1:5000/pikaoa/api:${commit} pullPolicy: IfNotPresent + ci: + lane: pikaoa-test + namespace: pikaoa-ci + pipeline: pikaoa-test-nc01-pac + pipelineRunPrefix: pikaoa-test-nc01 + serviceAccountName: pikaoa-test-nc01-tekton-runner + workspaceSize: 8Gi + pipelineTimeout: 20m0s + toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + buildkitImage: 127.0.0.1:5000/hwlab/buildkit:rootless + build: + dockerfiles: + api: deploy/api.Dockerfile + worker: deploy/worker.Dockerfile + web: deploy/web.Dockerfile + images: + api: 127.0.0.1:5000/pikaoa/api + worker: 127.0.0.1:5000/pikaoa/worker + web: 127.0.0.1:5000/pikaoa/web + networkMode: host + gitops: + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + writeUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + branch: nc01-pikaoa-test-gitops + manifestPath: deploy/gitops/nc01-test/resources.yaml + credential: + secretName: pac-gitea-pikaoa-test-nc01 + tokenKey: token + username: unidesk-admin + author: + name: PikaOA Test NC01 CI + email: pikaoa-test-nc01-ci@unidesk.local database: configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url name: pikaoa_test @@ -101,8 +137,9 @@ testRuntime: sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-test.env sourceKey: PIKAOA_SESSION_SECRET targetKey: PIKAOA_SESSION_SECRET - migration: - jobName: pikaoa-migrate + initializer: + mode: fresh-database-only + jobName: pikaoa-init backoffLimit: 2 command: - /usr/local/bin/pikaoa @@ -158,9 +195,9 @@ delivery: source: repository: pikainc/pikaoa worktreeRemote: https://github.com/pikainc/pikaoa.git - branch: master + branch: release readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git - snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 + snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01 build: dockerfiles: api: deploy/api.Dockerfile diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 1e72523e..25e15eba 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -446,7 +446,7 @@ sourceAuthority: naming: gitea-actions-immutable-source prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-release-nc01 legacyGitMirror: null - - key: pikaoa-nc01 + - key: pikaoa-test-nc01 targetId: NC01 credentialOverride: github: @@ -486,12 +486,58 @@ sourceAuthority: publicRead: false readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git gitops: - branch: nc01-pikaoa-gitops + branch: nc01-pikaoa-test-gitops flushDisposition: gitea-writeback snapshot: naming: gitea-actions-immutable-source prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 legacyGitMirror: null + - key: pikaoa-nc01 + targetId: NC01 + credentialOverride: + github: + transport: https-token + sourceRef: pikainc-selfmedia-gh-token.txt + sourceKey: GH_TOKEN + format: raw-token + requiredFor: + - upstream-mirror + - mirror-sync + - managed-repository-fetch + - github-head-observe + - github-hooks-list + - github-hooks-reconcile + permissions: + contents: read + metadata: read + webhooks: read-write + gitFetchCredential: + apiVersion: unidesk.ai/v1 + kind: GitFetchCredential + authMode: github-https-token + host: github.com + secretRef: + namespace: devops-infra + name: gitea-github-sync-secrets + key: github-token-pikainc-pikaoa + upstream: + repository: pikainc/pikaoa + cloneUrl: https://github.com/pikainc/pikaoa.git + branch: release + visibility: private + gitea: + owner: mirrors + name: pikainc-pikaoa + mirrorMode: controlled-push + publicRead: false + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + gitops: + branch: nc01-pikaoa-gitops + flushDisposition: gitea-writeback + snapshot: + naming: gitea-actions-immutable-source + prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01 + legacyGitMirror: null targets: - id: JD01 diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index cea135da..8436c436 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -213,15 +213,15 @@ repositories: runtime_service_port: "4317" health_path: /healthz health_url: http://selfmedia.selfmedia.svc.cluster.local:4317/healthz - - id: pikaoa-nc01 - name: pikaoa-nc01 + - id: pikaoa-test-nc01 + name: pikaoa-test-nc01 namespace: pikaoa-ci providerType: gitea url: https://gitea.pikapython.com/mirrors/pikainc-pikaoa cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git owner: mirrors repo: pikainc-pikaoa - secretName: pac-gitea-pikaoa-nc01 + secretName: pac-gitea-pikaoa-test-nc01 tokenKey: token webhookSecretKey: webhook.secret concurrencyLimit: 1 @@ -230,9 +230,9 @@ repositories: source_branch: master source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 node: NC01 - pipeline_name: pikaoa-nc01-pac - pipeline_run_prefix: pikaoa-nc01 - service_account: pikaoa-nc01-tekton-runner + pipeline_name: pikaoa-test-nc01-pac + pipeline_run_prefix: pikaoa-test-nc01 + service_account: pikaoa-test-nc01-tekton-runner pipeline_timeout: 20m0s workspace_pvc_size: 8Gi image_repository: 127.0.0.1:5000/pikaoa/api @@ -242,16 +242,16 @@ repositories: registry_probe_base: http://127.0.0.1:5000 gitops_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git gitops_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git - gitops_branch: nc01-pikaoa-gitops + gitops_branch: nc01-pikaoa-test-gitops gitops_username: unidesk-admin - gitops_secret_name: pac-gitea-pikaoa-nc01 - gitops_manifest_path: deploy/gitops/nc01/resources.yaml - runtime_namespace: pikaoa + gitops_secret_name: pac-gitea-pikaoa-test-nc01 + gitops_manifest_path: deploy/gitops/nc01-test/resources.yaml + runtime_namespace: pikaoa-test runtime_deployment: pikaoa-api runtime_service: pikaoa-web runtime_service_port: "8080" health_path: /healthz - health_url: http://pikaoa-web.pikaoa.svc.cluster.local:8080/healthz + health_url: http://pikaoa-web.pikaoa-test.svc.cluster.local:8080/healthz consumers: - extends: templates.consumers.agentrunV02 variables: @@ -467,8 +467,49 @@ consumers: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet fsGroup: 1000 + - id: pikaoa-test-nc01 + repositoryRef: pikaoa-test-nc01 + node: NC01 + lane: pikaoa-test + namespace: pikaoa-ci + pipeline: pikaoa-test-nc01-pac + pipelineRunPrefix: pikaoa-test-nc01 + argoNamespace: argocd + argoApplication: pikaoa-test-nc01 + closeoutGitOpsMirrorFlush: false + argoBootstrap: + project: default + repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + targetRevision: nc01-pikaoa-test-gitops + path: deploy/gitops/nc01-test + destinationNamespace: pikaoa-test + automated: true + repositoryCredential: + secretName: argocd-repo-pikaoa-test-nc01 + username: unidesk-admin + deliveryProvenance: + required: true + markerValue: admission-pac-v2:pikaoa-test-nc01 + executionServiceAccountName: pikaoa-test-nc01-tekton-runner + gitOps: + repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + targetRevision: nc01-pikaoa-test-gitops + runnerServiceAccount: + name: pikaoa-test-nc01-tekton-runner + automountServiceAccountToken: false + roleBindingName: pikaoa-test-nc01-tekton-runner + sourceArtifact: + mode: embedded-pipeline-spec + renderer: pikaoa-test-runtime + configRef: config/pikaoa.yaml#testRuntime.targets.NC01 + pipelineRunPath: .tekton/pikaoa-test-nc01-pac.yaml + maxKeepRuns: 8 + taskRunTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + fsGroup: 1000 - id: pikaoa-nc01 - repositoryRef: pikaoa-nc01 + repositoryRef: pikaoa-test-nc01 node: NC01 lane: pikaoa namespace: pikaoa-ci @@ -477,6 +518,19 @@ consumers: argoNamespace: argocd argoApplication: pikaoa-nc01 closeoutGitOpsMirrorFlush: false + params: + source_branch: release + source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01 + pipeline_name: pikaoa-nc01-pac + pipeline_run_prefix: pikaoa-nc01 + service_account: pikaoa-nc01-tekton-runner + gitops_branch: nc01-pikaoa-gitops + gitops_secret_name: pac-gitea-pikaoa-nc01 + gitops_manifest_path: deploy/gitops/nc01/resources.yaml + runtime_namespace: pikaoa + runtime_deployment: pikaoa-api + runtime_service: pikaoa-web + health_url: http://pikaoa-web.pikaoa.svc.cluster.local:8080/healthz argoBootstrap: project: default repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git diff --git a/docs/reference/pikaoa.md b/docs/reference/pikaoa.md index c2f71fa0..a9888f20 100644 --- a/docs/reference/pikaoa.md +++ b/docs/reference/pikaoa.md @@ -78,7 +78,7 @@ 5. `secrets sync` 按 YAML 声明补齐允许生成的本地来源,并下发 `pikaoa-test-runtime`。 6. 使用同一 commit 依次执行: - - `--step migration`; + - `--step init`,仅初始化全新数据库; - `--step api`; - `--step worker`; - `--step web`。 @@ -87,7 +87,7 @@ ## 单步运行 - foundation 不需要 `--commit`,也不检查业务 Secret。 -- migration、api、worker、web 和 all 必须显式提供 `--commit`。 +- init、api、worker、web 和 all 必须显式提供 `--commit`。 - 每个 `start` 立即返回任务 ID;使用同一 target、instance 和 step 的 `status` 查询终态。 - request identity 包含: - target; @@ -108,19 +108,21 @@ - 提供新的 `--commit` 修订标识; - 修改 foundation 的稳定 manifest 结构。 - 不为重跑引入第二状态库、控制器、租约或长期历史存储。 -- 迁移、rollout 或运行面查询失败返回具名失败码,不静默进入下一阶段。 +- initializer、rollout 或运行面查询失败返回具名失败码,不静默进入下一阶段。 - OTel exporter、配置版本和 commit 对齐漂移只产生 `blocking=false` warning,不恢复 dedicated 门禁。 ## 交付阶段 -- 当前阶段先稳定 YAML-first CLI 原入口和固定 NC01 测试运行面。 -- CLI 闭环通过后,再建设独立 test CI/CD: - - 复用同一 owning YAML; - - 按 foundation、Secret sync、migration、api、worker、web 顺序编排; +- `pikaoa-test-nc01` 独立 test CI/CD 复用同一 owning YAML: + - 产品 `master` 只生成 `.tekton/pikaoa-test-nc01-pac.yaml`; + - API、Worker、Web 并行构建并以 digest 发布 GitOps; + - initializer 通过 Argo `PreSync` 在 API、Worker、Web 前初始化全新数据库; - 不创建专用测试集群; - 不生成动态 namespace; - 不创建 namespace-local PostgreSQL; - 不把测试 CI/CD 变成生产交付或用户业务的阻塞门禁。 +- GitOps 接管后,手动 `test-target start|stop` mutation 仅用于暂停 Argo 自动同步后的有界调试;禁止 CLI direct-apply 与 Argo 同时写入。 +- 生产 `pikaoa-nc01` 保留 `release` source authority,但当前不 bootstrap、不生成 `.tekton`、不创建 PipelineRun。 - PR、构建或单测不能替代固定 NodePort 原入口验收。 ## 本地验证 diff --git a/scripts/src/pikaoa-test-delivery-renderer.test.ts b/scripts/src/pikaoa-test-delivery-renderer.test.ts new file mode 100644 index 00000000..2585747e --- /dev/null +++ b/scripts/src/pikaoa-test-delivery-renderer.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { renderPikaoaTestDesiredPipeline } from "./pikaoa-test-delivery-renderer"; + +function pacDocument(): Record { + return Bun.YAML.parse(readFileSync(resolve(import.meta.dir, "../../config/platform-infra/pipelines-as-code.yaml"), "utf8")) as Record; +} + +function rendererBinding(): Parameters[0] { + const document = pacDocument(); + const repositories = document.repositories as Array>; + const consumers = document.consumers as Array>; + const repository = repositories.find((item) => item.id === "pikaoa-test-nc01"); + const consumer = consumers.find((item) => item.id === "pikaoa-test-nc01"); + if (repository === undefined || consumer === undefined) throw new Error("PikaOA test PaC fixture is required"); + return { consumer, repository } as Parameters[0]; +} + +function sourceFixture(): string { + const root = mkdtempSync(resolve(tmpdir(), "pikaoa-test-renderer-")); + mkdirSync(resolve(root, "deploy"), { recursive: true }); + for (const file of ["api.Dockerfile", "worker.Dockerfile", "web.Dockerfile"]) writeFileSync(resolve(root, "deploy", file), "FROM scratch\n"); + return root; +} + +describe("PikaOA test PaC renderer", () => { + test("keeps master test and release production consumers isolated", () => { + const document = pacDocument(); + const repositories = document.repositories as Array>; + const consumers = document.consumers as Array>; + const repository = repositories.find((item) => item.id === "pikaoa-test-nc01"); + const testConsumer = consumers.find((item) => item.id === "pikaoa-test-nc01"); + const productionConsumer = consumers.find((item) => item.id === "pikaoa-nc01"); + expect((repository?.params as Record).source_branch).toBe("master"); + expect((testConsumer?.sourceArtifact as Record).pipelineRunPath).toBe(".tekton/pikaoa-test-nc01-pac.yaml"); + expect((testConsumer?.sourceArtifact as Record).renderer).toBe("pikaoa-test-runtime"); + expect((productionConsumer?.params as Record).source_branch).toBe("release"); + expect((productionConsumer?.params as Record).source_snapshot_prefix).toBe("refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01"); + expect(productionConsumer?.sourceArtifact).toBeUndefined(); + }); + + test("renders parallel digest builds and a PreSync fresh database initializer", () => { + const source = sourceFixture(); + try { + const rendered = renderPikaoaTestDesiredPipeline(rendererBinding(), source); + const spec = rendered.pipeline.spec as Record; + const tasks = spec.tasks as Array>; + for (const name of ["build-api", "build-worker", "build-web"]) { + const task = tasks.find((item) => item.name === name); + expect(task?.runAfter).toEqual(["source"]); + expect(JSON.stringify(task)).toContain("buildctl-daemonless.sh build"); + expect(JSON.stringify(task)).toContain("$(params.revision)"); + expect(JSON.stringify(task)).not.toContain("source-token"); + } + const sourceTask = tasks.find((item) => item.name === "source"); + expect(JSON.stringify(sourceTask)).toContain("source-token"); + expect(JSON.stringify(sourceTask)).toContain("GIT_ASKPASS"); + expect(JSON.stringify(sourceTask)).toContain("$(params.gitops-secret-name)"); + const publish = tasks.find((item) => item.name === "gitops-publish"); + expect(publish?.runAfter).toEqual(["build-api", "build-worker", "build-web"]); + const script = ((((publish?.taskSpec as Record).steps as Array>)[0]?.script) as string); + for (const evidence of [ + '"status":"published"', + '"registryVerified":true', + '"publishedCount":3', + '"requiredServiceCount":3', + '"gitopsCommit"', + '"service":"api"', + '"service":"worker"', + '"service":"web"', + ]) expect(script).toContain(evidence); + const encoded = /printf '%s' '([A-Za-z0-9+/=]+)' \| base64 -d/u.exec(script)?.[1]; + if (encoded === undefined) throw new Error("embedded GitOps manifest is required"); + const manifest = Buffer.from(encoded, "base64").toString("utf8"); + expect(manifest).toContain("argocd.argoproj.io/hook: PreSync"); + expect(manifest).toContain("name: pikaoa-init"); + expect(manifest).toContain("name: initializer"); + expect(manifest).not.toContain("kind: Secret"); + expect(manifest).toContain("secretName: pikaoa-test-runtime"); + expect(manifest).toContain("image: __PIKAOA_API_IMAGE__"); + expect(manifest).toContain("pikaoa.unidesk.io/source-commit: __PIKAOA_SOURCE_COMMIT__"); + expect(script).toContain('s|__PIKAOA_SOURCE_COMMIT__|$SOURCE_COMMIT|g'); + expect(script).toContain("GitOps manifest contains unresolved provenance or image placeholders"); + expect(script).toContain("git ls-remote --exit-code --heads"); + expect(script).toContain("git -C /workspace/gitops checkout --orphan"); + } finally { + rmSync(source, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/src/pikaoa-test-delivery-renderer.ts b/scripts/src/pikaoa-test-delivery-renderer.ts new file mode 100644 index 00000000..9ff83392 --- /dev/null +++ b/scripts/src/pikaoa-test-delivery-renderer.ts @@ -0,0 +1,360 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +import { + pikaoaTestRuntimeConfigRef, + resolvePikaoaTestRuntimeTargetByConfigRef, + type TestTargetSpec, +} from "./pikaoa-test-target"; +import { renderPikaoaTestRuntimeManifest, type PikaoaTestRenderContext } from "./pikaoa-test-runtime-manifest"; +import { stableJsonSha256 } from "./stable-json"; + +export interface PikaoaTestRendererBinding { + readonly consumer: { + readonly id: string; + readonly node: string; + readonly lane: string; + readonly namespace: string; + readonly pipeline: string; + readonly pipelineRunPrefix: string; + readonly sourceArtifact: { + readonly configRef: string; + readonly mode: string; + readonly renderer: string; + }; + }; + readonly repository: { + readonly id: string; + readonly params: Readonly>; + }; +} + +export interface PikaoaTestRenderedPipeline { + readonly pipeline: Record; + readonly configRef: string; + readonly effectiveConfigSha256: string; + readonly sourceWorktreeRemote: string; +} + +export function renderPikaoaTestDesiredPipeline(binding: PikaoaTestRendererBinding, sourceWorktree: string): PikaoaTestRenderedPipeline { + const target = resolvePikaoaTestRuntimeTargetByConfigRef(binding.consumer.sourceArtifact.configRef); + const configRef = pikaoaTestRuntimeConfigRef(target.id); + if (binding.consumer.sourceArtifact.renderer !== "pikaoa-test-runtime" || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") { + throw new Error("pikaoa-test-runtime requires embedded-pipeline-spec"); + } + if (binding.consumer.sourceArtifact.configRef !== configRef) { + throw new Error(`sourceArtifact.configRef must equal resolved owning selector ${configRef}`); + } + assertBinding(target, binding); + assertSourceRepositoryContract(target, sourceWorktree); + return { + pipeline: pipeline(target), + configRef, + effectiveConfigSha256: stableJsonSha256(target), + sourceWorktreeRemote: target.source.worktreeRemote, + }; +} + +export function pikaoaTestSourceWorktreeRemote(targetId: string): string { + return resolvePikaoaTestRuntimeTargetByConfigRef(pikaoaTestRuntimeConfigRef(targetId)).source.worktreeRemote; +} + +function pipeline(target: TestTargetSpec): Record { + const runtimeManifest = renderGitOpsManifest(target); + return { + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { name: target.ci.pipeline, namespace: target.ci.namespace }, + spec: { + params: [ + { name: "revision", type: "string" }, + { name: "source-snapshot-prefix", type: "string", default: target.source.snapshotPrefix }, + { name: "git-read-url", type: "string", default: target.source.readUrl }, + { name: "gitops-read-url", type: "string", default: target.gitops.readUrl }, + { name: "gitops-write-url", type: "string", default: target.gitops.writeUrl }, + { name: "gitops-username", type: "string", default: target.gitops.credentialUsername }, + { name: "gitops-secret-name", type: "string", default: target.gitops.credentialSecretName }, + ], + workspaces: [{ name: "workspace" }], + tasks: [ + sourceTask(target), + imageBuildTask(target, "api"), + imageBuildTask(target, "worker"), + imageBuildTask(target, "web"), + gitOpsTask(target, runtimeManifest), + ], + }, + }; +} + +function sourceTask(target: TestTargetSpec): Record { + return { + name: "source", + taskSpec: { + params: [ + { name: "revision", type: "string" }, + { name: "source-snapshot-prefix", type: "string" }, + { name: "git-read-url", type: "string" }, + { name: "gitops-username", type: "string" }, + { name: "gitops-secret-name", type: "string" }, + ], + workspaces: [{ name: "workspace" }], + steps: [{ + name: "checkout", + image: target.ci.toolsImage, + imagePullPolicy: "IfNotPresent", + script: `#!/bin/sh +set -eu +revision="$(params.revision)" +case "$revision" in *[!0-9a-f]*|'') echo 'revision must be a full lowercase source commit' >&2; exit 2;; esac +test "\${#revision}" -eq 40 +askpass=/tmp/pikaoa-source-git-askpass +cat >"$askpass" <<'ASKPASS' +#!/bin/sh +case "$1" in + *Username*) printf '%s' "$PIKAOA_GIT_USERNAME" ;; + *Password*) cat /var/run/pikaoa-source/token ;; + *) exit 1 ;; +esac +ASKPASS +chmod 700 "$askpass" +export PIKAOA_GIT_USERNAME="$(params.gitops-username)" +export GIT_ASKPASS="$askpass" +export GIT_TERMINAL_PROMPT=0 +rm -rf /workspace/source +git init /workspace/source +git -C /workspace/source remote add origin "$(params.git-read-url)" +git -C /workspace/source fetch --depth=1 origin "$(params.source-snapshot-prefix)/$revision" +git -C /workspace/source checkout --detach FETCH_HEAD +test "$(git -C /workspace/source rev-parse HEAD)" = "$revision" +rm -f "$askpass" +printf '{"ok":true,"phase":"source","sourceCommit":"%s","valuesPrinted":false}\n' "$revision" +`, + securityContext: nonRootSecurity(), + volumeMounts: [ + { name: "workspace", mountPath: "/workspace" }, + { name: "source-token", mountPath: "/var/run/pikaoa-source", readOnly: true }, + ], + }], + volumes: [{ name: "source-token", secret: { secretName: "$(params.gitops-secret-name)", items: [{ key: target.gitops.credentialTokenKey, path: "token" }] } }], + }, + params: [ + { name: "revision", value: "$(params.revision)" }, + { name: "source-snapshot-prefix", value: "$(params.source-snapshot-prefix)" }, + { name: "git-read-url", value: "$(params.git-read-url)" }, + { name: "gitops-username", value: "$(params.gitops-username)" }, + { name: "gitops-secret-name", value: "$(params.gitops-secret-name)" }, + ], + workspaces: [{ name: "workspace", workspace: "workspace" }], + }; +} + +function imageBuildTask(target: TestTargetSpec, component: "api" | "worker" | "web"): Record { + const taskName = `build-${component}`; + const imageRepository = target.build.images[component]; + const dockerfile = target.build.dockerfiles[component]; + return { + name: taskName, + runAfter: ["source"], + taskSpec: { + params: [{ name: "revision", type: "string" }], + workspaces: [{ name: "workspace" }], + results: [{ name: "digest", type: "string" }, { name: "image", type: "string" }], + steps: [{ + name: "build", + image: target.ci.buildkitImage, + imagePullPolicy: "IfNotPresent", + workingDir: "/workspace/source", + env: [{ name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" }], + script: `#!/bin/sh +set -eu +revision="$(params.revision)" +tag=${shellSingleQuote(imageRepository)}:"$revision" +metadata=/tmp/${component}-metadata.json +buildctl-daemonless.sh build \\ + --frontend dockerfile.v0 \\ + --local context=. \\ + --local dockerfile=. \\ + --opt filename=${shellSingleQuote(dockerfile)} \\ + --opt network=${shellSingleQuote(target.build.networkMode)} \\ + --output "type=image,name=$tag,push=true" \\ + --metadata-file "$metadata" +digest=$(grep -o '"containerimage.digest":"sha256:[0-9a-f]*"' "$metadata" | head -n1 | cut -d'"' -f4) +case "$digest" in sha256:[0-9a-f]*) ;; *) echo 'build digest missing' >&2; exit 3;; esac +printf '%s' "$digest" > "$(results.digest.path)" +printf '%s@%s' ${shellSingleQuote(imageRepository)} "$digest" > "$(results.image.path)" +printf '{"ok":true,"phase":"${taskName}","sourceCommit":"%s","imageTag":"%s","digest":"%s","valuesPrinted":false}\n' "$revision" "$tag" "$digest" +`, + securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 }, + volumeMounts: [{ name: "workspace", mountPath: "/workspace" }], + }], + }, + params: [{ name: "revision", value: "$(params.revision)" }], + workspaces: [{ name: "workspace", workspace: "workspace" }], + }; +} + +function gitOpsTask(target: TestTargetSpec, runtimeManifest: string): Record { + const manifestB64 = Buffer.from(runtimeManifest, "utf8").toString("base64"); + return { + name: "gitops-publish", + runAfter: ["build-api", "build-worker", "build-web"], + taskSpec: { + params: [ + { name: "revision", type: "string" }, + { name: "gitops-read-url", type: "string" }, + { name: "gitops-write-url", type: "string" }, + { name: "gitops-username", type: "string" }, + { name: "gitops-secret-name", type: "string" }, + { name: "api-image", type: "string" }, + { name: "worker-image", type: "string" }, + { name: "web-image", type: "string" }, + ], + workspaces: [{ name: "workspace" }], + steps: [{ + name: "publish", + image: target.ci.toolsImage, + imagePullPolicy: "IfNotPresent", + env: [{ name: "SOURCE_COMMIT", value: "$(params.revision)" }], + script: `#!/bin/sh +set -eu +askpass=/tmp/pikaoa-git-askpass +cat >"$askpass" <<'ASKPASS' +#!/bin/sh +case "$1" in + *Username*) printf '%s' "$PIKAOA_GIT_USERNAME" ;; + *Password*) cat /var/run/pikaoa-gitops/token ;; + *) exit 1 ;; +esac +ASKPASS +chmod 700 "$askpass" +export PIKAOA_GIT_USERNAME="$(params.gitops-username)" +export GIT_ASKPASS="$askpass" +export GIT_TERMINAL_PROMPT=0 +case "$SOURCE_COMMIT" in *[!0-9a-f]*|'') echo 'source commit must be a full lowercase commit' >&2; exit 2;; esac +test "\${#SOURCE_COMMIT}" -eq 40 +api_image="$(params.api-image)" +worker_image="$(params.worker-image)" +web_image="$(params.web-image)" +for image in "$api_image" "$worker_image" "$web_image"; do + case "$image" in *@sha256:[0-9a-f]*) ;; *) echo 'required digestRef missing' >&2; exit 4;; esac +done +rm -rf /workspace/gitops +mkdir -p /workspace/gitops +git -C /workspace/gitops init +git -C /workspace/gitops remote add origin "$(params.gitops-read-url)" +if git ls-remote --exit-code --heads "$(params.gitops-read-url)" ${shellSingleQuote(target.gitops.branch)} >/dev/null 2>&1; then + git -C /workspace/gitops fetch --depth=1 origin ${shellSingleQuote(target.gitops.branch)} + git -C /workspace/gitops checkout -B ${shellSingleQuote(target.gitops.branch)} FETCH_HEAD +else + git -C /workspace/gitops checkout --orphan ${shellSingleQuote(target.gitops.branch)} + git -C /workspace/gitops rm -rf . >/dev/null 2>&1 || true +fi +manifest=/workspace/gitops/${shellSingleQuote(target.gitops.manifestPath)} +mkdir -p "$(dirname "$manifest")" +printf '%s' ${shellSingleQuote(manifestB64)} | base64 -d > "$manifest" +sed -i \\ + -e "s|__PIKAOA_SOURCE_COMMIT__|$SOURCE_COMMIT|g" \\ + -e "s|__PIKAOA_API_IMAGE__|$(params.api-image)|g" \\ + -e "s|__PIKAOA_WORKER_IMAGE__|$(params.worker-image)|g" \\ + -e "s|__PIKAOA_WEB_IMAGE__|$(params.web-image)|g" \\ + "$manifest" +if grep -Eq '\$\(params\.revision\)|__PIKAOA_SOURCE_COMMIT__|__PIKAOA_(API|WORKER|WEB)_IMAGE__' "$manifest"; then + echo 'GitOps manifest contains unresolved provenance or image placeholders' >&2 + exit 6 +fi +git -C /workspace/gitops config user.name ${shellSingleQuote(target.gitops.authorName)} +git -C /workspace/gitops config user.email ${shellSingleQuote(target.gitops.authorEmail)} +git -C /workspace/gitops add -- ${shellSingleQuote(target.gitops.manifestPath)} +if git -C /workspace/gitops diff --cached --quiet; then + changed=false +else + git -C /workspace/gitops commit -m "chore(gitops): deploy pikaoa test $SOURCE_COMMIT" + git -C /workspace/gitops push "$(params.gitops-write-url)" HEAD:${shellSingleQuote(target.gitops.branch)} + changed=true +fi +gitops_commit=$(git -C /workspace/gitops rev-parse HEAD) +case "$gitops_commit" in [0-9a-f][0-9a-f][0-9a-f][0-9a-f]*) ;; *) echo 'gitops commit missing' >&2; exit 5;; esac +rm -f "$askpass" +printf '{"ok":true,"status":"published","phase":"gitops-publish","sourceCommitId":"%s","registryVerified":true,"publishedCount":3,"reusedCount":0,"requiredServiceCount":3,"services":[{"service":"api","digestRef":"%s","digest":"%s"},{"service":"worker","digestRef":"%s","digest":"%s"},{"service":"web","digestRef":"%s","digest":"%s"}],"gitopsCommit":"%s","changed":%s,"valuesPrinted":false}\n' \ + "$SOURCE_COMMIT" "$api_image" "\${api_image##*@}" "$worker_image" "\${worker_image##*@}" "$web_image" "\${web_image##*@}" "$gitops_commit" "$changed" +`, + securityContext: nonRootSecurity(), + volumeMounts: [ + { name: "workspace", mountPath: "/workspace" }, + { name: "gitops-token", mountPath: "/var/run/pikaoa-gitops", readOnly: true }, + ], + }], + volumes: [{ name: "gitops-token", secret: { secretName: "$(params.gitops-secret-name)", items: [{ key: target.gitops.credentialTokenKey, path: "token" }] } }], + }, + params: [ + { name: "revision", value: "$(params.revision)" }, + { name: "gitops-read-url", value: "$(params.gitops-read-url)" }, + { name: "gitops-write-url", value: "$(params.gitops-write-url)" }, + { name: "gitops-username", value: "$(params.gitops-username)" }, + { name: "gitops-secret-name", value: "$(params.gitops-secret-name)" }, + { name: "api-image", value: "$(tasks.build-api.results.image)" }, + { name: "worker-image", value: "$(tasks.build-worker.results.image)" }, + { name: "web-image", value: "$(tasks.build-web.results.image)" }, + ], + workspaces: [{ name: "workspace", workspace: "workspace" }], + }; +} + +function renderGitOpsManifest(target: TestTargetSpec): string { + const context: PikaoaTestRenderContext = { + instanceId: "default", + commit: "__PIKAOA_SOURCE_COMMIT__", + step: "all", + namespace: target.namespace, + expiresAt: "gitops-managed", + apiImage: "__PIKAOA_API_IMAGE__", + workerImage: "__PIKAOA_WORKER_IMAGE__", + webImage: "__PIKAOA_WEB_IMAGE__", + initializerImage: "__PIKAOA_API_IMAGE__", + }; + const manifest = renderPikaoaTestRuntimeManifest(target, context, { secretMode: "existing", initializerHook: "argo-presync" }); + return manifest.map((object) => Bun.YAML.stringify(object, null, 2).trim()).join("\n---\n") + "\n"; +} + +function assertBinding(target: TestTargetSpec, binding: PikaoaTestRendererBinding): void { + const expected: Readonly> = { + id: "pikaoa-test-nc01", + node: target.node, + lane: target.ci.lane, + namespace: target.ci.namespace, + pipeline: target.ci.pipeline, + pipelineRunPrefix: target.ci.pipelineRunPrefix, + sourceBranch: target.source.branch, + gitopsBranch: target.gitops.branch, + serviceAccount: target.ci.serviceAccountName, + }; + const actual: Readonly> = { + id: binding.consumer.id, + node: binding.consumer.node, + lane: binding.consumer.lane, + namespace: binding.consumer.namespace, + pipeline: binding.consumer.pipeline, + pipelineRunPrefix: binding.consumer.pipelineRunPrefix, + sourceBranch: binding.repository.params.source_branch, + gitopsBranch: binding.repository.params.gitops_branch, + serviceAccount: binding.repository.params.service_account, + }; + for (const [key, value] of Object.entries(expected)) { + if (actual[key] !== value) throw new Error(`pikaoa-test-runtime binding ${key} must equal owning YAML value ${value}`); + } +} + +function assertSourceRepositoryContract(target: TestTargetSpec, sourceWorktree: string): void { + for (const path of Object.values(target.build.dockerfiles)) { + if (!existsSync(resolve(sourceWorktree, path))) throw new Error(`PikaOA source worktree is missing ${path}`); + } +} + +function nonRootSecurity(): Record { + return { allowPrivilegeEscalation: false, runAsNonRoot: true, runAsUser: 1000, runAsGroup: 1000 }; +} + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} diff --git a/scripts/src/pikaoa-test-runtime-manifest.ts b/scripts/src/pikaoa-test-runtime-manifest.ts new file mode 100644 index 00000000..a688348e --- /dev/null +++ b/scripts/src/pikaoa-test-runtime-manifest.ts @@ -0,0 +1,254 @@ +import type { TestTargetSpec } from "./pikaoa-test-target"; + +export type PikaoaTestTargetStep = "all" | "foundation" | "init" | "api" | "worker" | "web"; + +export interface PikaoaTestRenderContext { + instanceId: string; + commit: string; + step: PikaoaTestTargetStep; + namespace: string; + expiresAt: string; + apiImage: string; + workerImage: string; + webImage: string; + initializerImage: string; +} + +export interface PikaoaTestSecretMaterial { + databaseURL: string; + sessionSecret: string; + administratorPassword: string; + employeePassword: string; +} + +export interface PikaoaTestManifestOptions { + secretMode: "materialized" | "existing"; + secrets?: PikaoaTestSecretMaterial; + initializerHook?: "argo-presync"; +} + +const MANAGED_BY = "unidesk-pikaoa-test-target"; +const TEST_RUNTIME_LABEL = "pikaoa.unidesk.io/test-runtime"; +const TARGET_LABEL = "pikaoa.unidesk.io/target"; +const INSTANCE_LABEL = "pikaoa.unidesk.io/instance"; + +export function renderPikaoaTestRuntimeManifest( + target: TestTargetSpec, + context: PikaoaTestRenderContext, + options: PikaoaTestManifestOptions, +): Record[] { + if (options.secretMode === "materialized" && options.secrets === undefined) { + throw new Error("materialized PikaOA test manifest requires secret material"); + } + const labels = ownershipLabels(target, context); + const apiSelector = componentSelector("api", context.instanceId); + const workerSelector = componentSelector("worker", context.instanceId); + const webSelector = componentSelector("web", context.instanceId); + const initializerSelector = componentSelector("initializer", context.instanceId); + const prometheusPodLabels = target.observability.prometheusScrape ? { + [target.observability.prometheusSelector.key]: target.observability.prometheusSelector.value, + } : {}; + const commonPodAnnotations = (path: string, port: number): Record => target.observability.prometheusScrape ? { + "prometheus.io/scrape": "true", + "prometheus.io/path": path, + "prometheus.io/port": String(port), + } : {}; + const initializerAnnotations = options.initializerHook === "argo-presync" ? { + "argocd.argoproj.io/hook": "PreSync", + "argocd.argoproj.io/hook-delete-policy": "BeforeHookCreation,HookSucceeded", + } : {}; + return [ + { + apiVersion: "v1", kind: "Namespace", metadata: { name: context.namespace, labels, annotations: namespaceAnnotations(target, context) }, + }, + ...(options.secretMode === "materialized" ? [{ + apiVersion: "v1", kind: "Secret", metadata: { name: target.runtime.secretName, namespace: context.namespace, labels }, type: "Opaque", + stringData: runtimeSecretData(target, context, options.secrets!), + }] : []), + { + apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: target.runtime.attachment.claimName, namespace: context.namespace, labels }, + spec: { + accessModes: target.runtime.attachment.accessModes, + resources: { requests: { storage: target.runtime.attachment.storageRequest } }, + ...(target.runtime.attachment.storageClassName === null ? {} : { storageClassName: target.runtime.attachment.storageClassName }), + }, + }, + { + apiVersion: "v1", kind: "Service", metadata: { name: target.runtime.apiServiceName, namespace: context.namespace, labels: { ...labels, ...apiSelector } }, + spec: { selector: apiSelector, ports: [{ name: "http", port: target.runtime.apiPort, targetPort: target.runtime.apiPort }] }, + }, + { + apiVersion: "v1", kind: "Service", metadata: { name: target.exposure.serviceName, namespace: context.namespace, labels: { ...labels, ...webSelector } }, + spec: { + type: target.exposure.serviceType, + selector: webSelector, + ports: [{ name: "http", port: target.runtime.webPort, targetPort: target.runtime.webPort, nodePort: target.exposure.port }], + }, + }, + { + apiVersion: "batch/v1", kind: "Job", + metadata: { name: target.initializer.jobName, namespace: context.namespace, labels: { ...labels, ...initializerSelector }, annotations: initializerAnnotations }, + spec: { + backoffLimit: target.initializer.backoffLimit, + template: { + metadata: { labels: { ...labels, ...initializerSelector } }, + spec: { + restartPolicy: "Never", + containers: [{ + name: "initializer", image: context.initializerImage, imagePullPolicy: target.source.pullPolicy, + command: target.initializer.command, + env: [{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }], + volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }], + }], + volumes: [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }], + }, + }, + }, + }, + deployment(target, context, "api", context.apiImage, apiSelector, target.runtime.apiPort, prometheusPodLabels, commonPodAnnotations(target.observability.apiMetricsPath, target.runtime.apiPort), [ + { name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }, + { name: "PIKAOA_SESSION_SECRET", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.sessionSecret.targetKey } } }, + { name: "PIKAOA_BOOTSTRAP_ADMIN_USERNAME", value: target.runtime.administrator.username }, + { name: "PIKAOA_BOOTSTRAP_ADMIN_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.administrator.password.targetKey } } }, + { name: "PIKAOA_BOOTSTRAP_EMPLOYEE_USERNAME", value: target.runtime.employee.username }, + { name: "PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.employee.password.targetKey } } }, + ], target.probes.apiLivenessPath, target.probes.apiReadinessPath), + deployment(target, context, "worker", context.workerImage, workerSelector, target.runtime.workerMetricsPort, prometheusPodLabels, commonPodAnnotations(target.observability.workerMetricsPath, target.runtime.workerMetricsPort), [ + { name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }, + ], target.probes.workerLivenessPath, target.probes.workerReadinessPath), + deployment(target, context, "web", context.webImage, webSelector, target.runtime.webPort, {}, {}, [ + { name: target.runtime.webApiUpstreamEnv, value: `http://${target.runtime.apiServiceName}.${context.namespace}.svc.cluster.local:${target.runtime.apiPort}` }, + ], target.probes.webLivenessPath, target.probes.webReadinessPath), + ]; +} + +export function pikaoaManifestForStep(manifest: Record[], step: PikaoaTestTargetStep): Record[] { + if (step === "all") return manifest; + return manifest.filter((object) => { + if (object.kind === "Namespace" || object.kind === "PersistentVolumeClaim") return true; + if (step === "foundation") return false; + if (object.kind === "Secret") return true; + if (step === "init") return object.kind === "Job"; + const metadata = optionalRecord(object.metadata); + const labels = optionalRecord(metadata?.labels); + return labels?.["app.kubernetes.io/component"] === step; + }); +} + +export function ownershipLabels(target: TestTargetSpec, context: PikaoaTestRenderContext): Record { + return { + "app.kubernetes.io/name": "pikaoa", + "app.kubernetes.io/part-of": "pikaoa-test-runtime", + "app.kubernetes.io/managed-by": MANAGED_BY, + [TEST_RUNTIME_LABEL]: "true", + [TARGET_LABEL]: target.id, + [INSTANCE_LABEL]: context.instanceId, + }; +} + +export function componentSelector(component: string, instanceId: string): Record { + return { "app.kubernetes.io/name": "pikaoa", "app.kubernetes.io/component": component, [INSTANCE_LABEL]: instanceId }; +} + +export function namespaceAnnotations(target: TestTargetSpec, context: PikaoaTestRenderContext): Record { + return { + "pikaoa.unidesk.io/source-repository": target.source.repository, + "pikaoa.unidesk.io/source-commit": context.commit, + "pikaoa.unidesk.io/expires-at": context.expiresAt, + "pikaoa.unidesk.io/ttl-seconds": String(target.ttlSeconds), + }; +} + +export function renderDatabaseURL(target: TestTargetSpec, sourceURL: string): string { + const databaseURL = new URL(sourceURL); + databaseURL.searchParams.set("search_path", target.database.schema); + return databaseURL.toString(); +} + +export function placeholderPikaoaSecrets(): PikaoaTestSecretMaterial { + return { + databaseURL: "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require", + sessionSecret: "", + administratorPassword: "", + employeePassword: "", + }; +} + +function deployment( + target: TestTargetSpec, + context: PikaoaTestRenderContext, + component: "api" | "worker" | "web", + image: string, + selector: Record, + port: number, + podLabels: Record, + annotations: Record, + env: Array>, + livenessPath: string, + readinessPath: string, +): Record { + const labels = ownershipLabels(target, context); + return { + apiVersion: "apps/v1", kind: "Deployment", metadata: { name: `pikaoa-${component}`, namespace: context.namespace, labels: { ...labels, ...selector } }, + spec: { + replicas: 1, selector: { matchLabels: selector }, + template: { + metadata: { labels: { ...labels, ...selector, ...podLabels }, annotations }, + spec: { + ...(component === "api" ? { securityContext: { fsGroup: target.runtime.attachment.fsGroup } } : {}), + containers: [{ + name: component, image, imagePullPolicy: target.source.pullPolicy, env, + ports: [{ name: component === "worker" ? "metrics" : "http", containerPort: port }], + volumeMounts: [ + ...(component === "web" ? [] : [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }]), + ...(component === "api" ? [{ name: target.runtime.attachment.claimName, mountPath: target.runtime.attachment.storageRoot }] : []), + ], + livenessProbe: { httpGet: { path: livenessPath, port }, periodSeconds: 5, failureThreshold: 12 }, + readinessProbe: { httpGet: { path: readinessPath, port }, periodSeconds: 3, failureThreshold: 20 }, + }], + volumes: [ + ...(component === "web" ? [] : [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }]), + ...(component === "api" ? [{ name: target.runtime.attachment.claimName, persistentVolumeClaim: { claimName: target.runtime.attachment.claimName } }] : []), + ], + }, + }, + }, + }; +} + +function runtimeSecretData(target: TestTargetSpec, context: PikaoaTestRenderContext, secrets: PikaoaTestSecretMaterial): Record { + const databaseURL = renderDatabaseURL(target, secrets.databaseURL); + return { + [target.database.connection.targetKey]: databaseURL, + [target.runtime.sessionSecret.targetKey]: secrets.sessionSecret, + [target.runtime.administrator.password.targetKey]: secrets.administratorPassword, + [target.runtime.employee.password.targetKey]: secrets.employeePassword, + "pikaoa.yaml": [ + "api:", + ` listen: 0.0.0.0:${target.runtime.apiPort}`, + "worker:", + ` interval: ${target.runtime.workerInterval}`, + ` metricsListen: 0.0.0.0:${target.runtime.workerMetricsPort}`, + " outbox:", + ` batchSize: ${target.runtime.outboxBatchSize}`, + ` maxAttempts: ${target.runtime.outboxMaxAttempts}`, + ` retryDelay: ${target.runtime.outboxRetryDelay}`, + "database:", + ` url: ${JSON.stringify(databaseURL)}`, + "identity:", + ` sessionTTL: ${target.runtime.sessionTTL}`, + "attachment:", + ` storageRoot: ${JSON.stringify(target.runtime.attachment.storageRoot)}`, + ` maxBytes: ${target.runtime.attachment.maxBytes}`, + "observability:", + ` serviceName: pikaoa-test-${context.instanceId}`, + ` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`, + `shutdownTimeout: ${target.runtime.shutdownTimeout}`, + "", + ].join("\n"), + }; +} + +function optionalRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} diff --git a/scripts/src/pikaoa-test-target-async.test.ts b/scripts/src/pikaoa-test-target-async.test.ts index 61ad054c..79bc6dcd 100644 --- a/scripts/src/pikaoa-test-target-async.test.ts +++ b/scripts/src/pikaoa-test-target-async.test.ts @@ -74,11 +74,11 @@ if printf '%s' "$args" | grep -q ' rollout status '; then [ ! -f "$root/slow" ] || sleep 0.4 exit 0 fi -if printf '%s' "$args" | grep -q ' get job/pikaoa-migrate '; then - if [ -f "$root/fail-migration-query" ]; then printf '%s\n' 'job query failed' >&2; exit 1; fi - if [ -f "$root/failure-target-migration" ]; then +if printf '%s' "$args" | grep -q ' get job/pikaoa-init '; then + if [ -f "$root/fail-initializer-query" ]; then printf '%s\n' 'job query failed' >&2; exit 1; fi + if [ -f "$root/failure-target-initializer" ]; then printf '%s\n' '{"status":{"conditions":[{"type":"FailureTarget","status":"True","reason":"BackoffLimitExceeded"}]}}' - elif [ -f "$root/fail-migration" ]; then + elif [ -f "$root/fail-initializer" ]; then printf '%s\n' '{"status":{"conditions":[{"type":"Failed","status":"True","reason":"BackoffLimitExceeded"}]}}' else printf '%s\n' '{"status":{"conditions":[{"type":"Complete","status":"True"}]}}' @@ -273,33 +273,33 @@ exit 64 rmSync(join(fakeRoot, "slow")); const workerMissingSucceeded = await waitFor("runtime", "start-succeeded", "api"); - writeFileSync(join(fakeRoot, "fail-migration"), "1\n"); - await projection("start", "runtime", true, "migration"); - const failed = await waitFor("runtime", "start-failed", "migration"); + writeFileSync(join(fakeRoot, "fail-initializer"), "1\n"); + await projection("start", "runtime", true, "init"); + const failed = await waitFor("runtime", "start-failed", "init"); const failedTask = ((failed.status as Record).task as Record); - assert.equal(failedTask.code, "migration-job-failed"); + assert.equal(failedTask.code, "initializer-job-failed"); assert.equal(failedTask.exitCode, 46); - rmSync(join(fakeRoot, "fail-migration")); + rmSync(join(fakeRoot, "fail-initializer")); - writeFileSync(join(fakeRoot, "failure-target-migration"), "1\n"); - await projectionWithCommit("start", "runtime", concurrentCommit, "migration"); - const failureTarget = await waitFor("runtime", "start-failed", "migration"); + writeFileSync(join(fakeRoot, "failure-target-initializer"), "1\n"); + await projectionWithCommit("start", "runtime", concurrentCommit, "init"); + const failureTarget = await waitFor("runtime", "start-failed", "init"); const failureTargetTask = ((failureTarget.status as Record).task as Record); - assert.equal(failureTargetTask.code, "migration-job-failure-target"); + assert.equal(failureTargetTask.code, "initializer-job-failure-target"); assert.equal(failureTargetTask.exitCode, 46); - rmSync(join(fakeRoot, "failure-target-migration")); + rmSync(join(fakeRoot, "failure-target-initializer")); - writeFileSync(join(fakeRoot, "fail-migration-query"), "1\n"); - await projectionWithCommit("start", "runtime", "abcdef0123456789", "migration"); - const migrationQueryFailed = await waitFor("runtime", "start-failed", "migration"); - const migrationQueryFailedTask = ((migrationQueryFailed.status as Record).task as Record); - assert.equal(migrationQueryFailedTask.code, "migration-job-query-failed"); - assert.equal(migrationQueryFailedTask.exitCode, 46); - rmSync(join(fakeRoot, "fail-migration-query")); + writeFileSync(join(fakeRoot, "fail-initializer-query"), "1\n"); + await projectionWithCommit("start", "runtime", "abcdef0123456789", "init"); + const initializerQueryFailed = await waitFor("runtime", "start-failed", "init"); + const initializerQueryFailedTask = ((initializerQueryFailed.status as Record).task as Record); + assert.equal(initializerQueryFailedTask.code, "initializer-job-query-failed"); + assert.equal(initializerQueryFailedTask.exitCode, 46); + rmSync(join(fakeRoot, "fail-initializer-query")); - await projectionWithCommit("start", "runtime", "1234567890abcdef", "migration"); - const migrationSucceeded = await waitFor("runtime", "start-succeeded", "migration"); - assert.equal((((migrationSucceeded.status as Record).task as Record).exitCode), 0); + await projectionWithCommit("start", "runtime", "1234567890abcdef", "init"); + const initializerSucceeded = await waitFor("runtime", "start-succeeded", "init"); + assert.equal((((initializerSucceeded.status as Record).task as Record).exitCode), 0); writeFileSync(join(fakeRoot, "slow"), "1\n"); await projection("start", "runtime", true, "web"); @@ -344,8 +344,8 @@ exit 64 workerMissingSucceeded, failed, failureTarget, - migrationQueryFailed, - migrationSucceeded, + initializerQueryFailed, + initializerSucceeded, stopped, foundationSubmitted, foundationSucceeded, @@ -397,7 +397,7 @@ test("validation-only fixture never invokes remote capture", async () => { secretName: "pikaoa-test-runtime", key: "pikaoa.yaml", mountPath: "/etc/pikaoa/pikaoa.yaml", - consumers: ["migration", "api", "worker"], + consumers: ["initializer", "api", "worker"], valuesPrinted: false, }); assert.equal(planPayload.valuesPrinted, false); diff --git a/scripts/src/pikaoa-test-target.ts b/scripts/src/pikaoa-test-target.ts index 1ea5c84d..89fe6184 100644 --- a/scripts/src/pikaoa-test-target.ts +++ b/scripts/src/pikaoa-test-target.ts @@ -10,9 +10,18 @@ import type { RenderedCliResult } from "./output"; import { CliInputError } from "./output"; import { capture, compactCapture, parseJsonOutput, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library"; import { resolveConfigRef } from "./ops/config-refs"; +import { + pikaoaManifestForStep, + placeholderPikaoaSecrets, + renderDatabaseURL, + renderPikaoaTestRuntimeManifest, + type PikaoaTestRenderContext, + type PikaoaTestSecretMaterial, + type PikaoaTestTargetStep, +} from "./pikaoa-test-runtime-manifest"; type TestTargetAction = "plan" | "status" | "start" | "stop"; -type TestTargetStep = "all" | "foundation" | "migration" | "api" | "worker" | "web"; +type TestTargetStep = PikaoaTestTargetStep; interface TestTargetOptions { action: TestTargetAction; @@ -41,7 +50,7 @@ interface PrometheusSelectorSpec { valuesPrinted: false; } -interface TestTargetSpec { +export interface TestTargetSpec { id: string; enabled: boolean; validationOnly: boolean; @@ -64,12 +73,43 @@ interface TestTargetSpec { mode: string; repository: string; commitPolicy: string; + branch: string; + worktreeRemote: string; + readUrl: string; + snapshotPrefix: string; apiImage: string; workerImage: string; webImage: string; - migrationImage: string; + initializerImage: string; pullPolicy: string; }; + ci: { + lane: string; + namespace: string; + pipeline: string; + pipelineRunPrefix: string; + serviceAccountName: string; + workspaceSize: string; + pipelineTimeout: string; + toolsImage: string; + buildkitImage: string; + }; + build: { + dockerfiles: { api: string; worker: string; web: string }; + images: { api: string; worker: string; web: string }; + networkMode: string; + }; + gitops: { + readUrl: string; + writeUrl: string; + branch: string; + manifestPath: string; + credentialSecretName: string; + credentialTokenKey: string; + credentialUsername: string; + authorName: string; + authorEmail: string; + }; database: { configRef: string; name: string; @@ -103,7 +143,8 @@ interface TestTargetSpec { employee: { username: string; password: SecretSourceSpec }; sessionSecret: SecretSourceSpec; }; - migration: { + initializer: { + mode: "fresh-database-only"; jobName: string; backoffLimit: number; command: string[]; @@ -155,24 +196,8 @@ interface Blocker { message: string; } -interface RenderContext { - instanceId: string; - commit: string; - step: TestTargetStep; - namespace: string; - expiresAt: string; - apiImage: string; - workerImage: string; - webImage: string; - migrationImage: string; -} - -interface SecretMaterial { - databaseURL: string; - sessionSecret: string; - administratorPassword: string; - employeePassword: string; -} +type RenderContext = PikaoaTestRenderContext; +type SecretMaterial = PikaoaTestSecretMaterial; type RemoteCapture = typeof capture; @@ -194,14 +219,14 @@ export function pikaoaTestTargetHelp(): Record { usage: [ "bun scripts/cli.ts pikaoa test-target plan [--config path] [--target id] [--instance id] [--commit sha] [--output json]", "bun scripts/cli.ts pikaoa test-target status [--config path] [--target id] [--instance id] [--output json]", - "bun scripts/cli.ts pikaoa test-target start --target id --instance id [--commit sha] [--step foundation|migration|api|worker|web|all] --confirm", + "bun scripts/cli.ts pikaoa test-target start --target id --instance id [--commit sha] [--step foundation|init|api|worker|web|all] --confirm", "bun scripts/cli.ts pikaoa test-target stop --target id --instance id --confirm", ], source: "config/pikaoa.yaml#testRuntime", guarantees: [ "未声明测试 target 时 plan/status 返回 configured=false,start/stop 在远端调用前失败。", "validationOnly target 只用于本地渲染,不连接 route。", - "start 可按 foundation、migration、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。", + "start 可按 foundation、init、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。", "正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。", "Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。", ], @@ -240,7 +265,7 @@ function parseOptions(args: string[]): TestTargetOptions { else if (arg === "--target") targetId = simpleId(value, arg); else if (arg === "--instance") instanceId = kubernetesName(value, arg, 30); else if (arg === "--commit") commit = sourceCommit(value); - else if (arg === "--step") step = enumString(value, arg, ["all", "foundation", "migration", "api", "worker", "web"]); + else if (arg === "--step") step = enumString(value, arg, ["all", "foundation", "init", "api", "worker", "web"]); else { if (value !== "json" && value !== "text") throw inputError(`${arg} 只支持 text 或 json`, "invalid-output", value, ["text", "json"]); output = value; @@ -298,6 +323,13 @@ function parseTarget(id: string, root: Record, configLabel: str simpleId(id, "target id"); const source = record(root.source, `${path}.source`); const images = record(source.images, `${path}.source.images`); + const ci = record(root.ci, `${path}.ci`); + const build = record(root.build, `${path}.build`); + const dockerfiles = record(build.dockerfiles, `${path}.build.dockerfiles`); + const buildImages = record(build.images, `${path}.build.images`); + const gitops = record(root.gitops, `${path}.gitops`); + const gitopsCredential = record(gitops.credential, `${path}.gitops.credential`); + const gitopsAuthor = record(gitops.author, `${path}.gitops.author`); const database = record(root.database, `${path}.database`); const runtime = record(root.runtime, `${path}.runtime`); const attachment = record(runtime.attachment, `${path}.runtime.attachment`); @@ -313,7 +345,7 @@ function parseTarget(id: string, root: Record, configLabel: str const workerProbes = record(probes.worker, `${path}.probes.worker`); const webProbes = record(probes.web, `${path}.probes.web`); const cleanup = record(root.cleanup, `${path}.cleanup`); - const migration = record(root.migration, `${path}.migration`); + const initializer = record(root.initializer, `${path}.initializer`); const taskState = record(root.taskState, `${path}.taskState`); if (exporterFailure.blocking !== false) throw new Error(`${path}.observability.exporterFailure.blocking 必须为 false`); if (exporterFailure.warning !== true) throw new Error(`${path}.observability.exporterFailure.warning 必须为 true`); @@ -341,12 +373,51 @@ function parseTarget(id: string, root: Record, configLabel: str mode: exactString(source.mode, `${path}.source.mode`, "prebuilt-images"), repository: nonEmpty(source.repository, `${path}.source.repository`), commitPolicy: exactString(source.commitPolicy, `${path}.source.commitPolicy`, "cli-required"), + branch: nonEmpty(source.branch, `${path}.source.branch`), + worktreeRemote: nonEmpty(source.worktreeRemote, `${path}.source.worktreeRemote`), + readUrl: nonEmpty(source.readUrl, `${path}.source.readUrl`), + snapshotPrefix: nonEmpty(source.snapshotPrefix, `${path}.source.snapshotPrefix`), apiImage: imageReference(images.api, `${path}.source.images.api`), workerImage: imageReference(images.worker, `${path}.source.images.worker`), webImage: imageReference(images.web, `${path}.source.images.web`), - migrationImage: imageReference(images.migration, `${path}.source.images.migration`), + initializerImage: imageReference(images.initializer, `${path}.source.images.initializer`), pullPolicy: enumString(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]), }, + ci: { + lane: simpleId(nonEmpty(ci.lane, `${path}.ci.lane`), `${path}.ci.lane`), + namespace: kubernetesName(nonEmpty(ci.namespace, `${path}.ci.namespace`), `${path}.ci.namespace`, 63), + pipeline: kubernetesName(nonEmpty(ci.pipeline, `${path}.ci.pipeline`), `${path}.ci.pipeline`, 63), + pipelineRunPrefix: kubernetesName(nonEmpty(ci.pipelineRunPrefix, `${path}.ci.pipelineRunPrefix`), `${path}.ci.pipelineRunPrefix`, 63), + serviceAccountName: kubernetesName(nonEmpty(ci.serviceAccountName, `${path}.ci.serviceAccountName`), `${path}.ci.serviceAccountName`, 63), + workspaceSize: nonEmpty(ci.workspaceSize, `${path}.ci.workspaceSize`), + pipelineTimeout: nonEmpty(ci.pipelineTimeout, `${path}.ci.pipelineTimeout`), + toolsImage: imageReference(ci.toolsImage, `${path}.ci.toolsImage`), + buildkitImage: imageReference(ci.buildkitImage, `${path}.ci.buildkitImage`), + }, + build: { + dockerfiles: { + api: relativeSourcePath(dockerfiles.api, `${path}.build.dockerfiles.api`), + worker: relativeSourcePath(dockerfiles.worker, `${path}.build.dockerfiles.worker`), + web: relativeSourcePath(dockerfiles.web, `${path}.build.dockerfiles.web`), + }, + images: { + api: imageRepository(buildImages.api, `${path}.build.images.api`), + worker: imageRepository(buildImages.worker, `${path}.build.images.worker`), + web: imageRepository(buildImages.web, `${path}.build.images.web`), + }, + networkMode: exactString(build.networkMode, `${path}.build.networkMode`, "host"), + }, + gitops: { + readUrl: nonEmpty(gitops.readUrl, `${path}.gitops.readUrl`), + writeUrl: nonEmpty(gitops.writeUrl, `${path}.gitops.writeUrl`), + branch: nonEmpty(gitops.branch, `${path}.gitops.branch`), + manifestPath: relativeSourcePath(gitops.manifestPath, `${path}.gitops.manifestPath`), + credentialSecretName: kubernetesName(nonEmpty(gitopsCredential.secretName, `${path}.gitops.credential.secretName`), `${path}.gitops.credential.secretName`, 63), + credentialTokenKey: nonEmpty(gitopsCredential.tokenKey, `${path}.gitops.credential.tokenKey`), + credentialUsername: nonEmpty(gitopsCredential.username, `${path}.gitops.credential.username`), + authorName: nonEmpty(gitopsAuthor.name, `${path}.gitops.author.name`), + authorEmail: nonEmpty(gitopsAuthor.email, `${path}.gitops.author.email`), + }, database: { configRef: nonEmpty(database.configRef, `${path}.database.configRef`), name: postgresIdentifier(database.name, `${path}.database.name`), @@ -386,10 +457,11 @@ function parseTarget(id: string, root: Record, configLabel: str }, sessionSecret: secretSource(runtime.sessionSecret, "runtime.sessionSecret", `${path}.runtime.sessionSecret`), }, - migration: { - jobName: kubernetesName(nonEmpty(migration.jobName, `${path}.migration.jobName`), `${path}.migration.jobName`, 63), - backoffLimit: nonNegativeInteger(migration.backoffLimit, `${path}.migration.backoffLimit`), - command: nonEmptyStringList(migration.command, `${path}.migration.command`), + initializer: { + mode: exactString(initializer.mode, `${path}.initializer.mode`, "fresh-database-only") as "fresh-database-only", + jobName: kubernetesName(nonEmpty(initializer.jobName, `${path}.initializer.jobName`), `${path}.initializer.jobName`, 63), + backoffLimit: nonNegativeInteger(initializer.backoffLimit, `${path}.initializer.backoffLimit`), + command: nonEmptyStringList(initializer.command, `${path}.initializer.command`), }, exposure: { serviceName: kubernetesName(nonEmpty(exposure.serviceName, `${path}.exposure.serviceName`), `${path}.exposure.serviceName`, 63), @@ -422,6 +494,20 @@ function parseTarget(id: string, root: Record, configLabel: str return target; } +export function pikaoaTestRuntimeConfigRef(targetId: string): string { + simpleId(targetId, "PikaOA test runtime target id"); + return `config/pikaoa.yaml#testRuntime.targets.${targetId}`; +} + +export function resolvePikaoaTestRuntimeTargetByConfigRef(configRef: string): TestTargetSpec { + const resolved = resolveConfigRef(configRef, "PikaOA test runtime configRef"); + const match = /^testRuntime\.targets\.([A-Za-z0-9._-]+)$/u.exec(resolved.fragment); + if (resolved.file !== "config/pikaoa.yaml" || match === null) { + throw new Error("PikaOA test runtime configRef must select config/pikaoa.yaml#testRuntime.targets."); + } + return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file); +} + function planPayload(options: TestTargetOptions, selection: Selection): Record { const base = basePayload(options, selection); if (selection.target === null) return base; @@ -486,7 +572,10 @@ function renderedPlan(selection: Selection, context: RenderContext): Record secretSourceSummary(selection.configPath, source)), blockers: [] as Blocker[], } : readSecretMaterial(selection, selection.target); if (!material.ok) { return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: material.blockers, secret: { sources: material.sources, valuesPrinted: false }, failure: "secret-preflight-blocked" }, options); } - const manifest = manifestForStep(buildManifest(selection.target, context, material.values), options.step); + const manifest = pikaoaManifestForStep(renderPikaoaTestRuntimeManifest(selection.target, context, { + secretMode: "materialized", + secrets: material.values, + }), options.step); const remote = await remoteCapture(config, selection.target.route, ["sh"], startScript(selection.target, context, manifest)); const parsed = parseJsonOutput(remote.stdout); const mutation = parsed?.mutation === true; @@ -647,7 +740,7 @@ function renderContext(options: TestTargetOptions, target: TestTargetSpec, requi apiImage: renderImage(target.source.apiImage, commit), workerImage: renderImage(target.source.workerImage, commit), webImage: renderImage(target.source.webImage, commit), - migrationImage: renderImage(target.source.migrationImage, commit), + initializerImage: renderImage(target.source.initializerImage, commit), }; } @@ -674,9 +767,9 @@ function safetyBlockers(options: TestTargetOptions, selection: Selection, contex function consistencyWarnings(target: TestTargetSpec, context: RenderContext | null): Warning[] { const warnings: Warning[] = []; - if (!target.migration.command.includes("migrate") || !target.migration.command.includes("up")) warnings.push(warning("migration-command-unrecognized", "迁移命令未显示产品 migrate up;配置一致性只报告 warning。")); - if (![target.source.apiImage, target.source.workerImage, target.source.webImage, target.source.migrationImage].every((image) => image.includes("${commit}"))) warnings.push(warning("image-template-not-commit-scoped", "API、Worker、Web 或迁移镜像模板未包含 ${commit};版本一致性只报告 warning。")); - if (context !== null && ![context.apiImage, context.workerImage, context.webImage, context.migrationImage].every((image) => image.includes(context.commit))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。")); + if (!target.initializer.command.includes("migrate") || !target.initializer.command.includes("up")) warnings.push(warning("initializer-command-unrecognized", "空库 initializer 的当前产品命令未显示 migrate up;配置一致性只报告 warning。")); + if (![target.source.apiImage, target.source.workerImage, target.source.webImage, target.source.initializerImage].every((image) => image.includes("${commit}"))) warnings.push(warning("image-template-not-commit-scoped", "API、Worker、Web 或 initializer 镜像模板未包含 ${commit};版本一致性只报告 warning。")); + if (context !== null && ![context.apiImage, context.workerImage, context.webImage, context.initializerImage].every((image) => image.includes(context.commit))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。")); if (target.observability.exporterWarning) warnings.push(warning("otel-exporter-warning-only", "OTel exporter 失败按 owning YAML 仅报告 warning,不阻塞业务运行。")); if (!target.observability.prometheusScrape) warnings.push(warning("prometheus-scrape-disabled", "Prometheus scrape 已在 target YAML 中关闭。")); return warnings; @@ -717,178 +810,6 @@ function prometheusRuntimeStatus(items: Record[], selector: Pro }; } -function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record[] { - const labels = ownershipLabels(target, context); - const secretData = runtimeSecretData(target, context, secrets); - const apiSelector = componentSelector("api", context.instanceId); - const workerSelector = componentSelector("worker", context.instanceId); - const webSelector = componentSelector("web", context.instanceId); - const migrationSelector = componentSelector("migration", context.instanceId); - const prometheusPodLabels = target.observability.prometheusScrape ? { - [target.observability.prometheusSelector.key]: target.observability.prometheusSelector.value, - } : {}; - const commonPodAnnotations = (path: string, port: number): Record => target.observability.prometheusScrape ? { - "prometheus.io/scrape": "true", - "prometheus.io/path": path, - "prometheus.io/port": String(port), - } : {}; - return [ - { - apiVersion: "v1", kind: "Namespace", metadata: { name: context.namespace, labels, annotations: namespaceAnnotations(target, context) }, - }, - { - apiVersion: "v1", kind: "Secret", metadata: { name: target.runtime.secretName, namespace: context.namespace, labels }, type: "Opaque", stringData: secretData, - }, - { - apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: target.runtime.attachment.claimName, namespace: context.namespace, labels }, - spec: { - accessModes: target.runtime.attachment.accessModes, - resources: { requests: { storage: target.runtime.attachment.storageRequest } }, - ...(target.runtime.attachment.storageClassName === null ? {} : { storageClassName: target.runtime.attachment.storageClassName }), - }, - }, - { - apiVersion: "v1", kind: "Service", metadata: { name: target.runtime.apiServiceName, namespace: context.namespace, labels: { ...labels, ...apiSelector } }, - spec: { selector: apiSelector, ports: [{ name: "http", port: target.runtime.apiPort, targetPort: target.runtime.apiPort }] }, - }, - { - apiVersion: "v1", kind: "Service", metadata: { name: target.exposure.serviceName, namespace: context.namespace, labels: { ...labels, ...webSelector } }, - spec: { - type: target.exposure.serviceType, - selector: webSelector, - ports: [{ name: "http", port: target.runtime.webPort, targetPort: target.runtime.webPort, nodePort: target.exposure.port }], - }, - }, - { - apiVersion: "batch/v1", kind: "Job", metadata: { name: target.migration.jobName, namespace: context.namespace, labels: { ...labels, ...migrationSelector } }, - spec: { - backoffLimit: target.migration.backoffLimit, - template: { - metadata: { labels: { ...labels, ...migrationSelector } }, - spec: { - restartPolicy: "Never", - containers: [{ - name: "migration", image: context.migrationImage, imagePullPolicy: target.source.pullPolicy, - command: target.migration.command, - env: [{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }], - volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }], - }], - volumes: [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }], - }, - }, - }, - }, - deployment(target, context, "api", context.apiImage, apiSelector, target.runtime.apiPort, prometheusPodLabels, commonPodAnnotations(target.observability.apiMetricsPath, target.runtime.apiPort), [ - { name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }, - { name: "PIKAOA_SESSION_SECRET", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.sessionSecret.targetKey } } }, - { name: "PIKAOA_BOOTSTRAP_ADMIN_USERNAME", value: target.runtime.administrator.username }, - { name: "PIKAOA_BOOTSTRAP_ADMIN_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.administrator.password.targetKey } } }, - { name: "PIKAOA_BOOTSTRAP_EMPLOYEE_USERNAME", value: target.runtime.employee.username }, - { name: "PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.employee.password.targetKey } } }, - ], target.probes.apiLivenessPath, target.probes.apiReadinessPath), - deployment(target, context, "worker", context.workerImage, workerSelector, target.runtime.workerMetricsPort, prometheusPodLabels, commonPodAnnotations(target.observability.workerMetricsPath, target.runtime.workerMetricsPort), [ - { name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }, - ], target.probes.workerLivenessPath, target.probes.workerReadinessPath), - deployment(target, context, "web", context.webImage, webSelector, target.runtime.webPort, {}, {}, [ - { name: target.runtime.webApiUpstreamEnv, value: `http://${target.runtime.apiServiceName}:${target.runtime.apiPort}` }, - ], target.probes.webLivenessPath, target.probes.webReadinessPath), - ]; -} - -function manifestForStep(manifest: Record[], step: TestTargetStep): Record[] { - if (step === "all") return manifest; - return manifest.filter((object) => { - if (object.kind === "Namespace" || object.kind === "PersistentVolumeClaim") return true; - if (step === "foundation") return false; - if (object.kind === "Secret") return true; - if (step === "migration") return object.kind === "Job"; - const metadata = optionalRecord(object.metadata, "metadata") ?? {}; - const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {}; - return labels["app.kubernetes.io/component"] === step; - }); -} - -function deployment( - target: TestTargetSpec, - context: RenderContext, - component: "api" | "worker" | "web", - image: string, - selector: Record, - port: number, - podLabels: Record, - annotations: Record, - env: Array>, - livenessPath: string, - readinessPath: string, -): Record { - const labels = ownershipLabels(target, context); - return { - apiVersion: "apps/v1", kind: "Deployment", metadata: { name: `pikaoa-${component}`, namespace: context.namespace, labels: { ...labels, ...selector } }, - spec: { - replicas: 1, selector: { matchLabels: selector }, - template: { - metadata: { labels: { ...labels, ...selector, ...podLabels }, annotations }, - spec: { - ...(component === "api" ? { securityContext: { fsGroup: target.runtime.attachment.fsGroup } } : {}), - containers: [{ - name: component, image, imagePullPolicy: target.source.pullPolicy, env, - ports: [{ name: component === "worker" ? "metrics" : "http", containerPort: port }], - volumeMounts: [ - ...(component === "web" ? [] : [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }]), - ...(component === "api" ? [{ name: target.runtime.attachment.claimName, mountPath: target.runtime.attachment.storageRoot }] : []), - ], - livenessProbe: { httpGet: { path: livenessPath, port }, periodSeconds: 5, failureThreshold: 12 }, - readinessProbe: { httpGet: { path: readinessPath, port }, periodSeconds: 3, failureThreshold: 20 }, - }], - volumes: [ - ...(component === "web" ? [] : [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }]), - ...(component === "api" ? [{ name: target.runtime.attachment.claimName, persistentVolumeClaim: { claimName: target.runtime.attachment.claimName } }] : []), - ], - }, - }, - }, - }; -} - -function runtimeSecretData(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record { - const databaseURL = renderDatabaseURL(target, secrets.databaseURL); - return { - [target.database.connection.targetKey]: databaseURL, - [target.runtime.sessionSecret.targetKey]: secrets.sessionSecret, - [target.runtime.administrator.password.targetKey]: secrets.administratorPassword, - [target.runtime.employee.password.targetKey]: secrets.employeePassword, - "pikaoa.yaml": [ - "api:", - ` listen: 0.0.0.0:${target.runtime.apiPort}`, - "worker:", - ` interval: ${target.runtime.workerInterval}`, - ` metricsListen: 0.0.0.0:${target.runtime.workerMetricsPort}`, - " outbox:", - ` batchSize: ${target.runtime.outboxBatchSize}`, - ` maxAttempts: ${target.runtime.outboxMaxAttempts}`, - ` retryDelay: ${target.runtime.outboxRetryDelay}`, - "database:", - ` url: ${JSON.stringify(databaseURL)}`, - "identity:", - ` sessionTTL: ${target.runtime.sessionTTL}`, - "attachment:", - ` storageRoot: ${JSON.stringify(target.runtime.attachment.storageRoot)}`, - ` maxBytes: ${target.runtime.attachment.maxBytes}`, - "observability:", - ` serviceName: pikaoa-test-${context.instanceId}`, - ` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`, - `shutdownTimeout: ${target.runtime.shutdownTimeout}`, - "", - ].join("\n"), - }; -} - -function renderDatabaseURL(target: TestTargetSpec, sourceURL: string): string { - const databaseURL = new URL(sourceURL); - databaseURL.searchParams.set("search_path", target.database.schema); - return databaseURL.toString(); -} - function databasePlanSummary(target: TestTargetSpec): Record { const databaseURL = new URL(renderDatabaseURL(target, "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require")); return { @@ -1134,7 +1055,7 @@ printf '{"ok":true,"mutation":true,"code":"start-submitted","submitted":true,"pr } function startRunnerScript(target: TestTargetSpec, context: RenderContext, manifest: Record[], job: AsyncJobIdentity): string { - const migration = manifest.filter((object) => object.kind === "Job"); + const initializer = manifest.filter((object) => object.kind === "Job"); const workload = (name: string): Record[] => manifest.filter((object) => { if (object.kind !== "Deployment" && object.kind !== "Service") return false; const metadata = optionalRecord(object.metadata, "metadata") ?? {}; @@ -1146,7 +1067,7 @@ function startRunnerScript(target: TestTargetSpec, context: RenderContext, manif const encode = (objects: Record[]): string => Buffer.from(objects.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64"); const foundationEncoded = encode(foundation); const runtimeEncoded = encode(runtime); - const migrationEncoded = encode(migration); + const initializerEncoded = encode(initializer); const apiEncoded = encode(workload("api")); const workerEncoded = encode(workload("worker")); const webEncoded = encode(workload("web")); @@ -1228,7 +1149,7 @@ if kubectl get namespace "$namespace" --request-timeout=${target.taskState.statu fi printf '%s' ${shQuote(foundationEncoded)} | base64 -d >"$tmp/foundation.yaml" printf '%s' ${shQuote(runtimeEncoded)} | base64 -d >"$tmp/runtime.yaml" -printf '%s' ${shQuote(migrationEncoded)} | base64 -d >"$tmp/migration.yaml" +printf '%s' ${shQuote(initializerEncoded)} | base64 -d >"$tmp/initializer.yaml" printf '%s' ${shQuote(apiEncoded)} | base64 -d >"$tmp/api.yaml" printf '%s' ${shQuote(workerEncoded)} | base64 -d >"$tmp/worker.yaml" printf '%s' ${shQuote(webEncoded)} | base64 -d >"$tmp/web.yaml" @@ -1249,22 +1170,22 @@ if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(tar failure_code=runtime-secret-apply-failed exit 44 fi -if [ "$step" = all ] || [ "$step" = migration ]; then - current_phase=migration-apply +if [ "$step" = all ] || [ "$step" = init ]; then + current_phase=initializer-apply write_status running "$current_phase" start-running null check_canceled - kubectl -n "$namespace" delete job/${target.migration.jobName} --ignore-not-found --wait=false >/dev/null 2>&1 || true - if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/migration.yaml" >"$tmp/migration-apply.out" 2>"$tmp/migration-apply.err"; then failure_code=migration-apply-failed; exit 45; fi - current_phase=migration-wait + kubectl -n "$namespace" delete job/${target.initializer.jobName} --ignore-not-found --wait=false >/dev/null 2>&1 || true + if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/initializer.yaml" >"$tmp/initializer-apply.out" 2>"$tmp/initializer-apply.err"; then failure_code=initializer-apply-failed; exit 45; fi + current_phase=initializer-wait write_status running "$current_phase" start-running null - migration_deadline=$(( $(date +%s) + ${target.waitTimeoutSeconds} )) + initializer_deadline=$(( $(date +%s) + ${target.waitTimeoutSeconds} )) while :; do check_canceled - if ! kubectl -n "$namespace" get job/${target.migration.jobName} --request-timeout=${Math.min(target.taskState.statusRequestTimeoutSeconds, 5)}s -o json >"$tmp/migration-job.json" 2>"$tmp/migration-wait.err"; then - failure_code=migration-job-query-failed + if ! kubectl -n "$namespace" get job/${target.initializer.jobName} --request-timeout=${Math.min(target.taskState.statusRequestTimeoutSeconds, 5)}s -o json >"$tmp/initializer-job.json" 2>"$tmp/initializer-wait.err"; then + failure_code=initializer-job-query-failed exit 46 fi - if ! migration_condition="$(python3 - "$tmp/migration-job.json" <<'PY' + if ! initializer_condition="$(python3 - "$tmp/initializer-job.json" <<'PY' import json, pathlib, sys payload = json.loads(pathlib.Path(sys.argv[1]).read_text()) conditions = { @@ -1282,16 +1203,16 @@ else: print("running") PY )"; then - failure_code=migration-job-condition-invalid + failure_code=initializer-job-condition-invalid exit 46 fi - case "$migration_condition" in + case "$initializer_condition" in complete) break ;; - failure-target) failure_code=migration-job-failure-target; exit 46 ;; - failed) failure_code=migration-job-failed; exit 46 ;; + failure-target) failure_code=initializer-job-failure-target; exit 46 ;; + failed) failure_code=initializer-job-failed; exit 46 ;; esac - if [ "$(date +%s)" -ge "$migration_deadline" ]; then - failure_code=migration-job-timeout + if [ "$(date +%s)" -ge "$initializer_deadline" ]; then + failure_code=initializer-job-timeout exit 46 fi sleep 1 @@ -1567,8 +1488,8 @@ function sourceSummary(target: TestTargetSpec, commit: string | null, context?: commitPolicy: target.source.commitPolicy, commit, images: context === undefined - ? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage, webTemplate: target.source.webImage, migrationTemplate: target.source.migrationImage } - : { api: context.apiImage, worker: context.workerImage, web: context.webImage, migration: context.migrationImage }, + ? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage, webTemplate: target.source.webImage, initializerTemplate: target.source.initializerImage } + : { api: context.apiImage, worker: context.workerImage, web: context.webImage, initializer: context.initializerImage }, }; } @@ -1662,10 +1583,6 @@ function manifestFingerprint(manifest: Record[], stableRequest: return `sha256:${createHash("sha256").update(JSON.stringify(normalized)).digest("hex")}`; } -function placeholderSecrets(): SecretMaterial { - return { databaseURL: "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require", sessionSecret: "", administratorPassword: "", employeePassword: "" }; -} - function secretSources(target: TestTargetSpec): SecretSourceSpec[] { return [target.database.connection, target.runtime.sessionSecret, target.runtime.administrator.password, target.runtime.employee.password]; } @@ -1760,6 +1677,18 @@ function imageReference(value: unknown, path: string): string { return image; } +function imageRepository(value: unknown, path: string): string { + const image = nonEmpty(value, path); + if (/\s/u.test(image) || image.includes("@") || /:[^/]+$/u.test(image)) throw new Error(`${path} 必须是不带 tag/digest 的镜像仓库`); + return image; +} + +function relativeSourcePath(value: unknown, path: string): string { + const sourcePath = nonEmpty(value, path); + if (sourcePath.startsWith("/") || sourcePath.split(/[\\/]/u).includes("..")) throw new Error(`${path} 必须是无 .. 的仓库相对路径`); + return sourcePath; +} + function otlpGrpcEndpoint(value: unknown, path: string): string { const endpoint = nonEmpty(value, path); if (endpoint.includes("://") || !/^[A-Za-z0-9.-]+:[1-9][0-9]{0,4}$/u.test(endpoint)) throw new Error(`${path} 必须是无 scheme 的 OTLP gRPC host:port`); diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index 7244c5d8..e769fe11 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -120,19 +120,19 @@ test("unselected parse and repository errors remain warnings while selected obje expect(() => parsePacConfigDocument(invalidConsumerDocument, { consumerId: "selfmedia-production-nc01" })).toThrow("namespace"); const invalidRepositoryDocument = pacConfigDocument(); - const invalidRepository = (invalidRepositoryDocument.repositories as Array>).find((item) => item.id === "pikaoa-nc01"); + const invalidRepository = (invalidRepositoryDocument.repositories as Array>).find((item) => item.id === "pikaoa-test-nc01"); if (invalidRepository === undefined) throw new Error("pikaoa repository fixture is required"); invalidRepository.cloneUrl = "https://gitea.example.invalid/mirrors/pikaoa.git"; const isolated = parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "selfmedia-nc01" }); expect(isolated.validationWarnings).toContainEqual({ code: "pac-unselected-repository-invalid", - object: { kind: "repository", id: "pikaoa-nc01" }, + object: { kind: "repository", id: "pikaoa-test-nc01" }, consumer: null, - configPath: "config/platform-infra/pipelines-as-code.yaml#repositories.pikaoa-nc01", + configPath: "config/platform-infra/pipelines-as-code.yaml#repositories.pikaoa-test-nc01", blocking: false, evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u), }); - expect(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-nc01")).toBe(false); + expect(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-test-nc01")).toBe(false); expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).toThrow("cloneUrl"); const duplicateSelectedRepository = pacConfigDocument(); diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts index b35ac4e8..00e9525f 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts @@ -17,6 +17,9 @@ import { canonicalizePacPipelineSpec, firstPacSourceArtifactDrift, pacSourceArtifactSafeError, + pacSourceArtifactProvenanceFromManifest, + pipelineRunLabels, + pipelineRunWorkspaces, pacValueEvidence, pacSourceArtifactYaml, optionalPipelineRunWorkspaces, @@ -96,6 +99,46 @@ function runtimeInput(commit: string | null = sourceCommit): Record { + test("accepts PikaOA test runtime provenance", () => { + const expected = { + configRef: "config/pikaoa.yaml#testRuntime.targets.NC01", + effectiveConfigSha256: `sha256:${"a".repeat(64)}`, + renderer: "pikaoa-test-runtime", + mode: "embedded-pipeline-spec", + } as const; + const manifest = { + metadata: { annotations: pipelineProvenanceAnnotations(expected) }, + }; + expect(pacSourceArtifactProvenanceFromManifest(manifest)).toEqual(expected); + }); + + test("uses PikaOA-specific PipelineRun labels", () => { + const binding = { consumer: { id: "pikaoa-test-nc01", node: "NC01" } } as Parameters[0]; + expect(pipelineRunLabels(binding, "pikaoa")).toEqual({ + "app.kubernetes.io/name": "pikaoa-test-nc01-pac", + "app.kubernetes.io/part-of": "pikaoa", + "unidesk.ai/source-commit": "{{ revision }}", + "pikaoa.unidesk.io/source-commit": "{{ revision }}", + "pikaoa.unidesk.io/trigger": "pipelines-as-code", + }); + }); + + test("maps the PikaOA shared workspace to the declared RWO PVC size", () => { + const binding = { + consumer: { sourceArtifact: { renderer: "pikaoa-test-runtime" } }, + repository: { params: { workspace_pvc_size: "8Gi" } }, + } as Parameters[0]; + expect(pipelineRunWorkspaces(binding, { workspaces: [{ name: "workspace" }] })).toEqual([{ + name: "workspace", + volumeClaimTemplate: { + spec: { + accessModes: ["ReadWriteOnce"], + resources: { requests: { storage: "8Gi" } }, + }, + }, + }]); + }); + test("omits empty PipelineRun workspaces and preserves declared bindings", () => { expect(optionalPipelineRunWorkspaces([])).toEqual({}); const workspaces = [{ name: "source", emptyDir: {} }]; diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index cc319174..ba6054f8 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -19,9 +19,10 @@ import { stableJsonSha256, stableJsonValue } from "./stable-json"; import { pipelineProvenanceAnnotations, pipelineProvenanceFromManifest, withPipelineProvenanceAnnotations } from "./pipeline-provenance"; import { renderSub2RankDesiredPipeline, sub2RankOwningSourceRemote } from "./platform-infra-sub2rank-pipeline"; import { renderSelfMediaDesiredPipeline, selfMediaSourceWorktreeRemote } from "./selfmedia-delivery-renderer"; +import { renderPikaoaTestDesiredPipeline, pikaoaTestSourceWorktreeRemote } from "./pikaoa-test-delivery-renderer"; export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation"; -export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime"; +export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime" | "pikaoa-test-runtime"; export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime"; export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable"; @@ -499,11 +500,13 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree ? renderHwlabDesiredPipeline(binding, sourceWorktree) : sourceArtifact.renderer === "sub2rank-platform-service" ? renderSub2RankDesiredPipeline(binding) - : renderSelfMediaPipeline(binding, sourceWorktree); + : sourceArtifact.renderer === "selfmedia-runtime" + ? renderSelfMediaPipeline(binding, sourceWorktree) + : renderPikaoaTestPipeline(binding, sourceWorktree); const pipeline = sourceArtifact.renderer === "hwlab-runtime-lane" ? rendered.pipeline : withPipelineProvenanceAnnotations(rendered.pipeline, rendered.provenance); - if (!provenanceEquals(provenanceFromManifest(pipeline), rendered.provenance)) { + if (!provenanceEquals(pacSourceArtifactProvenanceFromManifest(pipeline), rendered.provenance)) { throw new Error(`${sourceArtifact.renderer} did not render the declared Pipeline provenance contract`); } const desiredSpec = requiredRecord(pipeline.spec, "rendered Pipeline.spec"); @@ -583,6 +586,19 @@ function renderSelfMediaPipeline(binding: PacSourceArtifactBinding, sourceWorktr }; } +function renderPikaoaTestPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record; provenance: PacSourceArtifactProvenance } { + const rendered = renderPikaoaTestDesiredPipeline(binding, sourceWorktree); + return { + pipeline: rendered.pipeline, + provenance: { + configRef: rendered.configRef, + effectiveConfigSha256: rendered.effectiveConfigSha256, + renderer: "pikaoa-test-runtime", + mode: "embedded-pipeline-spec", + }, + }; +} + function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWorktree: string): Record { const temporaryRoot = mkdtempSync(resolve(tmpdir(), "unidesk-pac-source-artifact-")); const temporarySource = resolve(temporaryRoot, "source"); @@ -659,7 +675,9 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec ? "sub2rank" : provenance.renderer === "selfmedia-runtime" ? "selfmedia" - : "agentrun", + : provenance.renderer === "pikaoa-test-runtime" + ? "pikaoa" + : "agentrun", ), }, spec: { @@ -732,7 +750,7 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P }; } -function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank" | "selfmedia"): Record { +export function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank" | "selfmedia" | "pikaoa"): Record { if (partOf === "agentrun") { return { "app.kubernetes.io/part-of": "agentrun", @@ -751,6 +769,15 @@ function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" "selfmedia.pikapython.com/trigger": "pipelines-as-code", }; } + if (partOf === "pikaoa") { + return { + "app.kubernetes.io/name": `${binding.consumer.id}-pac`, + "app.kubernetes.io/part-of": "pikaoa", + "unidesk.ai/source-commit": "{{ revision }}", + "pikaoa.unidesk.io/source-commit": "{{ revision }}", + "pikaoa.unidesk.io/trigger": "pipelines-as-code", + }; + } if (partOf === "sub2rank") { return { "app.kubernetes.io/name": "sub2rank", @@ -825,7 +852,7 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor if (name === "source-stage-ref") return { name, value: "{{ source_snapshot_prefix }}/{{ revision }}" }; if (name === "git-url") return { name, value: "{{ repo_url }}" }; const repositoryParam = name.replaceAll("-", "_"); - if (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime" && Object.prototype.hasOwnProperty.call(param, "default")) { + if ((binding.consumer.sourceArtifact.renderer === "selfmedia-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime") && Object.prototype.hasOwnProperty.call(param, "default")) { return { name, value: param.default }; } if (Object.prototype.hasOwnProperty.call(binding.repository.params, repositoryParam)) { @@ -836,11 +863,11 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor }); } -function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desiredSpec: Record): Record[] { +export function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desiredSpec: Record): Record[] { if (desiredSpec.workspaces === undefined) return []; return arrayRecords(desiredSpec.workspaces, "Pipeline.spec.workspaces").map((workspace) => { const name = requiredString(workspace.name, "Pipeline.spec.workspaces[].name"); - if (name === "source") { + if (name === "source" || (name === "workspace" && binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime")) { return { name, volumeClaimTemplate: { @@ -875,9 +902,9 @@ function inspectSourceArtifact(sourceWorktree: string, binding: PacSourceArtifac const pipelineDrift = firstPacSourceArtifactDrift(desired.pipeline, actualPipeline); const pipelineRunDrift = firstPacSourceArtifactDrift(desired.pipelineRun, actualPipelineRun); const actualProvenance = sourceArtifact.mode === "remote-pipeline-annotation" - ? provenanceFromManifest(actualPipeline) - : provenanceFromManifest(actualPipelineRun); - const wrapperProvenance = provenanceFromManifest(actualPipelineRun); + ? pacSourceArtifactProvenanceFromManifest(actualPipeline) + : pacSourceArtifactProvenanceFromManifest(actualPipelineRun); + const wrapperProvenance = pacSourceArtifactProvenanceFromManifest(actualPipelineRun); const provenanceAligned = provenanceEquals(actualProvenance, desired.provenance) && provenanceEquals(wrapperProvenance, desired.provenance); const aligned = pipelineDrift === null && pipelineRunDrift === null && provenanceAligned; return { @@ -918,6 +945,7 @@ function owningSourceRemote(binding: PacSourceArtifactBinding): string { } if (binding.consumer.sourceArtifact.renderer === "sub2rank-platform-service") return sub2RankOwningSourceRemote(); if (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime") return selfMediaSourceWorktreeRemote(binding.consumer.node); + if (binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime") return pikaoaTestSourceWorktreeRemote(binding.consumer.node); if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new PacSourceArtifactError("owning-source-lane-unresolved"); return hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).gitUrl; } @@ -1030,13 +1058,14 @@ function assertPipelineIdentity(pipeline: Record, binding: PacS if (metadata.namespace !== binding.consumer.namespace) throw new Error(`rendered Pipeline namespace ${String(metadata.namespace)} does not match consumer ${binding.consumer.namespace}`); } -function provenanceFromManifest(manifest: Record): PacSourceArtifactProvenance | null { +export function pacSourceArtifactProvenanceFromManifest(manifest: Record): PacSourceArtifactProvenance | null { const provenance = pipelineProvenanceFromManifest(manifest); if (provenance === null) return null; if (provenance.renderer !== "agentrun-control-plane" && provenance.renderer !== "hwlab-runtime-lane" && provenance.renderer !== "sub2rank-platform-service" - && provenance.renderer !== "selfmedia-runtime") return null; + && provenance.renderer !== "selfmedia-runtime" + && provenance.renderer !== "pikaoa-test-runtime") return null; if (provenance.mode !== "embedded-pipeline-spec" && provenance.mode !== "remote-pipeline-annotation") return null; return provenance as PacSourceArtifactProvenance; } diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index d3a1f8cc..b4738c89 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -801,7 +801,7 @@ function parseConsumerDeliveryProvenance(value: Record, path: s function parseSourceArtifact(value: Record, path: string): PacSourceArtifactSpec { const mode = y.enumField(value, "mode", path, ["embedded-pipeline-spec", "remote-pipeline-annotation"] as const); - const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime"] as const); + const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime", "pikaoa-test-runtime"] as const); const pipelineRunPath = sourceArtifactPath(y.stringField(value, "pipelineRunPath", path), `${path}.pipelineRunPath`); const pipelinePath = value.pipelinePath === undefined ? null : sourceArtifactPath(y.stringField(value, "pipelinePath", path), `${path}.pipelinePath`); const taskRunTemplate = y.objectField(value, "taskRunTemplate", path); @@ -811,6 +811,7 @@ function parseSourceArtifact(value: Record, path: string): PacS if (renderer === "hwlab-runtime-lane" && mode !== "remote-pipeline-annotation") throw new Error(`${path}.renderer hwlab-runtime-lane requires remote-pipeline-annotation`); if (renderer === "sub2rank-platform-service" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer sub2rank-platform-service requires embedded-pipeline-spec`); if (renderer === "selfmedia-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer selfmedia-runtime requires embedded-pipeline-spec`); + if (renderer === "pikaoa-test-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer pikaoa-test-runtime requires embedded-pipeline-spec`); return { mode, renderer, @@ -1103,10 +1104,10 @@ function validateConsumerConfig( if (consumer.sourceArtifact?.renderer === "sub2rank-platform-service" && consumer.runnerServiceAccount === null) { throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for sub2rank-platform-service`); } - if (consumer.sourceArtifact?.renderer === "selfmedia-runtime") { - if (consumer.runnerServiceAccount === null) throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for selfmedia-runtime`); + if (consumer.sourceArtifact?.renderer === "selfmedia-runtime" || consumer.sourceArtifact?.renderer === "pikaoa-test-runtime") { + if (consumer.runnerServiceAccount === null) throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for private GitOps runtime renderers`); if (consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined) { - throw new Error(`${configLabel}.consumers.${consumer.id}.argoBootstrap.repositoryCredential is required for the private selfmedia repository`); + throw new Error(`${configLabel}.consumers.${consumer.id}.argoBootstrap.repositoryCredential is required for the private repository`); } if (consumer.closeoutGitOpsMirrorFlush) throw new Error(`${configLabel}.consumers.${consumer.id}.closeoutGitOpsMirrorFlush must be false for Gitea writeback GitOps`); if (params.gitops_secret_name !== repository.secretName) throw new Error(`${configLabel}.consumers.${consumer.id}.effective params.gitops_secret_name must match repository secretName`); From 1c4d48696456bd81ea79ebda8db44791c4d34570 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 07:16:29 +0200 Subject: [PATCH 05/26] =?UTF-8?q?fix(pikaoa):=20=E5=88=86=E7=A6=BB?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=B8=8E=E7=94=9F=E4=BA=A7=20Gitea=20?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/gitea.yaml | 2 +- scripts/src/platform-infra-gitea-config.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 25e15eba..6d6c1f72 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -519,7 +519,7 @@ sourceAuthority: secretRef: namespace: devops-infra name: gitea-github-sync-secrets - key: github-token-pikainc-pikaoa + key: github-token-pikainc-pikaoa-release upstream: repository: pikainc/pikaoa cloneUrl: https://github.com/pikainc/pikaoa.git diff --git a/scripts/src/platform-infra-gitea-config.test.ts b/scripts/src/platform-infra-gitea-config.test.ts index be9f6171..bce72fc8 100644 --- a/scripts/src/platform-infra-gitea-config.test.ts +++ b/scripts/src/platform-infra-gitea-config.test.ts @@ -47,6 +47,22 @@ describe("platform-infra Gitea repository GitHub credentials", () => { .not.toBe(githubTokenEnvNameForSecretKey(globalCredential.gitFetchCredential.secretRef.key)); }); + test("keeps PikaOA master and release authorities on distinct Secret keys", () => { + const config = readGiteaConfig(); + const testAuthority = config.sourceAuthority.repositories.find((repo) => repo.key === "pikaoa-test-nc01"); + const productionAuthority = config.sourceAuthority.repositories.find((repo) => repo.key === "pikaoa-nc01"); + const testKey = testAuthority?.credentialOverride?.github.gitFetchCredential.secretRef.key; + const productionKey = productionAuthority?.credentialOverride?.github.gitFetchCredential.secretRef.key; + + expect(testAuthority?.upstream.branch).toBe("master"); + expect(productionAuthority?.upstream.branch).toBe("release"); + expect(testKey).toBe("github-token-pikainc-pikaoa"); + expect(productionKey).toBe("github-token-pikainc-pikaoa-release"); + expect(productionKey).not.toBe(testKey); + expect(githubTokenEnvNameForSecretKey(productionKey ?? "")) + .not.toBe(githubTokenEnvNameForSecretKey(testKey ?? "")); + }); + test("normalizes Secret keys to bounded environment variable names", () => { expect(githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia")) .toBe("UNIDESK_GITEA_GITHUB_TOKEN_GITHUB_TOKEN_PIKAINC_SELFMEDIA"); From 5ae67445698c7393cc06d75afe2199212bdfc018 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 07:19:36 +0200 Subject: [PATCH 06/26] =?UTF-8?q?docs:=20=E5=9B=BA=E5=8C=96=20WebTerm=20?= =?UTF-8?q?=E6=8C=81=E4=B9=85=20Session=20=E5=88=9B=E5=BB=BA=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/webterm.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/reference/webterm.md b/docs/reference/webterm.md index 7c0e6870..8d07be1f 100644 --- a/docs/reference/webterm.md +++ b/docs/reference/webterm.md @@ -22,3 +22,22 @@ - `browserOutputPolicy.maxFramesPerSecond` 是每个浏览器连接、每个终端的最大输出消息频率; - `browserOutputPolicy.maxFrameBytes` 是单条聚合输出消息的字节上限; - 后端必须按终端独立排队,小数据在频率窗口内合并,积压数据按窗口顺序发送,禁止用字节阈值绕过频率上限。 +- 持久 Codex Session 创建: + - 适用于本地 provider 的 `mycx` 与 `oncx` runtime 自动恢复标签; + - 新建必须直接调用原生 `codex app-server`,按顺序完成 `initialize`、`initialized`、`thread/start` 与 `thread/name/set`; + - `thread/start` 必须显式传入 `ephemeral=false`,并以返回的 thread ID 作为后续恢复 Session ID; + - 禁止通过附带 `--profile` 的 `mycx` 或 `oncx` 包装器启动 app-server,当前 Codex 只允许 profile 用于 runtime 命令; + - `mycx` 创建沿用默认 Codex home,`oncx` 创建必须沿用包装器声明的 `CODEX_HOME`、`CODEX_SQLITE_HOME` 和凭据环境清理规则; + - Webterm 运行在使用 `/usr/local/bin/host-shell` 的容器中时,app-server 必须通过宿主 namespace 执行原生 Codex; + - 容器与宿主工作目录分层: + - 容器进程工作目录必须使用容器内存在的目录; + - 目标工作目录只通过 `thread/start.cwd` 传给宿主 Codex; + - 禁止把仅宿主存在的路径直接作为容器 `spawn.cwd`; + - 只有 `thread/name/set` 成功后才能把 Session ID 写入 runtime 数据域,再通过 `mycx|oncx resume ` 启动终端; + - 恢复 prompt 必须作为 CLI 参数传入,禁止在 xterm 中模拟键盘输入; + - runtime 记录与 YAML 预置恢复标签必须共存,runtime 数据域不得改变 owning YAML 的预置定义。 +- 持久 Session 最小验收: + - 先执行目标的受控 `status`,确认 provider 健康和 runtime 数据域正确; + - 再从真实浏览器加号入口创建一个持久 Session,关闭标签后从恢复列表重新打开; + - 创建与恢复后的 Session ID 相同、标签为 runtime 自动恢复且终端进入 running,才判定端到端合同成立; + - 单元测试只验证 app-server 消息顺序、模式环境和宿主 namespace 启动参数,不能替代真实浏览器验收。 From a57f353eeb89b99a5f2aa1986d69d1335af2a138 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 07:20:49 +0200 Subject: [PATCH 07/26] =?UTF-8?q?fix(kafka):=20=E6=8E=88=E4=BA=88=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E4=BA=91=20API=20=E4=BA=8B=E4=BB=B6=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/kafka.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/platform-infra/kafka.yaml b/config/platform-infra/kafka.yaml index a52487d8..e9d1ccaa 100644 --- a/config/platform-infra/kafka.yaml +++ b/config/platform-infra/kafka.yaml @@ -143,6 +143,7 @@ clients: - agentrun.event.v1 - agentrun.event.debug.v1 - agentrun.hwlab.event.v1 + - hwlab.event.v1 - hwlab.event.debug.v1 dlqTopics: - agentrun.hwlab.event.dlq.v1 From 609c78f4efe812347c7b3dc72db43ce5413cb245 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 07:24:25 +0200 Subject: [PATCH 08/26] =?UTF-8?q?docs(pikaoa):=20=E8=B7=9F=E8=B8=AA=20PaC?= =?UTF-8?q?=20admission=20bootstrap=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/pikaoa-enterprise-platform.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 4c013294..50c3991d 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -132,6 +132,9 @@ ##### R1.5.9.13 [in_progress] 接入仅跟随产品 master 的 pikaoa-test-nc01 独立测试 CI/CD,通过正常 source PR merge 自动交付 NC01 pikaoa-test,保持生产 consumer 不触发(pikasTech/unidesk#2105),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.13_Task_Report.md)。 +###### R1.5.9.13.1 [in_progress] + +修复 PikaOA 首次 PaC bootstrap 未同步 admission provenance desired policy/binding 的缺口,使配置指纹漂移降级为非阻塞 warning 且正常产品 PR merge 产生的 PipelineRun 能继续执行,执行记录见 [pikasTech/unidesk#2127](https://github.com/pikasTech/unidesk/issues/2127),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.13.1_Task_Report.md)。 ### R1.6 审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 From e60dce625bb7644de463fe4852b0f97241e3abbc Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 07:37:43 +0200 Subject: [PATCH 09/26] fix: bootstrap PaC admission provenance --- ...-infra-pipelines-as-code-bootstrap.test.ts | 10 ++++-- ...tform-infra-pipelines-as-code-bootstrap.ts | 2 ++ ...platform-infra-pipelines-as-code-remote.sh | 36 ++++++++++++++++++- .../src/platform-infra-pipelines-as-code.ts | 3 +- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index e769fe11..bc0a0982 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -166,10 +166,16 @@ test("PaC apply checks the Gitea repository before dry-run return and cluster mu const preflight = remote.indexOf("if ! repository_preflight", action); const dryRun = remote.indexOf('if [ "$UNIDESK_PAC_DRY_RUN" = "1" ]', action); const release = remote.indexOf(" apply_release", action); + const admission = remote.indexOf(" apply_admission_manifest", release); + const rbac = remote.indexOf(" rbac_tmp=$(mktemp)", admission); expect(action).toBeGreaterThanOrEqual(0); expect(preflight).toBeGreaterThan(action); expect(dryRun).toBeGreaterThan(preflight); expect(release).toBeGreaterThan(dryRun); + expect(admission).toBeGreaterThan(release); + expect(rbac).toBeGreaterThan(admission); + expect(remote).toContain('"admission":{"applied":%s,"ready":%s}'); + expect(remote).toContain('"admissionProvenance":%s'); expect(remote).toContain('"error":"gitea-repository-missing"'); }); @@ -197,7 +203,7 @@ test("PaC bootstrap projects typed stages and compact JSON", () => { const projection = projectPacBootstrapResult(result); expect(projection.ok).toBe(true); expect(JSON.stringify(projection)).not.toContain("pipelinesAsCodeApply"); - expect((projection.stages as Record[]).map((item) => item.id)).toEqual(["gitea-init", "pac-controller", "tekton-consumer", "gitops-argo"]); + expect((projection.stages as Record[]).map((item) => item.id)).toEqual(["gitea-init", "pac-controller", "pac-admission", "tekton-consumer", "gitops-argo"]); const rendered = renderPacBootstrap(result).renderedText; expect(rendered).toContain("PRECONDITIONS"); expect(rendered).toContain("yaml-repository-exact-match"); @@ -237,6 +243,6 @@ test("PaC bootstrap distinguishes GitHub access failure from Gitea and PaC stage }); expect((projection.blockers as Record[])).toEqual([{ code: "github-repository-not-found-or-no-access", stage: "github-upstream", reason: "repository unavailable" }]); expect((projection.preconditions as Record[])[2]).toMatchObject({ ok: null, code: "gitea-bootstrap-skipped" }); - expect((projection.stages as Record[]).map((item) => item.state)).toEqual(["skipped", "skipped", "skipped", "skipped"]); + expect((projection.stages as Record[]).map((item) => item.state)).toEqual(["skipped", "skipped", "skipped", "skipped", "skipped"]); expect((projection.next as Record).kind).toBe("read-only-status"); }); diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts index 1bd3f6fa..3a8e2b84 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -69,6 +69,7 @@ export function projectPacBootstrapResult(result: Record): Reco const gitea = record(result.giteaBootstrap); const apply = record(result.pipelinesAsCodeApply); const remote = record(apply.remote); + const admission = record(remote.admission); const blockers = bootstrapBlockers(githubPreflight, gitea, apply, remote); const dryRun = result.mode === "dry-run"; const repositoryMissing = remote.error === "gitea-repository-missing"; @@ -101,6 +102,7 @@ export function projectPacBootstrapResult(result: Record): Reco stages: [ { id: "gitea-init", ok: giteaSkipped ? null : giteaReady, state: giteaSkipped ? "skipped" : giteaReady ? (dryRun ? "planned" : "ready") : "failed", mutation: gitea.mutation === true }, { id: "pac-controller", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + { id: "pac-admission", ok: applySkipped ? null : dryRun ? true : admission.ready === true, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : dryRun ? "planned" : admission.ready === true ? "ready" : "failed", mutation: admission.applied === true }, { id: "tekton-consumer", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, { id: "gitops-argo", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, ], diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 292b3bd1..e268cdcc 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -284,6 +284,13 @@ apply_release() { rm -f "$tmp" } +apply_admission_manifest() { + tmp=$(mktemp) + printf '%s' "$UNIDESK_PAC_ADMISSION_MANIFEST_B64" | base64 -d > "$tmp" + kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-admission-bootstrap" -f "$tmp" >/dev/null + rm -f "$tmp" +} + wait_ready() { timeout="${UNIDESK_PAC_WAIT_TIMEOUT_SECONDS:-55}" end=$(( $(now_epoch) + timeout )) @@ -313,6 +320,11 @@ apply_action() { return fi apply_release + admission_applied=false + if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" = "1" ]; then + apply_admission_manifest + admission_applied=true + fi kubectl create ns "$UNIDESK_PAC_TARGET_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" = "1" ]; then rbac_tmp=$(mktemp) @@ -351,7 +363,16 @@ apply_action() { hook_id=$(ensure_webhook) ready=false if wait_ready; then ready=true; fi - printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"argoBootstrap":%s,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$argo_bootstrap" "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" "$(json_string "$UNIDESK_PAC_GITEA_REPO")" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")" + admission_ready=true + if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" = "1" ]; then + admission_ready=false + if wait_admission_ready; then admission_ready=true; fi + else + prepare_admission_provenance_state + fi + ok=false + if [ "$ready" = true ] && [ "$admission_ready" = true ]; then ok=true; fi + printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"admission":{"applied":%s,"ready":%s},"admissionProvenance":%s,"argoBootstrap":%s,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ok" "$ready" "$admission_applied" "$admission_ready" "$UNIDESK_PAC_ADMISSION_STATE_JSON" "$argo_bootstrap" "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" "$(json_string "$UNIDESK_PAC_GITEA_REPO")" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")" } condition_status() { @@ -415,6 +436,19 @@ prepare_admission_provenance_state() { export UNIDESK_PAC_ADMISSION_STATE_JSON } +wait_admission_ready() { + timeout="${UNIDESK_PAC_WAIT_TIMEOUT_SECONDS:-55}" + end=$(( $(now_epoch) + timeout )) + while :; do + prepare_admission_provenance_state + if printf '%s' "$UNIDESK_PAC_ADMISSION_STATE_JSON" | node -e 'let input="";process.stdin.on("data",chunk=>input+=chunk);process.stdin.on("end",()=>process.exit(JSON.parse(input).ready===true?0:1));'; then + return 0 + fi + if [ "$(now_epoch)" -ge "$end" ]; then return 1; fi + sleep 2 + done +} + pipeline_rows() { payload_file=$(mktemp) kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file" diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index b4738c89..69f0f204 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -28,7 +28,7 @@ import { type PacSourceArtifactRuntimeObservation, type PacSourceArtifactSpec, } from "./platform-infra-pipelines-as-code-source-artifact"; -import { pacAdmissionDesiredIdentity } from "./platform-infra-pac-provenance"; +import { pacAdmissionDesiredIdentity, renderPacAdmissionDesiredFragment } from "./platform-infra-pac-provenance"; import { pacConsumerRbacDesiredIdentity, renderPacConsumerRbacDesiredFragment } from "./platform-infra-pac-consumer-rbac"; import { runGiteaMirrorBootstrapPreflight, runPlatformInfraGiteaCommand } from "./platform-infra-gitea"; import { readGiteaConfig } from "./platform-infra-gitea-config"; @@ -1754,6 +1754,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix, UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"), UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED: consumer.deliveryProvenance?.required === true ? "1" : "0", + UNIDESK_PAC_ADMISSION_MANIFEST_B64: Buffer.from(renderPacAdmissionDesiredFragment(target.id), "utf8").toString("base64"), UNIDESK_PAC_CONSUMER_RBAC_MANIFEST_B64: Buffer.from(renderPacConsumerRbacDesiredFragment(target.id), "utf8").toString("base64"), UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64: Buffer.from(runnerServiceAccountManifest(consumer), "utf8").toString("base64"), UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME: consumer.runnerServiceAccount?.name ?? "", From 3d2c84e46f84eafda42de8ab74c6467e8901caa9 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 07:45:01 +0200 Subject: [PATCH 10/26] =?UTF-8?q?fix(pikaoa):=20=E4=BF=AE=E6=AD=A3=20PaC?= =?UTF-8?q?=20workspace=20=E6=8C=82=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/pikaoa-test-delivery-renderer.test.ts | 9 +++++++++ scripts/src/pikaoa-test-delivery-renderer.ts | 9 +++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/src/pikaoa-test-delivery-renderer.test.ts b/scripts/src/pikaoa-test-delivery-renderer.test.ts index 2585747e..6421cb61 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.test.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.test.ts @@ -48,6 +48,15 @@ describe("PikaOA test PaC renderer", () => { const rendered = renderPikaoaTestDesiredPipeline(rendererBinding(), source); const spec = rendered.pipeline.spec as Record; const tasks = spec.tasks as Array>; + for (const task of tasks) { + const taskSpec = task.taskSpec as Record; + expect(taskSpec.workspaces).toEqual([{ name: "workspace", mountPath: "/workspace" }]); + const steps = taskSpec.steps as Array>; + for (const step of steps) { + const mounts = (step.volumeMounts ?? []) as Array>; + expect(mounts.some((mount) => mount.name === "workspace")).toBe(false); + } + } for (const name of ["build-api", "build-worker", "build-web"]) { const task = tasks.find((item) => item.name === name); expect(task?.runAfter).toEqual(["source"]); diff --git a/scripts/src/pikaoa-test-delivery-renderer.ts b/scripts/src/pikaoa-test-delivery-renderer.ts index 9ff83392..4524142e 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.ts @@ -98,7 +98,7 @@ function sourceTask(target: TestTargetSpec): Record { { name: "gitops-username", type: "string" }, { name: "gitops-secret-name", type: "string" }, ], - workspaces: [{ name: "workspace" }], + workspaces: [{ name: "workspace", mountPath: "/workspace" }], steps: [{ name: "checkout", image: target.ci.toolsImage, @@ -132,7 +132,6 @@ printf '{"ok":true,"phase":"source","sourceCommit":"%s","valuesPrinted":false}\n `, securityContext: nonRootSecurity(), volumeMounts: [ - { name: "workspace", mountPath: "/workspace" }, { name: "source-token", mountPath: "/var/run/pikaoa-source", readOnly: true }, ], }], @@ -158,7 +157,7 @@ function imageBuildTask(target: TestTargetSpec, component: "api" | "worker" | "w runAfter: ["source"], taskSpec: { params: [{ name: "revision", type: "string" }], - workspaces: [{ name: "workspace" }], + workspaces: [{ name: "workspace", mountPath: "/workspace" }], results: [{ name: "digest", type: "string" }, { name: "image", type: "string" }], steps: [{ name: "build", @@ -186,7 +185,6 @@ printf '%s@%s' ${shellSingleQuote(imageRepository)} "$digest" > "$(results.image printf '{"ok":true,"phase":"${taskName}","sourceCommit":"%s","imageTag":"%s","digest":"%s","valuesPrinted":false}\n' "$revision" "$tag" "$digest" `, securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 }, - volumeMounts: [{ name: "workspace", mountPath: "/workspace" }], }], }, params: [{ name: "revision", value: "$(params.revision)" }], @@ -210,7 +208,7 @@ function gitOpsTask(target: TestTargetSpec, runtimeManifest: string): Record Date: Wed, 15 Jul 2026 08:01:49 +0200 Subject: [PATCH 11/26] =?UTF-8?q?fix(pikaoa):=20=E7=A8=B3=E5=81=A5?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=20BuildKit=20=E9=95=9C=E5=83=8F=E6=91=98?= =?UTF-8?q?=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/pikaoa-test-delivery-renderer.test.ts | 3 +++ scripts/src/pikaoa-test-delivery-renderer.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/src/pikaoa-test-delivery-renderer.test.ts b/scripts/src/pikaoa-test-delivery-renderer.test.ts index 6421cb61..69b79b4f 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.test.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.test.ts @@ -63,6 +63,9 @@ describe("PikaOA test PaC renderer", () => { expect(JSON.stringify(task)).toContain("buildctl-daemonless.sh build"); expect(JSON.stringify(task)).toContain("$(params.revision)"); expect(JSON.stringify(task)).not.toContain("source-token"); + const buildScript = (((task?.taskSpec as Record).steps as Array>)[0]?.script) as string; + expect(buildScript).toContain('"containerimage.digest"[[:space:]]*:[[:space:]]*"\\(sha256:[0-9a-f]\\{64\\}\\)"'); + expect(buildScript).toContain("Docker-Content-Digest:"); } const sourceTask = tasks.find((item) => item.name === "source"); expect(JSON.stringify(sourceTask)).toContain("source-token"); diff --git a/scripts/src/pikaoa-test-delivery-renderer.ts b/scripts/src/pikaoa-test-delivery-renderer.ts index 4524142e..3a5e9e2f 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.ts @@ -178,8 +178,15 @@ buildctl-daemonless.sh build \\ --opt network=${shellSingleQuote(target.build.networkMode)} \\ --output "type=image,name=$tag,push=true" \\ --metadata-file "$metadata" -digest=$(grep -o '"containerimage.digest":"sha256:[0-9a-f]*"' "$metadata" | head -n1 | cut -d'"' -f4) -case "$digest" in sha256:[0-9a-f]*) ;; *) echo 'build digest missing' >&2; exit 3;; esac +metadata_compact=$(tr -d '\\n\\r' < "$metadata") +digest=$(printf '%s' "$metadata_compact" | sed -n 's/.*"containerimage.digest"[[:space:]]*:[[:space:]]*"\\(sha256:[0-9a-f]\\{64\\}\\)".*/\\1/p' | head -n1) +if ! printf '%s' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then + registry_host=${shellSingleQuote(imageRepository.split("/")[0] ?? "")} + registry_path=${shellSingleQuote(imageRepository.split("/").slice(1).join("/"))} + headers=$(wget --server-response --spider --header='Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' "http://$registry_host/v2/$registry_path/manifests/$revision" 2>&1 || true) + digest=$(printf '%s\\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*Docker-Content-Digest:/ {gsub(/\\r/, "", $2); print $2; exit}') +fi +case "$digest" in sha256:[0-9a-f][0-9a-f]*) test "\${#digest}" -eq 71 ;; *) echo 'build digest missing from metadata and registry manifest' >&2; exit 3;; esac printf '%s' "$digest" > "$(results.digest.path)" printf '%s@%s' ${shellSingleQuote(imageRepository)} "$digest" > "$(results.image.path)" printf '{"ok":true,"phase":"${taskName}","sourceCommit":"%s","imageTag":"%s","digest":"%s","valuesPrinted":false}\n' "$revision" "$tag" "$digest" From 11979c86b0a8f042716e8eb5837d4a14e6935d85 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:03:11 +0200 Subject: [PATCH 12/26] =?UTF-8?q?docs:=20=E8=B7=9F=E8=B8=AA=20PaC=20admiss?= =?UTF-8?q?ion=20=E5=9B=B4=E6=A0=8F=E8=AF=AD=E4=B9=89=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/pr-merge-driven-automatic-delivery.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/MDTODO/pr-merge-driven-automatic-delivery.md b/docs/MDTODO/pr-merge-driven-automatic-delivery.md index 6821e5bc..08b872a7 100644 --- a/docs/MDTODO/pr-merge-driven-automatic-delivery.md +++ b/docs/MDTODO/pr-merge-driven-automatic-delivery.md @@ -54,7 +54,8 @@ #### R4.1.1 [in_progress] -解决 [UniDesk #2039](https://github.com/pikasTech/unidesk/issues/2039):分层确认 AgentRun #358 合并后无 PipelineRun 是 webhook/mirror 缺口、只读 status 假阻塞还是 admission 实际拒绝;保留 #1769 对伪造 marker、错误 creator 和未授权 ServiceAccount 的真实安全拒绝,将 live spec/config SHA、API defaulting、版本及等价一致性漂移降级为结构化非阻塞 warning,禁止人工 mirror、PipelineRun、trigger、sync、flush 或 apply,并用修复 PR 的新真实 merge 事件验收自动交付,完成任务后将详细报告写入任务报告,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md)。 +解决 [UniDesk #2039](https://github.com/pikasTech/unidesk/issues/2039):现场确认 PR #1808/#1812 引入的 `ValidatingAdmissionPolicy` 以错误的原子 queue transition 假设拒绝官方 PaC watcher,导致 HWLAB `df925768` 与 AgentRun 自动流水线停在 Pending;以新提交语义回退业务阻断型 admission 围栏,保留 provenance、creator、marker、ServiceAccount 和漂移为结构化 `blocking=false` warning,禁止继续扩展 CEL 围栏、人工 PipelineRun、mirror、trigger、sync、flush 或 runtime patch,并由既有 merge 事件自动恢复及完成原入口验收,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md)。 + ### R4.2 依赖 R4.1,让 YAML-owned pipeline 输出标准 terminal evidence,由单一 controller 自动 reconcile GitOps;Argo/status 必须针对同一 GitOps repository 和 commit graph 验证 exact/descendant,并把 source、artifact、GitOps、revision、runtime digest 形成可审计链,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R4.2_Task_Report.md)。 From e3fc1b972582b7c347ed5265c688cbe7f84640c6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:10:54 +0200 Subject: [PATCH 13/26] =?UTF-8?q?fix(pikaoa):=20=E5=B7=B2=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E4=BA=A7=E7=89=A9=E6=97=B6=E9=99=8D=E7=BA=A7=20BuildK?= =?UTF-8?q?it=20=E6=94=B6=E5=B0=BE=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/pikaoa-test-delivery-renderer.test.ts | 3 +++ scripts/src/pikaoa-test-delivery-renderer.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/scripts/src/pikaoa-test-delivery-renderer.test.ts b/scripts/src/pikaoa-test-delivery-renderer.test.ts index 69b79b4f..e38e2888 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.test.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.test.ts @@ -66,6 +66,9 @@ describe("PikaOA test PaC renderer", () => { const buildScript = (((task?.taskSpec as Record).steps as Array>)[0]?.script) as string; expect(buildScript).toContain('"containerimage.digest"[[:space:]]*:[[:space:]]*"\\(sha256:[0-9a-f]\\{64\\}\\)"'); expect(buildScript).toContain("Docker-Content-Digest:"); + expect(buildScript).toContain("build_status=$?"); + expect(buildScript).toContain('"code":"buildctl-nonzero-artifact-verified"'); + expect(buildScript).toContain('"artifactVerified":true'); } const sourceTask = tasks.find((item) => item.name === "source"); expect(JSON.stringify(sourceTask)).toContain("source-token"); diff --git a/scripts/src/pikaoa-test-delivery-renderer.ts b/scripts/src/pikaoa-test-delivery-renderer.ts index 3a5e9e2f..51b744e8 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.ts @@ -170,6 +170,7 @@ set -eu revision="$(params.revision)" tag=${shellSingleQuote(imageRepository)}:"$revision" metadata=/tmp/${component}-metadata.json +set +e buildctl-daemonless.sh build \\ --frontend dockerfile.v0 \\ --local context=. \\ @@ -178,6 +179,8 @@ buildctl-daemonless.sh build \\ --opt network=${shellSingleQuote(target.build.networkMode)} \\ --output "type=image,name=$tag,push=true" \\ --metadata-file "$metadata" +build_status=$? +set -e metadata_compact=$(tr -d '\\n\\r' < "$metadata") digest=$(printf '%s' "$metadata_compact" | sed -n 's/.*"containerimage.digest"[[:space:]]*:[[:space:]]*"\\(sha256:[0-9a-f]\\{64\\}\\)".*/\\1/p' | head -n1) if ! printf '%s' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then @@ -187,6 +190,9 @@ if ! printf '%s' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then digest=$(printf '%s\\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*Docker-Content-Digest:/ {gsub(/\\r/, "", $2); print $2; exit}') fi case "$digest" in sha256:[0-9a-f][0-9a-f]*) test "\${#digest}" -eq 71 ;; *) echo 'build digest missing from metadata and registry manifest' >&2; exit 3;; esac +if [ "$build_status" -ne 0 ]; then + printf '{"level":"warning","code":"buildctl-nonzero-artifact-verified","component":"${component}","buildctlExitCode":%s,"artifactVerified":true,"valuesPrinted":false}\\n' "$build_status" >&2 +fi printf '%s' "$digest" > "$(results.digest.path)" printf '%s@%s' ${shellSingleQuote(imageRepository)} "$digest" > "$(results.image.path)" printf '{"ok":true,"phase":"${taskName}","sourceCommit":"%s","imageTag":"%s","digest":"%s","valuesPrinted":false}\n' "$revision" "$tag" "$digest" From 628ecd075ee232b932d8a6c9bff221329c6e219e Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:24:36 +0200 Subject: [PATCH 14/26] =?UTF-8?q?fix(pikaoa):=20=E9=81=BF=E5=85=8D=20Tekto?= =?UTF-8?q?n=20=E6=9B=BF=E6=8D=A2=E5=8D=A0=E4=BD=8D=E7=AC=A6=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E6=AD=A3=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/pikaoa-test-delivery-renderer.test.ts | 2 ++ scripts/src/pikaoa-test-delivery-renderer.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/src/pikaoa-test-delivery-renderer.test.ts b/scripts/src/pikaoa-test-delivery-renderer.test.ts index e38e2888..d0bc0503 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.test.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.test.ts @@ -99,6 +99,8 @@ describe("PikaOA test PaC renderer", () => { expect(manifest).toContain("pikaoa.unidesk.io/source-commit: __PIKAOA_SOURCE_COMMIT__"); expect(script).toContain('s|__PIKAOA_SOURCE_COMMIT__|$SOURCE_COMMIT|g'); expect(script).toContain("GitOps manifest contains unresolved provenance or image placeholders"); + expect(script).not.toContain("grep -Eq '$(params.revision)"); + expect(script).toContain("grep -Eq '__PIKAOA_SOURCE_COMMIT__|__PIKAOA_(API|WORKER|WEB)_IMAGE__'"); expect(script).toContain("git ls-remote --exit-code --heads"); expect(script).toContain("git -C /workspace/gitops checkout --orphan"); } finally { diff --git a/scripts/src/pikaoa-test-delivery-renderer.ts b/scripts/src/pikaoa-test-delivery-renderer.ts index 51b744e8..94af7c24 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.ts @@ -270,7 +270,7 @@ sed -i \\ -e "s|__PIKAOA_WORKER_IMAGE__|$(params.worker-image)|g" \\ -e "s|__PIKAOA_WEB_IMAGE__|$(params.web-image)|g" \\ "$manifest" -if grep -Eq '\$\(params\.revision\)|__PIKAOA_SOURCE_COMMIT__|__PIKAOA_(API|WORKER|WEB)_IMAGE__' "$manifest"; then +if grep -Eq '__PIKAOA_SOURCE_COMMIT__|__PIKAOA_(API|WORKER|WEB)_IMAGE__' "$manifest"; then echo 'GitOps manifest contains unresolved provenance or image placeholders' >&2 exit 6 fi From b84de2b2fd01c8413b26ae13719f9aa07156a695 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 07:27:01 +0200 Subject: [PATCH 15/26] =?UTF-8?q?docs(pikaoa):=20=E8=B7=9F=E8=B8=AA?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=20release=20=E4=B8=8A=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/pikaoa-enterprise-platform.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 50c3991d..efb705f1 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -145,6 +145,12 @@ ##### R1.6.1.1 [completed] 将 PK01 PostgreSQL 逻辑备份正规化为 YAML-first 多数据库 logicalDumps,保留 Sub2API 并新增 PikaOA 独立 dump、timer、retention 与受控状态/可恢复性摘要(pikasTech/unidesk#2115),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md)。 +##### R1.6.1.2 + +为 NC01 pikaoa namespace 的生产附件 PVC 建立 YAML-first Restic 离机备份、受控状态与 restore smoke,复用 PK01 /var/backups/unidesk 且失败只告警不阻塞业务,执行记录见 [pikasTech/unidesk#2128](https://github.com/pikasTech/unidesk/issues/2128),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1.2_Task_Report.md)。 +##### R1.6.1.3 [in_progress] + +建立仅跟随产品 release 的 PikaOA 生产 PaC/Tekton/GitOps/Argo lane,以 fresh initializer 在 NC01 pikaoa namespace 启动 API/Worker/Web/附件 PVC,接入 PK01 host PostgreSQL、OTel/Prometheus 和 oa.pikapython.com TLS 公网入口,执行记录见 [pikasTech/unidesk#2129](https://github.com/pikasTech/unidesk/issues/2129),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1.3_Task_Report.md)。 ### R1.7 通过 CLI、OTel、Prometheus 和 https://oa.pikapython.com 桌面/移动原入口验证完整主路径,依赖 R1.6,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.7_Task_Report.md)。 From f5e666c71a426ac1dc54b072b0124de44860663c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:17:37 +0200 Subject: [PATCH 16/26] =?UTF-8?q?feat(pikaoa):=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=20release=20=E6=8E=A7=E5=88=B6=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/pikaoa.yaml | 166 ++++++++++++++++++ config/platform-infra/pipelines-as-code.yaml | 58 +++++- .../R1.6.1.3_Task_Report.md | 53 ++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 2 +- .../src/pikaoa-test-delivery-renderer.test.ts | 44 ++++- scripts/src/pikaoa-test-delivery-renderer.ts | 23 ++- scripts/src/pikaoa-test-runtime-manifest.ts | 16 +- scripts/src/pikaoa-test-target.ts | 35 +++- .../src/platform-infra-gitea-config.test.ts | 19 ++ scripts/src/platform-infra-gitea-remote.sh | 4 +- scripts/src/platform-infra-gitea.ts | 44 +---- ...-infra-pipelines-as-code-bootstrap.test.ts | 2 +- ...infra-pipelines-as-code-source-artifact.ts | 14 +- .../src/platform-infra-pipelines-as-code.ts | 3 +- 14 files changed, 413 insertions(+), 70 deletions(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.3_Task_Report.md diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index c4f4f167..b5eb3328 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -101,6 +101,15 @@ testRuntime: sourceKey: DATABASE_URL targetKey: DATABASE_URL runtime: + replicas: + api: 1 + worker: 1 + web: 1 + resources: + api: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 1, memory: 512Mi } } + worker: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 1, memory: 512Mi } } + web: { requests: { cpu: 50m, memory: 64Mi }, limits: { cpu: 500m, memory: 256Mi } } + initializer: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 1, memory: 512Mi } } secretName: pikaoa-test-runtime apiServiceName: pikaoa-api apiPort: 8080 @@ -174,6 +183,163 @@ testRuntime: livenessPath: /healthz readinessPath: /healthz +releaseRuntime: + defaultTargetId: NC01 + targets: + NC01: + enabled: true + validationOnly: false + node: NC01 + route: NC01:k3s + namespace: pikaoa + ttlSeconds: 86400 + waitTimeoutSeconds: 300 + fieldManager: unidesk-pikaoa-release-runtime + taskState: + rootPath: /var/lib/unidesk/pikaoa-release-runtime + statusRequestTimeoutSeconds: 10 + logTailLines: 80 + cleanup: + mode: delete-namespace + requireOwnershipLabels: true + source: + mode: prebuilt-images + repository: pikainc/pikaoa + commitPolicy: cli-required + branch: release + worktreeRemote: https://github.com/pikainc/pikaoa.git + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01 + images: + api: 127.0.0.1:5000/pikaoa/api:${commit} + worker: 127.0.0.1:5000/pikaoa/worker:${commit} + web: 127.0.0.1:5000/pikaoa/web:${commit} + initializer: 127.0.0.1:5000/pikaoa/api:${commit} + pullPolicy: IfNotPresent + ci: + lane: pikaoa + namespace: pikaoa-ci + pipeline: pikaoa-nc01-pac + pipelineRunPrefix: pikaoa-nc01 + serviceAccountName: pikaoa-nc01-tekton-runner + workspaceSize: 8Gi + pipelineTimeout: 20m0s + toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + buildkitImage: 127.0.0.1:5000/hwlab/buildkit:rootless + build: + dockerfiles: + api: deploy/api.Dockerfile + worker: deploy/worker.Dockerfile + web: deploy/web.Dockerfile + images: + api: 127.0.0.1:5000/pikaoa/api + worker: 127.0.0.1:5000/pikaoa/worker + web: 127.0.0.1:5000/pikaoa/web + networkMode: host + gitops: + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + writeUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + branch: nc01-pikaoa-gitops + manifestPath: deploy/gitops/nc01/resources.yaml + credential: + secretName: pac-gitea-pikaoa-nc01 + tokenKey: token + username: unidesk-admin + author: + name: PikaOA NC01 CI + email: pikaoa-nc01-ci@unidesk.local + database: + configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-database-url + name: pikaoa + username: pikaoa + schema: pikaoa + connection: + sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa.env + sourceKey: DATABASE_URL + targetKey: DATABASE_URL + runtime: + replicas: + api: 1 + worker: 1 + web: 1 + resources: + api: { requests: { cpu: 250m, memory: 256Mi }, limits: { cpu: 1, memory: 1Gi } } + worker: { requests: { cpu: 100m, memory: 256Mi }, limits: { cpu: 1, memory: 1Gi } } + web: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 500m, memory: 512Mi } } + initializer: { requests: { cpu: 250m, memory: 256Mi }, limits: { cpu: 1, memory: 1Gi } } + secretName: pikaoa-runtime + apiServiceName: pikaoa-api + apiPort: 8080 + workerMetricsPort: 8081 + webPort: 8080 + webApiUpstreamEnv: PIKAOA_API_UPSTREAM + attachment: + storageRoot: /var/lib/pikaoa/attachments + maxBytes: 104857600 + claimName: pikaoa-attachments + storageRequest: 20Gi + storageClassName: null + fsGroup: 65532 + accessModes: + - ReadWriteOnce + workerInterval: 2s + outbox: + batchSize: 50 + maxAttempts: 4 + retryDelay: 2s + sessionTTL: 8h + shutdownTimeout: 10s + administrator: + username: admin + password: + sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt + targetKey: PIKAOA_BOOTSTRAP_ADMIN_PASSWORD + employee: + username: employee + password: + sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt + targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD + sessionSecret: + sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa.env + sourceKey: PIKAOA_SESSION_SECRET + targetKey: PIKAOA_SESSION_SECRET + initializer: + mode: fresh-database-only + jobName: pikaoa-init + backoffLimit: 2 + command: + - /usr/local/bin/pikaoa + - migrate + - up + - --config + - /etc/pikaoa/pikaoa.yaml + exposure: + serviceName: pikaoa-web + serviceType: NodePort + hostIP: 152.53.229.148 + port: 32096 + observability: + configRef: config/platform-infra/observability.yaml + otlpEndpoint: otel-collector.platform-infra.svc.cluster.local:4317 + prometheus: + configRef: config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector + scrape: true + apiPath: /metrics + workerPath: /metrics + exporterFailure: + warning: true + blocking: false + probes: + api: + livenessPath: /healthz + readinessPath: /readyz + worker: + livenessPath: /healthz + readinessPath: /readyz + web: + livenessPath: /healthz + readinessPath: /healthz + delivery: targets: NC01: diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 8436c436..442649ef 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -252,6 +252,45 @@ repositories: runtime_service_port: "8080" health_path: /healthz health_url: http://pikaoa-web.pikaoa-test.svc.cluster.local:8080/healthz + - id: pikaoa-nc01 + name: pikaoa-nc01 + namespace: pikaoa-ci + providerType: gitea + url: https://gitea.pikapython.com/mirrors/pikainc-pikaoa + cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + owner: mirrors + repo: pikainc-pikaoa + secretName: pac-gitea-pikaoa-nc01 + tokenKey: token + webhookSecretKey: webhook.secret + concurrencyLimit: 1 + params: + git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + source_branch: release + source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01 + node: NC01 + pipeline_name: pikaoa-nc01-pac + pipeline_run_prefix: pikaoa-nc01 + service_account: pikaoa-nc01-tekton-runner + pipeline_timeout: 20m0s + workspace_pvc_size: 8Gi + image_repository: 127.0.0.1:5000/pikaoa/api + api_image_repository: 127.0.0.1:5000/pikaoa/api + worker_image_repository: 127.0.0.1:5000/pikaoa/worker + web_image_repository: 127.0.0.1:5000/pikaoa/web + registry_probe_base: http://127.0.0.1:5000 + gitops_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + gitops_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + gitops_branch: nc01-pikaoa-gitops + gitops_username: unidesk-admin + gitops_secret_name: pac-gitea-pikaoa-nc01 + gitops_manifest_path: deploy/gitops/nc01/resources.yaml + runtime_namespace: pikaoa + runtime_deployment: pikaoa-api + runtime_service: pikaoa-web + runtime_service_port: "8080" + health_path: /healthz + health_url: http://pikaoa-web.pikaoa.svc.cluster.local:8080/healthz consumers: - extends: templates.consumers.agentrunV02 variables: @@ -509,7 +548,7 @@ consumers: dnsPolicy: ClusterFirstWithHostNet fsGroup: 1000 - id: pikaoa-nc01 - repositoryRef: pikaoa-test-nc01 + repositoryRef: pikaoa-nc01 node: NC01 lane: pikaoa namespace: pikaoa-ci @@ -541,10 +580,27 @@ consumers: repositoryCredential: secretName: argocd-repo-pikaoa-nc01 username: unidesk-admin + deliveryProvenance: + required: true + markerValue: admission-pac-v2:pikaoa-nc01 + executionServiceAccountName: pikaoa-nc01-tekton-runner + gitOps: + repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + targetRevision: nc01-pikaoa-gitops runnerServiceAccount: name: pikaoa-nc01-tekton-runner automountServiceAccountToken: false roleBindingName: pikaoa-nc01-tekton-runner + sourceArtifact: + mode: embedded-pipeline-spec + renderer: pikaoa-release-runtime + configRef: config/pikaoa.yaml#releaseRuntime.targets.NC01 + pipelineRunPath: .tekton/pikaoa-nc01-pac.yaml + maxKeepRuns: 8 + taskRunTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + fsGroup: 1000 templates: repositories: agentrunV02: diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.3_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.3_Task_Report.md new file mode 100644 index 00000000..ccca5f78 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.3_Task_Report.md @@ -0,0 +1,53 @@ +# R1.6.1.3 任务报告 + +## 任务回链 + +- MDTODO:`R1.6.1.3`。 +- GitHub:`pikasTech/unidesk#2129`、`pikasTech/unidesk#2110`。 +- Target:`NC01`。 +- 交付边界:本任务只完成生产 release 控制面、只读计划与定向测试,不执行生产 mutation。 + +## 已完成 + +- `config/pikaoa.yaml#releaseRuntime.targets.NC01` 成为生产运行面唯一 owning selector: + - namespace、单副本、resources、附件 PVC、Service、OTel、Prometheus、release source、fresh initializer policy 均由 YAML 声明; + - Secret 继续引用既有受控 distribution 与 PK01 PostgreSQL export,不打印值; + - 公网 hostname、FRP port、Caddy/TLS 事实保留在同一生产 delivery 声明。 +- `config/platform-infra/pipelines-as-code.yaml` 新增独立 `pikaoa-nc01` Repository/consumer: + - 只跟随 `release` 与 `refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01`; + - 与 `pikaoa-test-nc01/master` 的 Secret、PipelineRun、GitOps branch、Argo application 完全隔离; + - production sourceArtifact 使用 `pikaoa-release-runtime` renderer 生成 `.tekton/pikaoa-nc01-pac.yaml`。 +- test/release 共用 renderer 已修复真实 PaC 缺陷: + - TaskSpec workspace 声明 `mountPath: /workspace`,step 不再手工挂载 workspace,token Secret mounts 保留; + - BuildKit metadata 解析允许 JSON 冒号两侧空白,只接受 `containerimage.digest` 的完整 `sha256:<64hex>`; + - metadata 不可读时使用本地 registry manifest `Docker-Content-Digest` 受控兜底。 +- Gitea mirror webhook status 只解析选中 repository 的 credential 与 topology: + - `--repo pikaoa-nc01` 不再读取未选 `pikaoa-test-nc01` token; + - 现场状态显示 release commit `07a9a9db3cad` 已 committed。 + +## 验证证据 + +- `bun test scripts/src/pikaoa-test-delivery-renderer.test.ts scripts/src/platform-infra-gitea-config.test.ts scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts`:`24 pass / 0 fail`。 +- `platform-infra pipelines-as-code plan --target NC01 --consumer pikaoa-nc01`:识别独立 production Repository、consumer、Tekton 与 Argo 路径。 +- `platform-infra gitea mirror webhook status --target NC01 --repo pikaoa-nc01`:`READY=true`,release head、Gitea 与 snapshot 均为 `07a9a9db3cad`。 +- source-artifact `plan/check`:desired hash 已生成;产品 `.tekton/pikaoa-nc01-pac.yaml` 尚未写入,符合本任务禁止直接编辑固定产品主 worktree 的边界。 + +## 主代理后续命令 + +在 `/root/pikaoa` clean worktree 审核并写入产品 release artifact: + +```bash +bun scripts/cli.ts platform-infra pipelines-as-code source-artifact plan --target NC01 --consumer pikaoa-nc01 --source-worktree /root/pikaoa +bun scripts/cli.ts platform-infra pipelines-as-code source-artifact write --target NC01 --consumer pikaoa-nc01 --source-worktree /root/pikaoa --confirm +bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer pikaoa-nc01 --source-worktree /root/pikaoa +``` + +产品 artifact 提交、正常 release PR merge 后,由主代理执行只读 bootstrap 计划并按正常入口触发交付: + +```bash +bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01 --repo pikaoa-nc01 +bun scripts/cli.ts platform-infra pipelines-as-code plan --target NC01 --consumer pikaoa-nc01 +bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01 --consumer pikaoa-nc01 +``` + +交付完成后再按原入口验收 `pikaoa` namespace、initializer、API/Worker/Web、附件 PVC、OTel/Prometheus 与 `https://oa.pikapython.com`;不得人工创建 PipelineRun、Argo refresh 或生产 namespace mutation。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index efb705f1..02b6cb97 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -148,7 +148,7 @@ ##### R1.6.1.2 为 NC01 pikaoa namespace 的生产附件 PVC 建立 YAML-first Restic 离机备份、受控状态与 restore smoke,复用 PK01 /var/backups/unidesk 且失败只告警不阻塞业务,执行记录见 [pikasTech/unidesk#2128](https://github.com/pikasTech/unidesk/issues/2128),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1.2_Task_Report.md)。 -##### R1.6.1.3 [in_progress] +##### R1.6.1.3 [completed] 建立仅跟随产品 release 的 PikaOA 生产 PaC/Tekton/GitOps/Argo lane,以 fresh initializer 在 NC01 pikaoa namespace 启动 API/Worker/Web/附件 PVC,接入 PK01 host PostgreSQL、OTel/Prometheus 和 oa.pikapython.com TLS 公网入口,执行记录见 [pikasTech/unidesk#2129](https://github.com/pikasTech/unidesk/issues/2129),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1.3_Task_Report.md)。 ### R1.7 diff --git a/scripts/src/pikaoa-test-delivery-renderer.test.ts b/scripts/src/pikaoa-test-delivery-renderer.test.ts index d0bc0503..003b2eb2 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.test.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.test.ts @@ -9,13 +9,13 @@ function pacDocument(): Record { return Bun.YAML.parse(readFileSync(resolve(import.meta.dir, "../../config/platform-infra/pipelines-as-code.yaml"), "utf8")) as Record; } -function rendererBinding(): Parameters[0] { +function rendererBinding(id = "pikaoa-test-nc01"): Parameters[0] { const document = pacDocument(); const repositories = document.repositories as Array>; const consumers = document.consumers as Array>; - const repository = repositories.find((item) => item.id === "pikaoa-test-nc01"); - const consumer = consumers.find((item) => item.id === "pikaoa-test-nc01"); - if (repository === undefined || consumer === undefined) throw new Error("PikaOA test PaC fixture is required"); + const consumer = consumers.find((item) => item.id === id); + const repository = repositories.find((item) => item.id === consumer?.repositoryRef); + if (repository === undefined || consumer === undefined) throw new Error(`PikaOA PaC fixture is required: ${id}`); return { consumer, repository } as Parameters[0]; } @@ -34,12 +34,31 @@ describe("PikaOA test PaC renderer", () => { const repository = repositories.find((item) => item.id === "pikaoa-test-nc01"); const testConsumer = consumers.find((item) => item.id === "pikaoa-test-nc01"); const productionConsumer = consumers.find((item) => item.id === "pikaoa-nc01"); + const productionRepository = repositories.find((item) => item.id === "pikaoa-nc01"); expect((repository?.params as Record).source_branch).toBe("master"); expect((testConsumer?.sourceArtifact as Record).pipelineRunPath).toBe(".tekton/pikaoa-test-nc01-pac.yaml"); expect((testConsumer?.sourceArtifact as Record).renderer).toBe("pikaoa-test-runtime"); expect((productionConsumer?.params as Record).source_branch).toBe("release"); expect((productionConsumer?.params as Record).source_snapshot_prefix).toBe("refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01"); - expect(productionConsumer?.sourceArtifact).toBeUndefined(); + expect(productionConsumer?.repositoryRef).toBe("pikaoa-nc01"); + expect((productionRepository?.params as Record).source_branch).toBe("release"); + expect((productionConsumer?.sourceArtifact as Record).renderer).toBe("pikaoa-release-runtime"); + expect((productionConsumer?.sourceArtifact as Record).configRef).toBe("config/pikaoa.yaml#releaseRuntime.targets.NC01"); + }); + + test("renders release runtime from its isolated owning selector", () => { + const source = sourceFixture(); + try { + const rendered = renderPikaoaTestDesiredPipeline(rendererBinding("pikaoa-nc01"), source); + expect(rendered.configRef).toBe("config/pikaoa.yaml#releaseRuntime.targets.NC01"); + const serialized = JSON.stringify(rendered.pipeline); + expect(serialized).toContain("pikaoa-nc01-pac"); + expect(serialized).toContain("refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01"); + expect(serialized).toContain("pac-gitea-pikaoa-nc01"); + expect(serialized).not.toContain("pikaoa-test-nc01-pac"); + } finally { + rmSync(source, { recursive: true, force: true }); + } }); test("renders parallel digest builds and a PreSync fresh database initializer", () => { @@ -65,15 +84,30 @@ describe("PikaOA test PaC renderer", () => { expect(JSON.stringify(task)).not.toContain("source-token"); const buildScript = (((task?.taskSpec as Record).steps as Array>)[0]?.script) as string; expect(buildScript).toContain('"containerimage.digest"[[:space:]]*:[[:space:]]*"\\(sha256:[0-9a-f]\\{64\\}\\)"'); + expect(buildScript).toContain(".*/\\1/p"); + expect(buildScript).not.toContain("\u0001"); expect(buildScript).toContain("Docker-Content-Digest:"); expect(buildScript).toContain("build_status=$?"); expect(buildScript).toContain('"code":"buildctl-nonzero-artifact-verified"'); expect(buildScript).toContain('"artifactVerified":true'); } + const digest = "sha256:" + "a".repeat(64); + const metadata = JSON.stringify({ "containerimage.digest": digest }, null, 2); + const parsedDigest = /"containerimage\.digest"\s*:\s*"(sha256:[0-9a-f]{64})"/u.exec(metadata)?.[1]; + expect(parsedDigest).toBe(digest); const sourceTask = tasks.find((item) => item.name === "source"); expect(JSON.stringify(sourceTask)).toContain("source-token"); expect(JSON.stringify(sourceTask)).toContain("GIT_ASKPASS"); expect(JSON.stringify(sourceTask)).toContain("$(params.gitops-secret-name)"); + for (const task of tasks) { + const taskSpec = task.taskSpec as Record | undefined; + if (taskSpec === undefined) continue; + expect(taskSpec.workspaces).toEqual([{ name: "workspace", mountPath: "/workspace" }]); + const steps = taskSpec.steps as Array>; + for (const step of steps) { + expect((step.volumeMounts as Array> | undefined)?.some((mount) => mount.name === "workspace") ?? false).toBe(false); + } + } const publish = tasks.find((item) => item.name === "gitops-publish"); expect(publish?.runAfter).toEqual(["build-api", "build-worker", "build-web"]); const script = ((((publish?.taskSpec as Record).steps as Array>)[0]?.script) as string); diff --git a/scripts/src/pikaoa-test-delivery-renderer.ts b/scripts/src/pikaoa-test-delivery-renderer.ts index 94af7c24..b3d760f7 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.ts @@ -3,6 +3,8 @@ import { resolve } from "node:path"; import { pikaoaTestRuntimeConfigRef, + pikaoaReleaseRuntimeConfigRef, + resolvePikaoaReleaseRuntimeTargetByConfigRef, resolvePikaoaTestRuntimeTargetByConfigRef, type TestTargetSpec, } from "./pikaoa-test-target"; @@ -37,10 +39,13 @@ export interface PikaoaTestRenderedPipeline { } export function renderPikaoaTestDesiredPipeline(binding: PikaoaTestRendererBinding, sourceWorktree: string): PikaoaTestRenderedPipeline { - const target = resolvePikaoaTestRuntimeTargetByConfigRef(binding.consumer.sourceArtifact.configRef); - const configRef = pikaoaTestRuntimeConfigRef(target.id); - if (binding.consumer.sourceArtifact.renderer !== "pikaoa-test-runtime" || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") { - throw new Error("pikaoa-test-runtime requires embedded-pipeline-spec"); + const release = binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime"; + const target = release + ? resolvePikaoaReleaseRuntimeTargetByConfigRef(binding.consumer.sourceArtifact.configRef) + : resolvePikaoaTestRuntimeTargetByConfigRef(binding.consumer.sourceArtifact.configRef); + const configRef = release ? pikaoaReleaseRuntimeConfigRef(target.id) : pikaoaTestRuntimeConfigRef(target.id); + if ((!release && binding.consumer.sourceArtifact.renderer !== "pikaoa-test-runtime") || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") { + throw new Error(`${binding.consumer.sourceArtifact.renderer} requires embedded-pipeline-spec`); } if (binding.consumer.sourceArtifact.configRef !== configRef) { throw new Error(`sourceArtifact.configRef must equal resolved owning selector ${configRef}`); @@ -59,6 +64,10 @@ export function pikaoaTestSourceWorktreeRemote(targetId: string): string { return resolvePikaoaTestRuntimeTargetByConfigRef(pikaoaTestRuntimeConfigRef(targetId)).source.worktreeRemote; } +export function pikaoaReleaseSourceWorktreeRemote(targetId: string): string { + return resolvePikaoaReleaseRuntimeTargetByConfigRef(pikaoaReleaseRuntimeConfigRef(targetId)).source.worktreeRemote; +} + function pipeline(target: TestTargetSpec): Record { const runtimeManifest = renderGitOpsManifest(target); return { @@ -280,7 +289,7 @@ git -C /workspace/gitops add -- ${shellSingleQuote(target.gitops.manifestPath)} if git -C /workspace/gitops diff --cached --quiet; then changed=false else - git -C /workspace/gitops commit -m "chore(gitops): deploy pikaoa test $SOURCE_COMMIT" + git -C /workspace/gitops commit -m "chore(gitops): deploy pikaoa $SOURCE_COMMIT" git -C /workspace/gitops push "$(params.gitops-write-url)" HEAD:${shellSingleQuote(target.gitops.branch)} changed=true fi @@ -329,7 +338,7 @@ function renderGitOpsManifest(target: TestTargetSpec): string { function assertBinding(target: TestTargetSpec, binding: PikaoaTestRendererBinding): void { const expected: Readonly> = { - id: "pikaoa-test-nc01", + id: target.source.branch === "release" ? "pikaoa-nc01" : "pikaoa-test-nc01", node: target.node, lane: target.ci.lane, namespace: target.ci.namespace, @@ -351,7 +360,7 @@ function assertBinding(target: TestTargetSpec, binding: PikaoaTestRendererBindin serviceAccount: binding.repository.params.service_account, }; for (const [key, value] of Object.entries(expected)) { - if (actual[key] !== value) throw new Error(`pikaoa-test-runtime binding ${key} must equal owning YAML value ${value}`); + if (actual[key] !== value) throw new Error(`PikaOA runtime binding ${key} must equal owning YAML value ${value}`); } } diff --git a/scripts/src/pikaoa-test-runtime-manifest.ts b/scripts/src/pikaoa-test-runtime-manifest.ts index a688348e..2a2e8567 100644 --- a/scripts/src/pikaoa-test-runtime-manifest.ts +++ b/scripts/src/pikaoa-test-runtime-manifest.ts @@ -27,8 +27,6 @@ export interface PikaoaTestManifestOptions { initializerHook?: "argo-presync"; } -const MANAGED_BY = "unidesk-pikaoa-test-target"; -const TEST_RUNTIME_LABEL = "pikaoa.unidesk.io/test-runtime"; const TARGET_LABEL = "pikaoa.unidesk.io/target"; const INSTANCE_LABEL = "pikaoa.unidesk.io/instance"; @@ -97,6 +95,7 @@ export function renderPikaoaTestRuntimeManifest( containers: [{ name: "initializer", image: context.initializerImage, imagePullPolicy: target.source.pullPolicy, command: target.initializer.command, + resources: target.runtime.resources.initializer, env: [{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }], volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }], }], @@ -136,11 +135,12 @@ export function pikaoaManifestForStep(manifest: Record[], step: } export function ownershipLabels(target: TestTargetSpec, context: PikaoaTestRenderContext): Record { + const release = target.source.branch === "release"; return { "app.kubernetes.io/name": "pikaoa", - "app.kubernetes.io/part-of": "pikaoa-test-runtime", - "app.kubernetes.io/managed-by": MANAGED_BY, - [TEST_RUNTIME_LABEL]: "true", + "app.kubernetes.io/part-of": release ? "pikaoa-release-runtime" : "pikaoa-test-runtime", + "app.kubernetes.io/managed-by": target.fieldManager, + [release ? "pikaoa.unidesk.io/release-runtime" : "pikaoa.unidesk.io/test-runtime"]: "true", [TARGET_LABEL]: target.id, [INSTANCE_LABEL]: context.instanceId, }; @@ -191,13 +191,13 @@ function deployment( return { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: `pikaoa-${component}`, namespace: context.namespace, labels: { ...labels, ...selector } }, spec: { - replicas: 1, selector: { matchLabels: selector }, + replicas: target.runtime.replicas[component], selector: { matchLabels: selector }, template: { metadata: { labels: { ...labels, ...selector, ...podLabels }, annotations }, spec: { ...(component === "api" ? { securityContext: { fsGroup: target.runtime.attachment.fsGroup } } : {}), containers: [{ - name: component, image, imagePullPolicy: target.source.pullPolicy, env, + name: component, image, imagePullPolicy: target.source.pullPolicy, env, resources: target.runtime.resources[component], ports: [{ name: component === "worker" ? "metrics" : "http", containerPort: port }], volumeMounts: [ ...(component === "web" ? [] : [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }]), @@ -241,7 +241,7 @@ function runtimeSecretData(target: TestTargetSpec, context: PikaoaTestRenderCont ` storageRoot: ${JSON.stringify(target.runtime.attachment.storageRoot)}`, ` maxBytes: ${target.runtime.attachment.maxBytes}`, "observability:", - ` serviceName: pikaoa-test-${context.instanceId}`, + ` serviceName: ${target.source.branch === "release" ? "pikaoa" : `pikaoa-test-${context.instanceId}`}`, ` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`, `shutdownTimeout: ${target.runtime.shutdownTimeout}`, "", diff --git a/scripts/src/pikaoa-test-target.ts b/scripts/src/pikaoa-test-target.ts index 89fe6184..b99f5b8c 100644 --- a/scripts/src/pikaoa-test-target.ts +++ b/scripts/src/pikaoa-test-target.ts @@ -118,6 +118,8 @@ export interface TestTargetSpec { connection: SecretSourceSpec; }; runtime: { + replicas: { api: number; worker: number; web: number }; + resources: Record<"api" | "worker" | "web" | "initializer", Record>; secretName: string; apiServiceName: string; apiPort: number; @@ -318,8 +320,8 @@ function readSelection(options: TestTargetOptions): Selection { }; } -function parseTarget(id: string, root: Record, configLabel: string): TestTargetSpec { - const path = `${configLabel}.testRuntime.targets.${id}`; +function parseTarget(id: string, root: Record, configLabel: string, runtimeSection = "testRuntime"): TestTargetSpec { + const path = `${configLabel}.${runtimeSection}.targets.${id}`; simpleId(id, "target id"); const source = record(root.source, `${path}.source`); const images = record(source.images, `${path}.source.images`); @@ -332,6 +334,8 @@ function parseTarget(id: string, root: Record, configLabel: str const gitopsAuthor = record(gitops.author, `${path}.gitops.author`); const database = record(root.database, `${path}.database`); const runtime = record(root.runtime, `${path}.runtime`); + const replicas = record(runtime.replicas, `${path}.runtime.replicas`); + const resources = record(runtime.resources, `${path}.runtime.resources`); const attachment = record(runtime.attachment, `${path}.runtime.attachment`); const outbox = record(runtime.outbox, `${path}.runtime.outbox`); const administrator = record(runtime.administrator, `${path}.runtime.administrator`); @@ -426,6 +430,17 @@ function parseTarget(id: string, root: Record, configLabel: str connection: secretSource(database.connection, "database.connection", `${path}.database.connection`, true), }, runtime: { + replicas: { + api: positiveInteger(replicas.api, `${path}.runtime.replicas.api`, 20), + worker: positiveInteger(replicas.worker, `${path}.runtime.replicas.worker`, 20), + web: positiveInteger(replicas.web, `${path}.runtime.replicas.web`, 20), + }, + resources: { + api: record(resources.api, `${path}.runtime.resources.api`), + worker: record(resources.worker, `${path}.runtime.resources.worker`), + web: record(resources.web, `${path}.runtime.resources.web`), + initializer: record(resources.initializer, `${path}.runtime.resources.initializer`), + }, secretName: kubernetesName(nonEmpty(runtime.secretName, `${path}.runtime.secretName`), `${path}.runtime.secretName`, 63), apiServiceName: kubernetesName(nonEmpty(runtime.apiServiceName, `${path}.runtime.apiServiceName`), `${path}.runtime.apiServiceName`, 63), apiPort: positiveInteger(runtime.apiPort, `${path}.runtime.apiPort`, 65_535), @@ -505,7 +520,21 @@ export function resolvePikaoaTestRuntimeTargetByConfigRef(configRef: string): Te if (resolved.file !== "config/pikaoa.yaml" || match === null) { throw new Error("PikaOA test runtime configRef must select config/pikaoa.yaml#testRuntime.targets."); } - return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file); + return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file, "testRuntime"); +} + +export function pikaoaReleaseRuntimeConfigRef(targetId: string): string { + simpleId(targetId, "PikaOA release runtime target id"); + return `config/pikaoa.yaml#releaseRuntime.targets.${targetId}`; +} + +export function resolvePikaoaReleaseRuntimeTargetByConfigRef(configRef: string): TestTargetSpec { + const resolved = resolveConfigRef(configRef, "PikaOA release runtime configRef"); + const match = /^releaseRuntime\.targets\.([A-Za-z0-9._-]+)$/u.exec(resolved.fragment); + if (resolved.file !== "config/pikaoa.yaml" || match === null) { + throw new Error("PikaOA release runtime configRef must select config/pikaoa.yaml#releaseRuntime.targets."); + } + return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file, "releaseRuntime"); } function planPayload(options: TestTargetOptions, selection: Selection): Record { diff --git a/scripts/src/platform-infra-gitea-config.test.ts b/scripts/src/platform-infra-gitea-config.test.ts index bce72fc8..45c9fe65 100644 --- a/scripts/src/platform-infra-gitea-config.test.ts +++ b/scripts/src/platform-infra-gitea-config.test.ts @@ -103,6 +103,25 @@ describe("platform-infra Gitea repository GitHub credentials", () => { expect(JSON.stringify(result)).not.toContain(config.sourceAuthority.credentials.github.sourceRef); }); + test("production PikaOA status scope never reads the unselected master credential", () => { + const config = readGiteaConfig(); + const production = config.sourceAuthority.repositories.find((repo) => repo.key === "pikaoa-nc01"); + expect(production).toBeDefined(); + const requested: string[] = []; + const result = buildGiteaMirrorBootstrapCredentialPreflight(config, [production!], (credential) => { + requested.push(credential.gitFetchCredential.secretRef.key); + return { [credential.sourceKey]: "fixture-release-token" }; + }); + + expect(result.blockers).toEqual([]); + expect(requested).toEqual(["github-token-pikainc-pikaoa-release"]); + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0]).toMatchObject({ + id: "github-upstream:pikaoa-nc01", + valuesPrinted: false, + }); + }); + test("does not inject the NC01 selfmedia token into JD01 bridge containers", () => { const config = readGiteaConfig(); const selfmediaTokenEnv = githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia"); diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index f91c62f1..340c5450 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -1074,7 +1074,9 @@ rows = [] for repo in repos: repository = repo["upstream"]["repository"] topology_spec = next((item for item in topology_repositories if isinstance(item, dict) and item.get("repository") == repository), {}) - token = os.environ[topology_spec.get("githubTokenEnv") or os.environ["UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"]] + credential_spec = repo.get("githubCredential") or {} + token_env = credential_spec.get("tokenEnv") or topology_spec.get("githubTokenEnv") or os.environ["UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"] + token = os.environ[token_env] branch = repo["upstream"]["branch"] result = request(f"/repos/{repository}/hooks") hooks_result = parse_github_hooks_response(result.get("ok"), result.get("status"), result.get("body")) diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 2e85410c..0a7ffe78 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -1182,6 +1182,9 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string { function remoteScript(action: "apply" | "status" | "validate" | "mirror-preflight" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record } { const frpcExposure = targetFrpcExposure(gitea, target); const sync = targetWebhookSync(gitea, target); + const selectedRepositories = params.repos ?? repositoriesForTarget(gitea, target); + const selectedCredentials = githubCredentialsForConsumers(gitea, target, selectedRepositories, false); + const defaultCredential = selectedCredentials[0] ?? gitea.sourceAuthority.credentials.github; const env: Record = { UNIDESK_GITEA_ACTION: action, UNIDESK_GITEA_TARGET_ID: target.id, @@ -1201,9 +1204,9 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-prefligh UNIDESK_GITEA_ROOT_URL: gitea.app.server.rootUrl, UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0", UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url, - UNIDESK_GITEA_GITHUB_SECRET_KEY: gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key, - UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV: githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key), - UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP: githubCredentialsForConsumers(gitea, target, repositoriesForTarget(gitea, target), true) + UNIDESK_GITEA_GITHUB_SECRET_KEY: defaultCredential.gitFetchCredential.secretRef.key, + UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV: githubTokenEnvName(defaultCredential.gitFetchCredential.secretRef.key), + UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP: selectedCredentials .map((credential) => `${credential.gitFetchCredential.secretRef.key}=${githubTokenEnvName(credential.gitFetchCredential.secretRef.key)}`) .join(","), UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","), @@ -1702,10 +1705,7 @@ function webhookSyncSummary(gitea: GiteaConfig, target: GiteaTarget): Record> { const adminPath = credentialPath(gitea, gitea.sourceAuthority.credentials.admin.sourceRef); - const github = gitea.sourceAuthority.credentials.github; - const githubPath = credentialPath(gitea, github.sourceRef); const adminLinePair = existsSync(adminPath) ? parseLinePairCredential(adminPath, gitea.sourceAuthority.credentials.admin) : { username: null, password: null }; - const githubEnv = existsSync(githubPath) ? parseGithubCredentialFile(githubPath, github) : {}; const summaries: Array> = [ { id: "gitea-admin", @@ -1716,36 +1716,8 @@ function credentialSummaries(gitea: GiteaConfig, repositories = gitea.sourceAuth permissionResult: { status: "not-applicable", error: null }, valuesPrinted: false, }, - { - id: "github-upstream", - sourceRef: github.sourceRef, - present: existsSync(githubPath), - requiredKeysPresent: Boolean(githubEnv[github.sourceKey]), - fingerprint: fingerprintKeys(githubEnv, [github.sourceKey]), - permissionResult: existsSync(githubPath) && Boolean(githubEnv[github.sourceKey]) - ? { status: "not-probed", error: null } - : { status: "blocked-missing-credential", error: "credential material is absent" }, - valuesPrinted: false, - }, ]; - for (const repo of repositories) { - const githubOverride = repo.credentialOverride?.github; - if (githubOverride === undefined) continue; - const sourcePath = credentialPath(gitea, githubOverride.sourceRef); - const values = existsSync(sourcePath) ? parseGithubCredentialFile(sourcePath, githubOverride) : {}; - summaries.push({ - id: `github-upstream:${repo.key}`, - repository: repo.upstream.repository, - sourceRef: githubOverride.sourceRef, - present: existsSync(sourcePath), - requiredKeysPresent: Boolean(values[githubOverride.sourceKey]), - fingerprint: fingerprintKeys(values, [githubOverride.sourceKey]), - permissionResult: existsSync(sourcePath) && Boolean(values[githubOverride.sourceKey]) - ? { status: "not-probed", permissions: githubOverride.permissions, error: null } - : { status: "blocked-missing-credential", permissions: githubOverride.permissions, error: "credential material is absent" }, - valuesPrinted: false, - }); - } + summaries.push(...buildGiteaMirrorBootstrapCredentialPreflight(gitea, repositories).credentials); return summaries; } @@ -1803,7 +1775,7 @@ function githubCredentialsForConsumers( repositories: GiteaMirrorRepository[], includeHookOwnerCredentials: boolean, ): GiteaGithubCredential[] { - const credentials = [gitea.sourceAuthority.credentials.github, ...repositories.flatMap((repo) => repo.credentialOverride?.github ?? [])]; + const credentials = repositories.map((repo) => githubCredentialForRepo(gitea, repo)); if (includeHookOwnerCredentials && gitea.sourceAuthority.webhookSync.hookReconcile.ownerTargetId.toLowerCase() === target.id.toLowerCase()) { credentials.push(...gitea.sourceAuthority.repositories.flatMap((repo) => { diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index bc0a0982..99dd5965 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -133,7 +133,7 @@ test("unselected parse and repository errors remain warnings while selected obje evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u), }); expect(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-test-nc01")).toBe(false); - expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).toThrow("cloneUrl"); + expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).not.toThrow(); const duplicateSelectedRepository = pacConfigDocument(); const repositories = duplicateSelectedRepository.repositories as Array>; diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index ba6054f8..aaae95b3 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -19,10 +19,10 @@ import { stableJsonSha256, stableJsonValue } from "./stable-json"; import { pipelineProvenanceAnnotations, pipelineProvenanceFromManifest, withPipelineProvenanceAnnotations } from "./pipeline-provenance"; import { renderSub2RankDesiredPipeline, sub2RankOwningSourceRemote } from "./platform-infra-sub2rank-pipeline"; import { renderSelfMediaDesiredPipeline, selfMediaSourceWorktreeRemote } from "./selfmedia-delivery-renderer"; -import { renderPikaoaTestDesiredPipeline, pikaoaTestSourceWorktreeRemote } from "./pikaoa-test-delivery-renderer"; +import { renderPikaoaTestDesiredPipeline, pikaoaReleaseSourceWorktreeRemote, pikaoaTestSourceWorktreeRemote } from "./pikaoa-test-delivery-renderer"; export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation"; -export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime" | "pikaoa-test-runtime"; +export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime" | "pikaoa-test-runtime" | "pikaoa-release-runtime"; export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime"; export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable"; @@ -593,7 +593,7 @@ function renderPikaoaTestPipeline(binding: PacSourceArtifactBinding, sourceWorkt provenance: { configRef: rendered.configRef, effectiveConfigSha256: rendered.effectiveConfigSha256, - renderer: "pikaoa-test-runtime", + renderer: binding.consumer.sourceArtifact.renderer, mode: "embedded-pipeline-spec", }, }; @@ -852,7 +852,7 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor if (name === "source-stage-ref") return { name, value: "{{ source_snapshot_prefix }}/{{ revision }}" }; if (name === "git-url") return { name, value: "{{ repo_url }}" }; const repositoryParam = name.replaceAll("-", "_"); - if ((binding.consumer.sourceArtifact.renderer === "selfmedia-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime") && Object.prototype.hasOwnProperty.call(param, "default")) { + if ((binding.consumer.sourceArtifact.renderer === "selfmedia-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime") && Object.prototype.hasOwnProperty.call(param, "default")) { return { name, value: param.default }; } if (Object.prototype.hasOwnProperty.call(binding.repository.params, repositoryParam)) { @@ -867,7 +867,7 @@ export function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desired if (desiredSpec.workspaces === undefined) return []; return arrayRecords(desiredSpec.workspaces, "Pipeline.spec.workspaces").map((workspace) => { const name = requiredString(workspace.name, "Pipeline.spec.workspaces[].name"); - if (name === "source" || (name === "workspace" && binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime")) { + if (name === "source" || (name === "workspace" && (binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime"))) { return { name, volumeClaimTemplate: { @@ -946,6 +946,7 @@ function owningSourceRemote(binding: PacSourceArtifactBinding): string { if (binding.consumer.sourceArtifact.renderer === "sub2rank-platform-service") return sub2RankOwningSourceRemote(); if (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime") return selfMediaSourceWorktreeRemote(binding.consumer.node); if (binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime") return pikaoaTestSourceWorktreeRemote(binding.consumer.node); + if (binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime") return pikaoaReleaseSourceWorktreeRemote(binding.consumer.node); if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new PacSourceArtifactError("owning-source-lane-unresolved"); return hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).gitUrl; } @@ -1065,7 +1066,8 @@ export function pacSourceArtifactProvenanceFromManifest(manifest: Record, path: s function parseSourceArtifact(value: Record, path: string): PacSourceArtifactSpec { const mode = y.enumField(value, "mode", path, ["embedded-pipeline-spec", "remote-pipeline-annotation"] as const); - const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime", "pikaoa-test-runtime"] as const); + const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime", "pikaoa-test-runtime", "pikaoa-release-runtime"] as const); const pipelineRunPath = sourceArtifactPath(y.stringField(value, "pipelineRunPath", path), `${path}.pipelineRunPath`); const pipelinePath = value.pipelinePath === undefined ? null : sourceArtifactPath(y.stringField(value, "pipelinePath", path), `${path}.pipelinePath`); const taskRunTemplate = y.objectField(value, "taskRunTemplate", path); @@ -812,6 +812,7 @@ function parseSourceArtifact(value: Record, path: string): PacS if (renderer === "sub2rank-platform-service" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer sub2rank-platform-service requires embedded-pipeline-spec`); if (renderer === "selfmedia-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer selfmedia-runtime requires embedded-pipeline-spec`); if (renderer === "pikaoa-test-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer pikaoa-test-runtime requires embedded-pipeline-spec`); + if (renderer === "pikaoa-release-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer pikaoa-release-runtime requires embedded-pipeline-spec`); return { mode, renderer, From 3f48b61fc7fb93b2261b9bfb4fc00247df4b6be5 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 19:16:39 +0200 Subject: [PATCH 17/26] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20Sub2API=20Gr?= =?UTF-8?q?ok=20=E6=8E=A5=E5=85=A5=E8=B0=83=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../R2.2.37_Task_Report.md | 94 +++++++++++++++++++ docs/MDTODO/sub2api-upstream-reliability.md | 3 + 2 files changed, 97 insertions(+) create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.2.37_Task_Report.md diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.37_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.37_Task_Report.md new file mode 100644 index 00000000..781cfcb7 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.37_Task_Report.md @@ -0,0 +1,94 @@ +# R2.2.37 Sub2API 原生 Grok 接入 Codex 调查报告 + +## 调查边界 + +本任务只读检查 `Wei-Shaw/sub2api` 官方仓库最新发布标签、主线源码、发布说明、issue 和合并 PR;未修改 PK01 runtime、YAML、源码或部署。 + +## 版本基线 + +- 官方仓库最新发布标签为 `v0.1.155`,提交 `41cec0db059ffb82d0efdcfcf07a24ab51fbfe97`,发布时间为 2026-07-14。 +- 官方最新发布说明:。 +- `v0.1.155` 已包含原生 Grok provider、Grok OAuth、xAI API Key、Codex Responses 兼容、Grok 渠道健康监控和近期 Grok 兼容修复。 + +## 核心结论 + +最新 Sub2API 已原生支持 Grok 上游接入 Codex,普通接入不需要修改 Sub2API 源码。正确链路是: + +```text +Codex CLI + -> Sub2API /v1/responses + -> Sub2API API Key 绑定的 platform=grok 分组 + -> platform=grok 的 OAuth 或 API Key 账号 + -> OAuth: cli-chat-proxy.grok.com/v1/responses + API Key: api.x.ai/v1/responses +``` + +不要把 Grok 配置成 `platform=openai` 的自定义 base URL。原生 Grok 路径负责 OAuth 刷新、CLI 身份头、请求字段净化、模型映射、配额观察、429 冷却和 Grok 专属健康监控;伪装成 OpenAI 会绕过这些能力。 + +## 官方源码证据 + +### 路由与分组选择 + +- `backend/internal/server/routes/gateway.go` 把 `PlatformOpenAI` 和 `PlatformGrok` 都识别为 OpenAI Responses 兼容平台。 +- `/v1/responses`、`/responses` 和 `/backend-api/codex/responses` 均可由 Grok 分组进入 OpenAI gateway。 +- API Key 所绑定分组的平台决定请求走哪个 provider,因此客户端使用的是 Sub2API API Key,不是直接使用 xAI Key。 + +### Grok 专用转发 + +- `backend/internal/service/openai_gateway_forward.go` 在账号平台为 `grok` 时直接调用 `forwardGrokResponses`。 +- `backend/internal/service/openai_gateway_grok.go` 同时接受 `oauth` 和 `api_key` 两种 Grok 账号。 +- 转发前会映射模型、净化 xAI 不兼容字段和工具,OAuth 请求还会带 Grok CLI 身份与缓存路由信息。 +- Codex WebSocket Responses 入站被接受,但 Grok 上游实际桥接为 HTTP/SSE,并非 xAI WebSocket 透传。 + +### 模型映射 + +- `backend/internal/pkg/xai/models.go` 默认包含 `grok-4.5`、`grok-4.3`、`grok-build-0.1`、`grok-composer-2.5-fast` 和 Grok 4.20 系列。 +- 默认别名包括 `grok -> grok-4.5`、`grok-latest -> grok-4.5`、`grok-build -> grok-build-0.1`、`grok-composer -> grok-composer-2.5-fast`。 +- 最清晰的 Codex 配置是直接请求 `grok-4.5`。只有消费端必须沿用其他模型名时,才在 Grok 账号的 `model_mapping` 中显式映射;不建议用 `gpt-*` 名称长期伪装 Grok,以免模型归因、价格和排障语义混乱。 + +## Sub2API 配置步骤 + +1. 在管理后台创建原生 Grok 账号: + - 订阅账号:选择 `Grok -> OAuth` 并完成 xAI 授权;推理默认走 `https://cli-chat-proxy.grok.com/v1`。 + - 官方计费 API:选择 `Grok -> API Key`,base URL 使用 `https://api.x.ai/v1`。 +2. 创建平台为 `grok` 的分组,并把 Grok 账号加入该分组。 +3. 创建一个 Sub2API API Key,并把该 Key 指派给 Grok 分组。 +4. 先验证该 Key 的 `/v1/models` 能看到 `grok-4.5`,再做 `/v1/responses` 的同步、流式和工具调用 smoke。 +5. 对 OAuth 账号保持保守并发起步;是否提高并发应由实际首 Token、429 和渠道健康数据决定。 + +## Codex 客户端配置模板 + +`~/.codex/config.toml` 可使用独立 provider,避免覆盖现有默认 provider: + +```toml +model_provider = "sub2api_grok" +model = "grok-4.5" + +[model_providers.sub2api_grok] +name = "Grok via Sub2API" +base_url = "https:///v1" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +``` + +认证使用绑定 Grok 分组的 Sub2API API Key,通过 Codex 既有 `auth.json` 或受控凭据入口注入;不要把 xAI Key 直接交给 Codex,也不要把密钥写入仓库。初次接入建议保持 HTTP Responses;Sub2API 虽支持 Codex WebSocket 入站,但 Grok 上游仍为 HTTP/SSE,只有完成真实 WS smoke 后再启用 Codex WebSocket 标记。 + +## 官方 issue 与修复演进 + +- 原生 Grok 订阅支持由 PR #3310 引入,明确覆盖 `/v1/responses`、`/responses` 和 `/backend-api/codex/responses`:。 +- PR #3558 补齐 Codex/OpenAI 兼容路由、WebSocket 入站桥接和 Codex 请求净化:。 +- Issue #3952 记录旧实现缺失 Grok CLI 版本身份头会被 OAuth 上游以 426 拒绝:。 +- PR #4009 修复 CLI 身份头、OAuth 路由、`additional_tools`、Composer 推理参数,并加入 xAI API Key 账号:。 +- `v0.1.152` 已发布上述关键修复,`v0.1.155` 又修复 Grok reasoning `content:null` 422、缓存路由和监控能力,因此新接入应直接以 `v0.1.155` 为基线。 + +## 风险与验收重点 + +- OAuth 与 API Key 是不同上游路径,不能混用 base URL:OAuth 走订阅 CLI proxy,API Key 走 `api.x.ai`。 +- Codex 的私有/新增 Responses 字段可能领先 xAI schema;`v0.1.155` 已清理已知的 `additional_tools`、不兼容 reasoning 字段和 `content:null`,仍应以真实工具调用 smoke 为准。 +- Grok quota 是被动观察上游 rate-limit header;首次成功请求前显示 unknown 属正常现象。 +- `401` 表示凭据问题,`403` 表示权限或订阅 entitlement,`429` 会进入临时不可调度;这些应按真实错误分类,不能统一当网络失败。 + +## 建议 + +直接采用 `v0.1.155` 的原生 Grok provider,优先建立独立 Grok 分组和独立 Sub2API Key,以 `grok-4.5 + Responses + HTTP` 作为首个受控配置。无需 Sub2API 源码变更;下一步若要落地,应先把账号、分组、Key 和模型映射声明进 owning YAML,再走受控 plan/apply 和最小 smoke。 diff --git a/docs/MDTODO/sub2api-upstream-reliability.md b/docs/MDTODO/sub2api-upstream-reliability.md index 208bcdac..89cd3d13 100644 --- a/docs/MDTODO/sub2api-upstream-reliability.md +++ b/docs/MDTODO/sub2api-upstream-reliability.md @@ -133,6 +133,9 @@ #### R2.2.36 [completed] 由 Artificer 优化 Sub2API 利润统计 CLI 的单次聚合与渐进披露,减少 7 天成本利润计算中的重复查询、外部解析和手工算术;默认 Kubernetes 风格与显式 JSON 都应直接保留紧凑汇总,账号明细语义化下钻,缺失历史成本时输出参数化利润公式;不修改 runtime 或部署,关联 [GitHub issue #2043](https://github.com/pikasTech/unidesk/issues/2043),完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.36_Task_Report.md)。 +#### R2.2.37 [in_progress] + +把 `~/.codex/config.toml.grok` 和 `~/.codex/auth.json.grok` 调整为通过 Sub2API 原生 Grok 分组接入 Codex;保留密钥脱敏和文件权限,先验证现有 Key 的分组/模型能力,再修改并做最小只读配置验收,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.37_Task_Report.md)。 ### R2.3 [completed] 调查并收敛 Sub2API 原生 system-log 索引未保留策略事件的能力缺口,关联 [Issue #2027](https://github.com/pikasTech/unidesk/issues/2027),完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.3_Task_Report.md)。 From 1f3283ffac2632fba14581bccd95333d463a1f8a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:40:48 +0200 Subject: [PATCH 18/26] =?UTF-8?q?fix(cicd):=20=E5=9B=9E=E9=80=80=20PaC=20a?= =?UTF-8?q?dmission=20=E4=B8=9A=E5=8A=A1=E9=98=BB=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/pipelines-as-code.yaml | 5 +- .../R4.1.1_Task_Report.md | 81 +++++++++++++++++++ scripts/native/cicd/pac-status-evaluator.cjs | 30 ++++--- .../src/platform-infra-pac-provenance.test.ts | 37 +++++---- scripts/src/platform-infra-pac-provenance.ts | 12 +-- ...-infra-pipelines-as-code-bootstrap.test.ts | 29 +++++++ ...tform-infra-pipelines-as-code-bootstrap.ts | 15 +++- ...platform-infra-pipelines-as-code-remote.sh | 49 +++++------ 8 files changed, 193 insertions(+), 65 deletions(-) create mode 100644 docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 8436c436..f323004b 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -65,9 +65,10 @@ deliveryProvenance: admission: policyName: unidesk-pac-delivery-provenance-v2 bindingName: unidesk-pac-delivery-provenance-v2 - failurePolicy: Fail + failurePolicy: Ignore validationActions: - - Deny + - Warn + - Audit capabilities: sentinelInternalPublish: enabled: false diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md new file mode 100644 index 00000000..fcc3135b --- /dev/null +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md @@ -0,0 +1,81 @@ +# R4.1.1 任务报告 + +## 状态 + +- MDTODO:`R4.1.1` +- GitHub issue:`pikasTech/unidesk#2039` +- 状态:`in_progress` +- 原因:源码修复与定向验证已完成,尚未合并 PR,也未执行合并后的 NC01 原入口运行面验收。 + +## 问题事实 + +- PR #1808 引入同名 `ValidatingAdmissionPolicy` 与 binding: + - 使用 `failurePolicy: Fail`; + - 使用 `validationActions: [Deny]`; + - 阻断不满足 provenance CEL 的 PipelineRun 创建或更新。 +- PR #1812 继续收紧 watcher 的 queue transition: + - 假定同一次 UPDATE 完成 `spec.status: PipelineRunPending -> absent`; + - 同时完成 `state: queued -> started`。 +- NC01 官方 `pipelines-as-code-watcher` 的真实更新不满足该原子假定: + - live `unidesk-pac-delivery-provenance-v2` 拒绝 watcher; + - HWLAB `df925768` 与 AgentRun 自动队列停在 Pending。 + +## 本次语义回退 + +- owning YAML 将同名 v2 policy 改为 Kubernetes 原生 warning-only: + - `failurePolicy: Ignore`; + - `validationActions: [Warn, Audit]`。 +- 保留现有观察项的计算和披露: + - CEL、marker、creator 与 ServiceAccount; + - queue transition、spec/config SHA、版本与 resource epoch; + - 默认 RBAC。 +- 上述观察项不再作为以下入口的阻断条件: + - 业务 admission 与 bootstrap; + - status、history 与 debug; + - 外层 PaC delivery eligibility。 +- admission 与 consumer bootstrap 漂移统一输出结构化字段: + - `blocking: false`; + - `warning: true|false`; + - `warnings` / `reasons`。 +- PaC bootstrap 的 `pac-admission` 阶段在 live identity 或 generation 尚未精确收敛时显示 `state: warning`,整体结果仍可成功。 +- 外层 GitHub/Gitea PaC push event 继续作为: + - 唯一流水线触发; + - 唯一 delivery authority。 +- admission exact identity 仅决定 `admissionProvenanceVerified` 与 warning,不再让已观察到的 outer event 失去 eligibility。 + +## 明确保留 + +- PR #1808 的 source artifact、三阶段 terminal、GitOps/status/history/debug 可观测性与无关后续改动。 +- YAML-owned policy/binding 名称 `unidesk-pac-delivery-provenance-v2`,以便受控 bootstrap 通过 server-side apply 原位收敛现有 live v2。 +- provenance、creator、marker、ServiceAccount、spec/config SHA、版本、epoch、RBAC 与 drift 的只读计算和结构化披露。 +- GitHub PR merge 继续作为唯一正式交付触发。 +- 未新增以下入口或机制: + - PipelineRun、mirror、trigger、sync、flush、Argo sync 或 runtime patch; + - 数据库迁移、合同、锁、租约、第二 authority 或 fallback。 + +## 定向验证 + +- `sh -n scripts/src/platform-infra-pipelines-as-code-remote.sh` +- `bun test scripts/src/platform-infra-pac-provenance.test.ts scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts` + - 结果:`23 pass, 0 fail, 234 expect()`。 +- `node` 调用 `runPacStatusFixtureChecks()`: + - 结果:`ok=true, checks=34`。 +- `bun scripts/cli.ts platform-infra pipelines-as-code plan --target NC01` + - 结果:YAML source-of-truth、single-path、Gitea source 与 runtime-zero-docker 策略均为 `true`。 +- `git diff --check` + - 结果:通过。 + +## 合并后受控收敛 + +- 主代理在 PR 合并后先执行只读计划: + - `bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target NC01 --consumer agentrun-nc01-v02 --dry-run` +- 确认计划只体现 owning YAML 的同名 v2 warning-only 收敛后,再执行: + - `bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target NC01 --consumer agentrun-nc01-v02 --confirm` +- 不需要 prune:policy 与 binding 名称保持不变,bootstrap 使用 server-side apply 原位把 live `Fail/Deny` 更新为 `Ignore` 与 `Warn/Audit`。 +- bootstrap 不是流水线补跑入口;禁止随后人工执行 PipelineRun、mirror、trigger、sync、flush、Argo sync 或 runtime patch。 +- 运行面验收由主代理观察既有 merge 事件自动恢复,并通过原 `status`、`history` 与受影响 HWLAB/AgentRun 原入口完成。 + +## 当前阻塞 + +- 无源码或定向验证阻塞。 +- 运行面验收仍等待 PR 合并及主代理执行受控 bootstrap,因此任务保持 `in_progress`。 diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index 030760dd..8ff82b34 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -222,9 +222,9 @@ function evaluatePacAdmissionState(inputValue) { if (annotations["unidesk.ai/pac-controller-identity"] !== expected.controllerIdentity) reasons.push(`${kind}-controller-identity-mismatch`); if (annotations["unidesk.ai/effective-config-sha256"] !== expected.configSha256) reasons.push(`${kind}-config-sha256-mismatch`); } - if (policySpec.failurePolicy !== "Fail") reasons.push("policy-failure-policy-not-fail"); + if (policySpec.failurePolicy !== "Ignore") reasons.push("policy-failure-policy-not-ignore"); if (bindingSpec.policyName !== expected.policyName) reasons.push("binding-policy-name-mismatch"); - if (!Array.isArray(bindingSpec.validationActions) || bindingSpec.validationActions.length !== 1 || bindingSpec.validationActions[0] !== "Deny") reasons.push("binding-validation-actions-not-deny"); + if (!Array.isArray(bindingSpec.validationActions) || stableCanonicalJson(bindingSpec.validationActions) !== stableCanonicalJson(["Warn", "Audit"])) reasons.push("binding-validation-actions-not-warning-only"); if (!Number.isInteger(policyMetadata.generation) || !Number.isInteger(policyStatus.observedGeneration) || policyStatus.observedGeneration < policyMetadata.generation) reasons.push("policy-generation-not-observed"); if (warnings.length > 0) reasons.push("policy-expression-warning"); if (roleMetadata.name !== "unidesk-pac-consumer-runner") reasons.push("default-role-missing"); @@ -258,6 +258,9 @@ function evaluatePacAdmissionState(inputValue) { configured: true, required: true, ready: reasons.length === 0, + blocking: false, + warning: reasons.length > 0, + warnings: reasons, source: "live-admissionregistration-v1", policyName: stringOrNull(policyMetadata.name), bindingName: stringOrNull(bindingMetadata.name), @@ -357,13 +360,18 @@ function classifyPacPipelineRun(consumerInput, itemInput) { deliveryClass, reason, candidate, - deliveryAuthorityEligible: outer && (!provenanceRequired || admissionProvenanceVerified), + deliveryAuthorityEligible: outer, deliveryOwner: outer ? admissionProvenanceVerified ? "pac-controller-admission" : "pac-controller-observed" : null, executionOwner: inner ? "tekton-child-unattached" : null, parentPipelineRun: null, parentRelation: inner ? "unproven" : null, metadataObservationOnly: outer && !admissionProvenanceVerified, admissionProvenanceVerified, + admissionWarning: outer && !admissionProvenanceVerified ? { + code: "pac-admission-provenance-unverified", + blocking: false, + reasons: Array.isArray(admissionState.reasons) ? admissionState.reasons : [], + } : null, admissionPolicyReady: admissionState.ready === true, admissionPolicyName: stringOrNull(admissionState.policyName), admissionBindingName: stringOrNull(admissionState.bindingName), @@ -1285,19 +1293,19 @@ function runPacStatusFixtureChecks() { }; const classificationCases = [ { id: "admission-provenance-verified", expected: true, item: verifiedRun, state: admissionState }, - { id: "pre-bootstrap-run-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, creationTimestamp: "2025-12-31T23:59:59Z" } }, state: admissionState }, - { id: "forged-name-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState }, - { id: "forged-label-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", labels: { ...verifiedRun.metadata.labels, "tekton.dev/pipeline": consumer.pipeline }, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState }, - { id: "forged-pipeline-ref-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } }, spec: { ...verifiedRun.spec, pipelineRef: { name: consumer.pipeline } } }, state: admissionState }, - { id: "wrong-service-account-fails-closed", expected: false, item: { ...verifiedRun, spec: { taskRunTemplate: { serviceAccountName: "default" } } }, state: admissionState }, - { id: "policy-identity-mismatch-fails-closed", expected: false, item: verifiedRun, state: { ...admissionState, configSha256: `sha256:${"2".repeat(64)}` } }, + { id: "pre-bootstrap-run-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, creationTimestamp: "2025-12-31T23:59:59Z" } }, state: admissionState }, + { id: "marker-mismatch-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState }, + { id: "candidate-label-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", labels: { ...verifiedRun.metadata.labels, "tekton.dev/pipeline": consumer.pipeline }, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState }, + { id: "candidate-pipeline-ref-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } }, spec: { ...verifiedRun.spec, pipelineRef: { name: consumer.pipeline } } }, state: admissionState }, + { id: "service-account-mismatch-warning-only", expected: false, item: { ...verifiedRun, spec: { taskRunTemplate: { serviceAccountName: "default" } } }, state: admissionState }, + { id: "policy-identity-mismatch-warning-only", expected: false, item: verifiedRun, state: { ...admissionState, configSha256: `sha256:${"2".repeat(64)}` } }, ]; for (const item of classificationCases) { const result = classifyPacPipelineRun({ ...consumer, admissionState: item.state }, item.item); checks.push({ id: item.id, - ok: result.admissionProvenanceVerified === item.expected && result.deliveryAuthorityEligible === item.expected, - expectedOk: item.expected, + ok: result.admissionProvenanceVerified === item.expected && result.deliveryAuthorityEligible === true, + expectedOk: true, actualOk: result.deliveryAuthorityEligible, expectedCode: item.expected ? "admission-owned-pac-push-provenance-verified" : "pac-push-metadata-observed-but-admission-provenance-unverified", actualCode: result.reason, diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index 3199cec4..ffd5f403 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -38,6 +38,11 @@ test("admission contract rejects malformed required declarations instead of sile const duplicatedRequiredConsumers = duplicatedServiceAccount.consumers.filter((item: any) => item.deliveryProvenance !== undefined); duplicatedRequiredConsumers[1].deliveryProvenance.executionServiceAccountName = duplicatedRequiredConsumers[0].deliveryProvenance.executionServiceAccountName; expect(() => parsePacAdmissionContract(duplicatedServiceAccount)).toThrow("executionServiceAccountName must be unique per target"); + + const denyingAdmission = structuredClone(source); + denyingAdmission.deliveryProvenance.admission.failurePolicy = "Fail"; + denyingAdmission.deliveryProvenance.admission.validationActions = ["Deny"]; + expect(() => parsePacAdmissionContract(denyingAdmission)).toThrow("validationActions must be exactly [Warn, Audit]"); }); test("admission queue transition contract rejects absent sources, unknown Tekton states, and duplicate transitions", () => { @@ -55,7 +60,7 @@ test("admission queue transition contract rejects absent sources, unknown Tekton expect(() => parsePacAdmissionContract(duplicate)).toThrow("deliveryProvenance.queueTransition.allowed must be unique"); }); -test("versioned admission desired state protects controller proof, candidate identity, params, and execution SA", () => { +test("versioned admission desired state reports controller proof and candidate drift without denying delivery", () => { const contract = readPacAdmissionContract(); const owningYaml = Bun.YAML.parse(readFileSync(resolve(root, "config/platform-infra/pipelines-as-code.yaml"), "utf8")) as any; expect(owningYaml.deliveryProvenance.terminalRoles).toBeUndefined(); @@ -69,15 +74,15 @@ test("versioned admission desired state protects controller proof, candidate ide toState: "started", }]); expect(contract.child.enabled).toBe(false); - expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01", "selfmedia-nc01"]); + expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01", "selfmedia-nc01", "selfmedia-production-nc01", "pikaoa-test-nc01"]); const items = documents(renderPacAdmissionDesiredFragment("NC01")); expect(items.map((item) => item.kind)).toEqual(["ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding"]); const policy = items[0]; const expressions = policy.spec.validations.map((item: any) => item.expression).join("\n"); const messages = policy.spec.validations.map((item: any) => item.message).join("\n"); - expect(policy.spec.failurePolicy).toBe("Fail"); + expect(policy.spec.failurePolicy).toBe("Ignore"); expect(policy.spec.matchConstraints.matchPolicy).toBe("Equivalent"); - expect(items[1].spec.validationActions).toEqual(["Deny"]); + expect(items[1].spec.validationActions).toEqual(["Warn", "Audit"]); expect(policy.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("0"); expect(items[1].metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("1"); expect(expressions).toContain("request.userInfo.username"); @@ -134,8 +139,8 @@ test("versioned admission desired state protects controller proof, candidate ide test("required consumer default RBAC has one automatic desired owner per namespace and no Tekton mutation verbs", () => { const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); - expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding"]); - expect(rbac.filter((item) => item.kind === "Role").map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "devops-infra", "selfmedia-ci"]); + expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding"]); + expect(rbac.filter((item) => item.kind === "Role").map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "devops-infra", "selfmedia-ci", "pikaoa-ci"]); for (const role of rbac.filter((item) => item.kind === "Role")) { expect(role.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-1"); expect(role.metadata.annotations["unidesk.ai/effective-config-sha256"]).toBe(pacConsumerRbacDesiredIdentity("NC01").configSha256); @@ -150,6 +155,8 @@ test("required consumer default RBAC has one automatic desired owner per namespa "RoleBinding", "Role", "RoleBinding", + "Role", + "RoleBinding", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", "ServiceAccount", @@ -158,7 +165,7 @@ test("required consumer default RBAC has one automatic desired owner per namespa ]); }); -test("live admission evaluator requires exact desired hashes, observed generation, default-SA loss, and a new resource epoch", () => { +test("live admission evaluator reports exact desired drift as non-blocking warnings", () => { const contract = readPacAdmissionContract(); const admission = documents(renderPacAdmissionDesiredFragment("NC01")); const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); @@ -174,7 +181,7 @@ test("live admission evaluator requires exact desired hashes, observed generatio rbacConfigSha256: rbac[0].metadata.annotations["unidesk.ai/effective-config-sha256"], }; const input = { policy, binding, role: rbac[0], roleBinding: rbac[1], namespace: "agentrun-ci", defaultCanMutate: false, expected }; - expect(evaluator.evaluatePacAdmissionState(input)).toMatchObject({ ready: true, activeAfter: "2026-01-01T00:00:01Z", defaultCanMutate: false }); + expect(evaluator.evaluatePacAdmissionState(input)).toMatchObject({ ready: true, blocking: false, warning: false, activeAfter: "2026-01-01T00:00:01Z", defaultCanMutate: false }); expect(evaluator.evaluatePacAdmissionState({ ...input, policy: { @@ -189,7 +196,7 @@ test("live admission evaluator requires exact desired hashes, observed generatio }, }, }, - })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["policy-expression-warning"]) }); + })).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["policy-expression-warning"]) }); const apiDefaultedPolicy = { ...policy, spec: { @@ -206,10 +213,10 @@ test("live admission evaluator requires exact desired hashes, observed generatio ready: true, liveSpecSha256: expected.configSha256, }); - expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: true })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["default-serviceaccount-can-mutate-tekton-runs"]) }); - expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: null })).toMatchObject({ ready: false, defaultCanMutate: null, reasons: expect.arrayContaining(["default-serviceaccount-mutation-probe-unavailable"]) }); - expect(evaluator.evaluatePacAdmissionState({ ...input, role: { ...rbac[0], rules: [rbac[0].rules[0]] } })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["default-role-rules-mismatch"]) }); - expect(evaluator.evaluatePacAdmissionState({ ...input, expected: { ...expected, configSha256: `sha256:${"0".repeat(64)}` } })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["policy-config-sha256-mismatch"]) }); + expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: true })).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["default-serviceaccount-can-mutate-tekton-runs"]) }); + expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: null })).toMatchObject({ ready: false, blocking: false, warning: true, defaultCanMutate: null, reasons: expect.arrayContaining(["default-serviceaccount-mutation-probe-unavailable"]) }); + expect(evaluator.evaluatePacAdmissionState({ ...input, role: { ...rbac[0], rules: [rbac[0].rules[0]] } })).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["default-role-rules-mismatch"]) }); + expect(evaluator.evaluatePacAdmissionState({ ...input, expected: { ...expected, configSha256: `sha256:${"0".repeat(64)}` } })).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["policy-config-sha256-mismatch"]) }); const mutatedPolicy = { ...policy, spec: { @@ -345,10 +352,10 @@ test("AgentRun rendered delivery plan and real TaskRun terminals close the statu })).toMatchObject({ ok: true, code: "pac-ready-gitops-exact" }); }); -test("provenance fixtures fail closed for pre-bootstrap, forged marker, wrong SA, and policy mismatch", () => { +test("provenance fixtures keep outer PaC delivery eligible while reporting admission warnings", () => { const result = evaluator.runPacStatusFixtureChecks(); expect(result.ok).toBe(true); - for (const id of ["gitops-only-registry-not-configured", "image-registry-missing-fails-closed", "equal-revisions-ignore-owning-git-fetch-failure", "different-revisions-fetch-failure-remains-unknown", "admission-provenance-verified", "pre-bootstrap-run-fails-closed", "forged-name-candidate-fails-closed", "forged-label-candidate-fails-closed", "forged-pipeline-ref-candidate-fails-closed", "wrong-service-account-fails-closed", "policy-identity-mismatch-fails-closed"]) { + for (const id of ["gitops-only-registry-not-configured", "image-registry-missing-fails-closed", "equal-revisions-ignore-owning-git-fetch-failure", "different-revisions-fetch-failure-remains-unknown", "admission-provenance-verified", "pre-bootstrap-run-warning-only", "marker-mismatch-warning-only", "candidate-label-warning-only", "candidate-pipeline-ref-warning-only", "service-account-mismatch-warning-only", "policy-identity-mismatch-warning-only"]) { expect(result.checks.find((item) => item.id === id)?.ok).toBe(true); } }); diff --git a/scripts/src/platform-infra-pac-provenance.ts b/scripts/src/platform-infra-pac-provenance.ts index c95f517d..139fe2d8 100644 --- a/scripts/src/platform-infra-pac-provenance.ts +++ b/scripts/src/platform-infra-pac-provenance.ts @@ -33,8 +33,8 @@ export interface PacAdmissionContract { readonly allowedQueueTransitions: readonly PacAdmissionQueueTransitionContract[]; readonly policyName: string; readonly bindingName: string; - readonly failurePolicy: "Fail"; - readonly validationActions: readonly ["Deny"]; + readonly failurePolicy: "Ignore"; + readonly validationActions: readonly ["Warn", "Audit"]; readonly child: { readonly enabled: false; readonly parentNameAnnotation: string; @@ -83,7 +83,9 @@ export function parsePacAdmissionContract(source: Record): PacA const transitionKeys = new Set(allowedQueueTransitions.map((transition) => [transition.fromSpecStatus, transition.toSpecStatus, transition.fromState, transition.toState].join("\u0000"))); if (transitionKeys.size !== allowedQueueTransitions.length) throw new Error("deliveryProvenance.queueTransition.allowed must be unique"); const validationActions = stringArray(admission.validationActions, "deliveryProvenance.admission.validationActions"); - if (validationActions.length !== 1 || validationActions[0] !== "Deny") throw new Error("deliveryProvenance.admission.validationActions must be exactly [Deny]"); + if (validationActions.length !== 2 || validationActions[0] !== "Warn" || validationActions[1] !== "Audit") { + throw new Error("deliveryProvenance.admission.validationActions must be exactly [Warn, Audit]"); + } const consumers = recordArray(root.consumers, "consumers").flatMap((consumer, index) => { if (consumer.deliveryProvenance === undefined) return []; const value = record(consumer.deliveryProvenance, `consumers[${index}].deliveryProvenance`); @@ -127,8 +129,8 @@ export function parsePacAdmissionContract(source: Record): PacA allowedQueueTransitions, policyName, bindingName, - failurePolicy: literal(admission.failurePolicy, "deliveryProvenance.admission.failurePolicy", "Fail"), - validationActions: ["Deny"], + failurePolicy: literal(admission.failurePolicy, "deliveryProvenance.admission.failurePolicy", "Ignore"), + validationActions: ["Warn", "Audit"], child: { enabled: literal(child.enabled, "deliveryProvenance.child.enabled", false), parentNameAnnotation: required(child.parentNameAnnotation, "deliveryProvenance.child.parentNameAnnotation"), diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index bc0a0982..e2f89a7a 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -210,6 +210,35 @@ test("PaC bootstrap projects typed stages and compact JSON", () => { expect(rendered).toContain("confirm:"); }); +test("PaC bootstrap keeps admission drift as a non-blocking warning", () => { + const result = { + ok: true, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: true, + mode: "confirmed", + target: { id: "NC01" }, + consumer: { id: "widgets", node: "NC01", lane: "v01" }, + repository: { id: "widgets", namespace: "ci" }, + mirrorRepository: { key: mirror.key, upstreamRepository: mirror.upstream.repository, upstreamBranch: mirror.upstream.branch, owner: mirror.gitea.owner, repo: mirror.gitea.name }, + githubPreflight: { ok: true, mutation: false, repositories: [{ key: mirror.key, code: "github-upstream-available", ok: true }] }, + giteaBootstrap: { ok: true, mutation: false, mode: "skipped", blockers: [] }, + pipelinesAsCodeApply: { + ok: true, + mutation: true, + mode: "confirmed", + remote: { + ok: true, + admission: { applied: true, ready: false }, + admissionProvenance: { policyName: "unidesk-pac-delivery-provenance-v2", reasons: ["policy-generation-not-observed"] }, + }, + }, + }; + const projection = projectPacBootstrapResult(result); + expect(projection.ok).toBe(true); + expect((projection.stages as Record[]).find((item) => item.id === "pac-admission")).toMatchObject({ ok: true, state: "warning", blocking: false }); + expect(projection.warnings).toEqual([expect.objectContaining({ code: "pac-admission-provenance-not-ready", blocking: false })]); +}); + test("PaC bootstrap returns fix-yaml next for zero YAML matches", () => { const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: no exact match")); expect(projectPacBootstrapResult(result)).toBe(result); diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts index 3a8e2b84..62add1aa 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -70,6 +70,7 @@ export function projectPacBootstrapResult(result: Record): Reco const apply = record(result.pipelinesAsCodeApply); const remote = record(apply.remote); const admission = record(remote.admission); + const admissionProvenance = record(remote.admissionProvenance); const blockers = bootstrapBlockers(githubPreflight, gitea, apply, remote); const dryRun = result.mode === "dry-run"; const repositoryMissing = remote.error === "gitea-repository-missing"; @@ -78,6 +79,16 @@ export function projectPacBootstrapResult(result: Record): Reco const giteaSkipped = gitea.mode === "skipped"; const applySkipped = apply.mode === "skipped"; const applyReady = apply.ok === true || (dryRun && repositoryMissing); + const warnings = [ + ...records(result.warnings), + ...(applySkipped || dryRun || repositoryMissing || admission.ready === true ? [] : [{ + object: { kind: "ValidatingAdmissionPolicy", id: stringValue(admissionProvenance.policyName, "PaC admission") }, + blocking: false, + code: "pac-admission-provenance-not-ready", + configPath: "config/platform-infra/pipelines-as-code.yaml#deliveryProvenance.admission", + reasons: Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons : [], + }]), + ]; const next = bootstrapNext(result, blockers); return { ok: blockers.length === 0 && giteaReady && applyReady, @@ -102,12 +113,12 @@ export function projectPacBootstrapResult(result: Record): Reco stages: [ { id: "gitea-init", ok: giteaSkipped ? null : giteaReady, state: giteaSkipped ? "skipped" : giteaReady ? (dryRun ? "planned" : "ready") : "failed", mutation: gitea.mutation === true }, { id: "pac-controller", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, - { id: "pac-admission", ok: applySkipped ? null : dryRun ? true : admission.ready === true, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : dryRun ? "planned" : admission.ready === true ? "ready" : "failed", mutation: admission.applied === true }, + { id: "pac-admission", ok: applySkipped ? null : true, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : dryRun ? "planned" : admission.ready === true ? "ready" : "warning", blocking: false, mutation: admission.applied === true }, { id: "tekton-consumer", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, { id: "gitops-argo", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, ], repository: { id: repository.id, namespace: repository.namespace }, - warnings: result.warnings, + warnings, blockers, next, valuesPrinted: false, diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index e268cdcc..13a6a668 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -363,15 +363,13 @@ apply_action() { hook_id=$(ensure_webhook) ready=false if wait_ready; then ready=true; fi + prepare_admission_provenance_state admission_ready=true if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" = "1" ]; then - admission_ready=false - if wait_admission_ready; then admission_ready=true; fi - else - prepare_admission_provenance_state + admission_ready=$(printf '%s' "$UNIDESK_PAC_ADMISSION_STATE_JSON" | node -e 'let input="";process.stdin.on("data",chunk=>input+=chunk);process.stdin.on("end",()=>process.stdout.write(JSON.parse(input).ready===true?"true":"false"));') fi ok=false - if [ "$ready" = true ] && [ "$admission_ready" = true ]; then ok=true; fi + if [ "$ready" = true ]; then ok=true; fi printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"admission":{"applied":%s,"ready":%s},"admissionProvenance":%s,"argoBootstrap":%s,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ok" "$ready" "$admission_applied" "$admission_ready" "$UNIDESK_PAC_ADMISSION_STATE_JSON" "$argo_bootstrap" "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" "$(json_string "$UNIDESK_PAC_GITEA_REPO")" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")" } @@ -436,19 +434,6 @@ prepare_admission_provenance_state() { export UNIDESK_PAC_ADMISSION_STATE_JSON } -wait_admission_ready() { - timeout="${UNIDESK_PAC_WAIT_TIMEOUT_SECONDS:-55}" - end=$(( $(now_epoch) + timeout )) - while :; do - prepare_admission_provenance_state - if printf '%s' "$UNIDESK_PAC_ADMISSION_STATE_JSON" | node -e 'let input="";process.stdin.on("data",chunk=>input+=chunk);process.stdin.on("end",()=>process.exit(JSON.parse(input).ready===true?0:1));'; then - return 0 - fi - if [ "$(now_epoch)" -ge "$end" ]; then return 1; fi - sleep 2 - done -} - pipeline_rows() { payload_file=$(mktemp) kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file" @@ -624,7 +609,7 @@ function defaultCanMutate(namespace) { function admissionStateForConsumer(consumer) { const expected = consumer.deliveryProvenance; - if (!expected || expected.required !== true) return { configured: false, required: false, ready: null, reasons: ['consumer-admission-provenance-not-required'], valuesPrinted: false }; + if (!expected || expected.required !== true) return { configured: false, required: false, ready: null, blocking: false, warning: false, warnings: [], reasons: ['consumer-admission-provenance-not-required'], valuesPrinted: false }; admissionPolicy ||= kubectlJson(['get', 'validatingadmissionpolicy', expected.policyName, '-o', 'json'], {}, 'admission-policy'); admissionBinding ||= kubectlJson(['get', 'validatingadmissionpolicybinding', expected.bindingName, '-o', 'json'], {}, 'admission-binding'); if (!admissionRbac.has(consumer.namespace)) { @@ -1561,6 +1546,9 @@ if (!passwordPresent) reasons.push('argocd-repository-secret-password-missing'); process.stdout.write(JSON.stringify({ configured: runnerConfigured || argoConfigured, ready: reasons.length === 0, + blocking: false, + warning: reasons.length > 0, + warnings: reasons, runner: { configured: runnerConfigured, serviceAccount: runnerName || null, @@ -1597,7 +1585,6 @@ status_action() { repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME") prepare_admission_provenance_state consumer_bootstrap=$(consumer_bootstrap_state) - bootstrap_ready=$(UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE||"{}"); process.stdout.write(s.configured===true&&s.ready!==true?"false":"true")') pipelines=$(pipeline_rows) latest=$(printf '%s' "$pipelines" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(a[0]?.name||"")') export UNIDESK_PAC_TARGET_PIPELINERUN="$latest" @@ -1621,7 +1608,6 @@ NODE ) runtime=$(json_normalize "$(runtime_summary)") diagnostics=$(cicd_diagnostics "$pipelines" "$artifact" "$argo" "$runtime") - admission_ready=$(UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_STATE||"{}"); process.stdout.write(s.ready===true?"true":"false")') diagnostics=$(UNIDESK_PAC_DIAGNOSTICS="$diagnostics" UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node <<'NODE' const diagnostics = JSON.parse(process.env.UNIDESK_PAC_DIAGNOSTICS || '{}'); const admissionProvenance = JSON.parse(process.env.UNIDESK_PAC_STATE || '{}'); @@ -1629,20 +1615,23 @@ const consumerBootstrap = JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE || if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionProvenance.ready !== true) { process.stdout.write(JSON.stringify({ ...diagnostics, - ok: false, - code: 'pac-admission-provenance-not-ready', - phase: 'admission-provenance-not-ready', - hint: 'required PaC admission policy, binding, desired RBAC, or live default-SA mutation gate is not ready', + warnings: [...(Array.isArray(diagnostics.warnings) ? diagnostics.warnings : []), { + code: 'pac-admission-provenance-not-ready', + blocking: false, + hint: 'PaC admission policy, binding, desired RBAC, or live default-SA observation is drifted; delivery continues', + reasons: Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons : [], + }], admissionProvenance, valuesPrinted: false, })); } else if (consumerBootstrap.configured === true && consumerBootstrap.ready !== true) { process.stdout.write(JSON.stringify({ ...diagnostics, - ok: false, - code: 'pac-consumer-bootstrap-not-ready', - phase: 'consumer-bootstrap-not-ready', - hint: 'declared runner ServiceAccount/RoleBinding or Argo repository credential is missing or drifted', + warnings: [...(Array.isArray(diagnostics.warnings) ? diagnostics.warnings : []), { + code: 'pac-consumer-bootstrap-not-ready', + blocking: false, + hint: 'declared runner ServiceAccount/RoleBinding or Argo repository credential is missing or drifted; status remains observable', + }], admissionProvenance, consumerBootstrap, valuesPrinted: false, @@ -1653,7 +1642,7 @@ if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionPro NODE ) printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"consumerBootstrap":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \ - "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && [ "$bootstrap_ready" = "true" ] && { [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ] || [ "$admission_ready" = "true" ]; } && echo true || echo false )" \ + "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && echo true || echo false )" \ "$( [ -n "$crd" ] && echo true || echo false )" \ "$(json_string "$controller_ready")" \ "$UNIDESK_PAC_ADMISSION_STATE_JSON" \ From d8490fb847d30e7cd494d39a1b4ac8a39fb3bc41 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:40:51 +0200 Subject: [PATCH 19/26] =?UTF-8?q?docs:=20=E6=81=A2=E5=A4=8D=20SelfMedia=20?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E8=B7=9F=E8=B8=AA=E4=B8=BB=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R3.2_Task_Report.md | 20 ++++ .../R3.3_Task_Report.md | 22 +++++ .../R4.1_Task_Report.md | 29 ++++++ .../R4.2_Task_Report.md | 5 + .../R5.1_Task_Report.md | 15 +++ .../R5.2_Task_Report.md | 16 ++++ .../R5.3.1_Task_Report.md | 5 + .../R5.3.2_Task_Report.md | 7 ++ .../R5.3.3_Task_Report.md | 20 ++++ .../R5.3_Task_Report.md | 18 ++++ .../R5_Task_Report.md | 18 ++++ .../R6.1_Task_Report.md | 43 +++++++++ .../R6_Task_Report.md | 77 ++++++++++++++++ .../R7_Task_Report.md | 32 +++++++ .../R8.1_Task_Report.md | 17 ++++ .../R8_Task_Report.md | 35 +++++++ docs/MDTODO/selfmedia-production-delivery.md | 92 +++++++++++++++++++ 17 files changed, 471 insertions(+) create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R3.2_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R3.3_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R4.1_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R4.2_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R5.1_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R5.2_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R5.3.1_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R5.3.2_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R5.3.3_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R5.3_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R5_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R6.1_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R6_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R7_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R8.1_Task_Report.md create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R8_Task_Report.md diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R3.2_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R3.2_Task_Report.md new file mode 100644 index 00000000..9a615b04 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R3.2_Task_Report.md @@ -0,0 +1,20 @@ +# R3.2 任务报告 + +- 目标:修复 PaC 在 `expectedRevision` 与 `observedRevision` 相同时,因补充 owning Git fetch 失败而误判 `relation=unknown` 的问题。 +- 交付: + - Issue:[UniDesk #2052](https://github.com/pikasTech/unidesk/issues/2052)。 + - PR:[UniDesk #2055](https://github.com/pikasTech/unidesk/pull/2055)。 + - 合并提交:`dbc4169d83b4b856324345d79503862e682d3e86`。 +- 结果: + - 两个合法 revision 值完全相同时直接判定为 `exact`。 + - owning Git fetch 失败保留 `pac-owning-git-fetch-failed`、`blocking=false`、`mutation=false` 的结构化 warning。 + - 不同 revision 继续保持 `unknown`、`stale` 或 `diverged` 的既有阻塞语义。 + - remote 输出前与 evaluator 消费前复用同一幂等正规化函数,避免证据字段二次处理漂移。 +- 验证: + - evaluator fixture 34/34 通过。 + - source-artifact 32/32 通过。 + - failure-evidence 5/5 通过。 + - 新增具名测试 2/2 通过。 + - remote shell 语法与 PaC CLI help 通过。 + - 完整三文件测试 47/48;唯一失败是合并前 `origin/master` 已存在的 admission consumer 列表断言未包含 `selfmedia-production-nc01`,不属于本 PR diff。 +- 边界:未修改或操作 YAML、Secret、PipelineRun、Argo、Gitea、mirror、rollout 或其他运行面。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R3.3_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R3.3_Task_Report.md new file mode 100644 index 00000000..86200667 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R3.3_Task_Report.md @@ -0,0 +1,22 @@ +# R3.3 任务报告 + +## 结论 + +生产期次 `prod-webterm-20260714174507` 的 Kokoro/Misaki 不可发音字符故障已完成源码修复、release 与 master 双分支交付、生产续跑和公网验收。根因是口播文本中的 `[…]` 经 NFKC 规范化后变成 `[...]`,Misaki 生成未知音素;现由既有 TTS 归一化层恢复为保留停顿语义且受支持的 Unicode 省略号 `…`。 + +## 交付 + +- release PR `pikainc/selfmedia#37`,merge commit `48e92b281ddef06948b1ea158551f93a6caf37a6`。 +- master 回补 PR `pikainc/selfmedia#38`,merge commit `c3872d0c8fb0bba8e9a9f0c36338a98126d42081`。 +- 定向与关联测试 9 通过、0 失败;真实失败样本修复后未知音素计数为 0。 +- 生产 source artifact verifier:source worktree clean、commit exact、embedded PipelineRun 与 runtime provenance aligned。 + +## 原入口验收 + +- WebTerm 通过同一后端终端会话触发 `voice -> inspect` 续跑,job `20260714-184250-7841e80a` 成功。 +- 生成视频 `output/episode.mp4`,大小 20,572,896 bytes;voice、subtitles、render、inspect 全部成功。 +- 公网 `http://152.53.229.148:4319/healthz` 返回 200;匿名 `/api/v1/editions` 返回 401;根页面返回 200。 + +## 边界 + +未人工创建 PipelineRun、未直接修改 Pod/Argo/GitOps、未重跑已经成功的前置阶段。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R4.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R4.1_Task_Report.md new file mode 100644 index 00000000..6681bd01 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R4.1_Task_Report.md @@ -0,0 +1,29 @@ +# R4.1 任务报告 + +## 目标 + +依据 [UniDesk #2064](https://github.com/pikasTech/unidesk/issues/2064),为 Pipelines-as-Code 自动交付增加 YAML-first 的 120 秒端到端预算可见性。超预算只输出非阻塞提示,证据不完整时保持 partial,不改变任何业务滚动终态。 + +## 交付 + +- PR:[UniDesk #2067](https://github.com/pikasTech/unidesk/pull/2067)。 +- 实现提交:`c96cab96f763d5256d1e94f7421b8b0f2f33d06d`。 +- 合并提交:`2a88bc0a8b4d89ef0b9b009e98b73702a65e2a68`。 +- owning YAML:`config/platform-infra/pipelines-as-code.yaml#deliveryTiming.endToEndBudgetSeconds`。 +- typed warning:`pac-automatic-delivery-over-budget`。 +- 固定语义:`blocking=false`、`mutation=false`。 +- hint 只指向 env identity、依赖缓存、BuildKit/cache、stage timing 与现有只读 `status/history` 入口。 +- `status/history` 只有流水线耗时、缺少完整端到端证据时投影 `partial`、`overBudget=null`,不提前报警。 + +## 验证 + +- `bun test scripts/src/platform-infra-pac-delivery-timing.test.ts scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts`:15/15 通过。 +- `plan --target NC01 --consumer selfmedia-nc01 --json`:正确读取并投影 120 秒 owning YAML 预算,`mutation=false`。 +- 真实 `delivery-timing`:SELFMEDIA 端到端 202 秒,正确输出 `202s/120s` warning;Argo 为 `Synced/Healthy`,runtime 为 `1/1`。 +- 真实 `status/history`:PipelineRun 为 193 秒但缺少完整端到端时间证据,均保持 `partial` 且无 warning。 +- `git diff --check`:通过。 +- PR 合并由主代理 guarded merge 完成;本子任务未部署、未创建人工 PipelineRun、未执行 sync/refresh 或其他运行面 mutation。 + +## 边界 + +R4.1 已完成超预算事实的非阻塞可见性。实际将 SELFMEDIA 自动交付压缩到 120 秒预算内仍属于 R4 后续 env reuse 与镜像阶段优化,不由本子任务冒充完成。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R4.2_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R4.2_Task_Report.md new file mode 100644 index 00000000..0c6ff1ad --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R4.2_Task_Report.md @@ -0,0 +1,5 @@ +# R4.2 任务报告 + +UniDesk PR #2074 与 SELFMEDIA PR #48 已按依赖顺序合并,merge commits 分别为 7d1af2953d6226d41fd0d7dec898d4a5944daade 与 0dbf92e1abc8d47cf5a9ffd7e8a569429d37ac03。config/env-identity.yaml 声明 package manifest 的依赖语义字段,同一 renderer 同时生成 PaC identity 与 Docker 安装 manifest;scripts、name、version 不再使依赖环境失效,依赖、lockfile、工具、模型、runtime 输入仍正确失效。 + +PR #48 因 Dockerfile、策略和 renderer 首次变化按预期 miss,端到端 193 秒。随后 Web-only PR #50 是真实业务验收:ENV_REUSE=hit,sourcePrepare=2 秒、envIdentity=0 秒、imageBuild=34 秒、gitopsPublish=2 秒,PipelineRun 46 秒、端到端 56 秒,低于 owning YAML 的 120 秒预算;Argo Synced/Healthy,runtime 1/1,构建与运行 digest 精确一致。全程由正常 master PR merge 自动触发,未人工创建 PipelineRun、刷新 Argo 或修改 release。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R5.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R5.1_Task_Report.md new file mode 100644 index 00000000..2febcebc --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R5.1_Task_Report.md @@ -0,0 +1,15 @@ +# R5.1 任务报告 + +## 交付 + +- PR `pikainc/selfmedia#43`,merge commit `44d675eb8b7fba23237d4302b4b5a27ce7b41a0a`。 +- 登录页、生产导控台、作业监看、产物面板、错误提示与内置 Codex 终端全部改为中文。 +- 阶段、作业、终端连接/运行、会话恢复、生成方式和分镜类型使用集中中文映射;技术路径、品牌和协议只在中文语义旁保留。 +- `bun test web/tests` 9 项通过,NC01 Web 构建通过。 + +## 测试集群验收 + +- 仅由 master PR merge 自动发布到开发公网 `http://152.53.229.148:4317`;未触碰 release/生产 4319。 +- 公网登录后的退休英文标签扫描命中 0;页面标题、按钮、状态、空态、终端工具栏与辅助文本均为中文。 +- 1920x1080 桌面和 390x844 移动端无页面级横向溢出;工厂保持紧凑工业风、直角/小倒角。 +- 后续资源 API PR #46 自动发布后,公网中文界面仍保持一致。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R5.2_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R5.2_Task_Report.md new file mode 100644 index 00000000..31731450 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R5.2_Task_Report.md @@ -0,0 +1,16 @@ +# R5.2 任务报告 + +## 交付 + +- PR `pikainc/selfmedia#44`,merge commit `d151cfa87e586d083fce04b10a60b0ce022df5ae`。 +- 规范路径 `/editions/:editionId/resources/:resourceId`,资源为 `sources|script|article|slides|audio|subtitles|output|artifacts`;期次短路径规范化到 sources。 +- 切期立即清除旧详情并显示目标期次中文加载态;AbortController 与 request identity 双重阻止乱序覆盖。 +- 非法期次、非法资源和坏路径显示中文不存在状态,不回退其他期次;支持复制期次/资源链接。 + +## 测试集群验收 + +- master PR merge 自动交付端到端 59 秒,`ENV_REUSE=hit`,Argo `Synced/Healthy`、runtime 1/1;exact verifier 对齐。 +- 公网 4317 直接打开、刷新、前进/后退均恢复同一 edition/resource。 +- 人为延迟旧期请求 900ms 后快速切回:最终 URL、选择器、标题、身份与内容全部为新期,旧期正文计数 0。 +- 加载期明确显示 `正在加载期次:<目标期次>`;非法资源刷新后仍显示 `这个资源地址不存在`。 +- 公网页面健康 200、匿名 API 401;未发布 release/生产 4319。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R5.3.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R5.3.1_Task_Report.md new file mode 100644 index 00000000..4ec40f67 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R5.3.1_Task_Report.md @@ -0,0 +1,5 @@ +# R5.3.1 任务报告 + +SELFMEDIA PR #46 已合并到 master,merge commit 为 f0fccd8536c7930be73347be95602c0d480b8493。开发集群提供 GET/PUT /api/v1/editions/:id/resources/:resourceId,并由统一 TypeScript CLI 的 resource show/update 使用同一路径;metadata、sources、script、article、slides 可编辑,固定路径、revision/If-Match、结构校验、原子写入与跨期/越界拒绝均已实现。 + +公网 4317 真实验收中,Web 保存测试期次标题后,内嵌 WebTerm 的持久 Codex 会话通过 bun /app/scripts/selfmedia-cli.ts resource show 读取到相同标题、metadata 内容和 revision,API key 未被读取或打印。随后同源 PUT 先完成外部并发更新,再用旧 revision 请求,返回 HTTP 409 与 revision_conflict;测试期次最终恢复原始标题并刷新确认。未触碰 release、生产 namespace 或 4319。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R5.3.2_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R5.3.2_Task_Report.md new file mode 100644 index 00000000..5e6611cd --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R5.3.2_Task_Report.md @@ -0,0 +1,7 @@ +# R5.3.2 任务报告 + +SELFMEDIA PR #50 已合并到 master,merge commit 为 6125213083a12bdb5bfe8a621b7bc9a7125d2e6b。开发 PaC 自动交付成功,PipelineRun 46 秒、端到端 56 秒,ENV_REUSE=hit,imageBuild=34 秒;Argo 最终观测精确 GitOps commit 14c781a9c999,runtime digest 与本次构建 sha256:4dd5682c4c8 一致。 + +公网 4317 验证了期次元数据结构化编辑、来源/文稿/微信文章/分镜资源编辑入口、只读资源说明、中文加载与保存状态、终端协同按钮。真实冲突场景返回 409 revision_conflict,页面显示保存冲突、保留草稿并禁用覆盖;重新加载后恢复原始内容。RESTful 深链可直接打开微信公众号文章资源,浏览器后退恢复原期次/资源,非法资源显示中文 not-found。专用“交付巡检”运行按钮弹出中文确认框,确认后创建单步作业并在 9 秒内成功,作业尾号 1d8be9d2。 + +桌面 1920x1080 与移动 390x844 均完成截图复核;两种视口 document scrollWidth 等于 innerWidth,未见横向溢出,控制台无 error/warning。未触碰 release、生产 namespace 或 4319。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R5.3.3_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R5.3.3_Task_Report.md new file mode 100644 index 00000000..3a66a74f --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R5.3.3_Task_Report.md @@ -0,0 +1,20 @@ +# R5.3.3 任务报告 + +## 结论 + +SELFMEDIA 已在 `edition.yaml#spec.production` 提供期次级配音、字幕和视频覆盖,REST API、统一 CLI、Web 工具带与 `voice`、`subtitles`、`render` 单步共享同一资源真相;空字段继承 `config/tools.yaml`。PR [#51](https://github.com/pikainc/selfmedia/pull/51) 已合并到 `master`,merge commit 为 `283034bd05895753675b716b86918b7db306a8d7`。 + +## 实现与验证 + +- 支持音色、模型语速、后处理语速、分段停顿、字幕字号/边框/边距、输出帧率、CRF 和编码预设。 +- metadata 保存继续使用 revision、`If-Match`、结构校验、原子写入与 HTTP 409 冲突合同。 +- Artificer 在 NC01 完成 14 项定向测试与 Web 构建,工具带保持紧凑直角工业风,编码预设使用中文标签。 +- 正常 PR merge 自动触发 PaC;PipelineRun 成功,Argo `Synced/Healthy`,runtime `1/1`。本次修改命中环境身份文件,`ENV_REUSE=miss`,镜像构建 148 秒,Pipeline 161 秒,PR merge 至 Pipeline 完成 172 秒;该超预算事实为非阻塞性能证据。 +- 公网 `4317` 真实闭环:Web 临时保存 `segmentPauseMs=260`;内嵌 WebTerm 的持久 Codex 会话通过正式 CLI 创建 voice 作业 `20260714-202500-998ac72d`,14 秒成功;API/manifest 投影显示有效值 260。 +- 验收后 Web 清空覆盖,并由卡片专用“本地配音”运行按钮和中文确认框创建作业 `20260714-202706-48f34f1d`,13 秒成功;metadata 不再含 tts 覆盖,音频恢复全局有效值 `segmentPauseMs=150`。 +- WebTerm 重启按钮已实测,重启代次 42,会话以“恢复上次会话”恢复;默认字号 15 像素、凭据就绪,未打印凭据。 +- 桌面 1920x1080 与移动 390x844 均无横向溢出;截图为 `/tmp/selfmedia-r5-production-settings-desktop.png` 与 `/tmp/selfmedia-r5-production-settings-mobile.png`。 + +## 边界 + +只合并和部署 `master` 开发集群;未触碰 `release`、生产 namespace 或公网 `4319`,未人工创建 PipelineRun,未执行 Argo sync/refresh 或 Pod patch。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R5.3_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R5.3_Task_Report.md new file mode 100644 index 00000000..9579d9fb --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R5.3_Task_Report.md @@ -0,0 +1,18 @@ +# R5.3 任务报告 + +## 结论 + +SELFMEDIA 首批 Web 手动编辑工作台已完成。PR [#46](https://github.com/pikainc/selfmedia/pull/46) 建立期次资源 REST API 与统一 CLI,PR [#50](https://github.com/pikainc/selfmedia/pull/50) 建立 RESTful 深链内的资源编辑器,PR [#51](https://github.com/pikainc/selfmedia/pull/51) 增加期次级配音、字幕和视频参数工具带;均只合并 `master`。 + +## 能力 + +- 可编辑 metadata、来源、文稿、微信公众号 Markdown、场景/分镜,以及配音、字幕和视频生产参数。 +- 保存状态、未保存草稿、保存中、错误和 revision 冲突均使用中文;冲突时保留草稿并禁止覆盖。 +- Web 与 WebTerm/CLI 使用同一 edition/resource API、revision 与磁盘资源,不建立第二状态库。 +- 产物资源保持只读,参数通过 metadata 的 `spec.production` 编辑;单步仍由卡片内专用“运行”按钮和确认框创建。 +- 公网 `4317` 已完成 Web 保存、WebTerm CLI 回读/运行、manifest 有效值、刷新保持和恢复原值闭环。 +- 桌面和移动端均无横向溢出,Web 工厂保持紧凑直角工业风。 + +## 边界 + +未发布 `release`、生产 namespace 或公网 `4319`。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R5_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R5_Task_Report.md new file mode 100644 index 00000000..0eb52820 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R5_Task_Report.md @@ -0,0 +1,18 @@ +# R5 任务报告 + +## 结论 + +SELFMEDIA 开发集群 Web 工厂的中文化、RESTful 深链和首批手动编辑能力已完成,并已通过公网 `http://152.53.229.148:4317` 真实验收。交付由 PR [#43](https://github.com/pikainc/selfmedia/pull/43)、[#44](https://github.com/pikainc/selfmedia/pull/44)、[#46](https://github.com/pikainc/selfmedia/pull/46)、[#50](https://github.com/pikainc/selfmedia/pull/50) 和 [#51](https://github.com/pikainc/selfmedia/pull/51) 组成。 + +## 终态 + +- 页面按钮、状态、空态、错误、确认框、终端工具栏和辅助文本使用中文;保留技术标识时提供中文语义。 +- 每一期与资源使用 `/editions/:editionId/resources/:resourceId` 深链,可复制、刷新恢复、前进后退;切期显示目标期次加载态并清空上一期内容,乱序响应不会覆盖当前选择。 +- Web 可编辑期次元数据、来源、文稿、微信公众号文章、场景/分镜、配音、字幕和视频参数;保存、冲突和继承状态中文可见。 +- Web、WebTerm 和统一 TypeScript CLI 共享同一 REST API、revision 合同与 edition/resource 真相;单步运行使用独立按钮和确认框。 +- 公网真实闭环验证了 Web 编辑、WebTerm CLI、voice 单步、manifest 有效值、恢复原配置、Codex 持久 session/restart、桌面与移动布局。 +- 自动交付只由正常 `master` PR merge 触发,运行面最终为 Argo `Synced/Healthy`、runtime `1/1`。 + +## 边界 + +全程未发布 `release`、生产 namespace 或公网 `4319`。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R6.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R6.1_Task_Report.md new file mode 100644 index 00000000..26d60859 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R6.1_Task_Report.md @@ -0,0 +1,43 @@ +# R6.1 任务报告 + +## 目标 + +修复 SELFMEDIA 生产首次 env-reuse cache miss 时,BuildKit 已推送镜像但在收尾返回 `context deadline exceeded`,导致 GitOps 阶段未执行的问题;保持自动交付,不用手工补跑。 + +## 现场根因 + +- 失败发布提交:`91c23dd0e45916336e03e65532dea7daa73b0323`。 +- 失败 PipelineRun:`selfmedia-production-nc01-91c23dd0e45916336e03e65532dea7dacl775`。 +- PipelineRun 178 秒后在 `image-build` exit 1。 +- 日志证明 layers、config、manifest 和 registry push 已完成,digest 为 `sha256:b95b17386db89a5ba5e88a1990eeea8c182c9602d1902e52c78f8194b4e54be7`,随后最后一行才出现 `error: context deadline exceeded`。 +- 旧生产运行面始终保持 Synced/Healthy,没有错误滚动。 + +## 实现 + +- GitHub issue:pikainc/selfmedia#56。 +- Artificer 提交:`5bc294aab2d44371d6facf17228b763501da131c`。 +- 开发 PR:pikainc/selfmedia#57。 +- master 合并提交:`a97f87756923bf9c82dca9ff5d9956a603f068ff`。 +- 生产 PR:pikainc/selfmedia#58。 +- release 合并提交:`1b8117de839d6274535e43f17bd2fbb7114ba2a1`。 +- `deploy/scripts/build-image.sh` 提取单一 POSIX sh 构建函数。 +- 只有日志同时证明 layers 和 manifest 推送完成,且最后非空行是 deadline 错误时,才执行一次相同构建重试。 +- 正常成功不重试;普通错误不重试;第二次失败原样失败;不把镜像存在当作成功。 +- 没有新增阻塞校验;超过 120 秒仍只记非阻塞 warning 和 env-reuse hint。 + +## 验证 + +- `sh -n deploy/scripts/build-image.sh deploy/scripts/build-image.test.sh` 通过。 +- 四类脚本测试通过:首次成功、特定 deadline 后重试成功、非目标错误不重试、第二次仍失败。 +- 开发 PipelineRun:`selfmedia-nc01-a97f87756923bf9c82dca9ff5d9956a603f068ff-txkxm`,Succeeded,88 秒,env reuse hit。 +- 开发阶段耗时:source 1 秒、identity 0 秒、image build 66 秒、GitOps 3 秒。 +- 开发 GitOps revision:`15a48f1585abc45c7998ed504e0c7696482cddbb`。 +- 开发 Argo:Synced/Healthy;运行镜像 digest:`sha256:7b267759a4f0db041dce82d08237ac58bd12d4784d650b2b492b79a2eb0f1125`;4317 健康。 +- 生产 PipelineRun:`selfmedia-production-nc01-1b8117de839d6274535e43f17bd2fbb7j4w42`,Succeeded,59 秒。 +- 生产阶段耗时:source 2 秒、identity 0 秒、image build 41 秒、GitOps 3 秒。 +- 生产 GitOps revision:`b80059b19aeb4edf6b018c986f17e9d496915f1f`。 +- 生产 Argo:Synced/Healthy;1/1;运行镜像 digest:`sha256:8b21258fc6aeb7ad353a7a84ac1dfd7b68e439f16e673910d9dabc4db4c19464`;4319 健康。 + +## 交付边界 + +全程只通过 PR merge 触发自动 PaC;没有手工 PipelineRun、Argo sync/refresh、Pod patch、mirror sync 或镜像/GitOps 补齐。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R6_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R6_Task_Report.md new file mode 100644 index 00000000..71e3126a --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R6_Task_Report.md @@ -0,0 +1,77 @@ +# R6 任务报告 + +## 目标 + +完成 Web 导航加载范围与全局增量刷新重构:资源切换只加载目标资源;切期保留大范围加载;手动和自动刷新保留旧内容、增量更新实际所需数据并保护编辑草稿;先验证开发 4317,再自动发布生产 4319。 + +## 交付 + +- GitHub issue:pikainc/selfmedia#52。 +- Artificer 功能提交:`92f61c5782fbb1669e1e7dec58102c6dadcabf04`。 +- 并发边界修正:`d07e436a01daf6a0bc611b62bbb8038ec7467ecd`。 +- 功能 PR:pikainc/selfmedia#53。 +- master 合并提交:`cb0ae1d1e6e8ede83e0da433724887999edce1a7`。 +- 首次 master→release PR #54 因生产 PaC add/add 历史冲突关闭。 +- 协调分支合并提交:`1d6b5f9c945f4454bb71af3e1b5179a36020a92b`,双亲为 release `48e92b2` 与 master `cb0ae1d`;合并树与 origin/master 内容零差异。 +- 生产发布 PR:pikainc/selfmedia#55,release 合并提交 `91c23dd0e45916336e03e65532dea7daa73b0323`。 +- 生产首次 cache miss 暴露的 BuildKit 收尾超时已在子任务 R6.1 / issue #56 修复。 +- 最终生产 release 提交:`1b8117de839d6274535e43f17bd2fbb7114ba2a1`。 + +## 功能行为 + +- `config/server.yaml#spec.webRefresh` 是唯一配置真相: + - 默认 5000ms; + - 可选 5000/10000/30000/60000ms; + - 允许关闭。 +- 全局刷新协调器只刷新健康、系统、期次列表、任务、当前期次详情、当前可见资源及打开的元数据。 +- 手动和自动刷新保留旧内容,不出现整页加载,不清空当前期次或生产信号链。 +- 同一请求在途时不重叠;资源保存只排队一次尾随刷新。 +- 刷新响应以 request id 和 edition identity 双重隔离,旧期次响应不能覆盖新期次。 +- dirty/saving/conflict 状态保留本地草稿;后台新 revision 只提示,不覆盖编辑内容。 +- 切资源只更新资源路由和资源工作台;切期仍清空旧期内容并显示目标期次大范围加载。 +- Web 保持中文、紧凑、直角工业风。 + +## 自动化验证 + +- Artificer 修正后:18 个测试通过、101 个断言通过。 +- Web build 通过。 +- 双视口 E2E:2 个测试、26 个断言通过。 +- E2E 覆盖双期次、禁用 abort、延迟旧响应、切期加载和资源保存唯一尾随刷新。 +- 开发功能 PipelineRun:`selfmedia-nc01-cb0ae1d1e6e8ede83e0da433724887999edce1a7-kkbs5`,Succeeded,79 秒,env reuse hit。 +- 最终开发 PipelineRun:`selfmedia-nc01-a97f87756923bf9c82dca9ff5d9956a603f068ff-txkxm`,Succeeded,88 秒。 +- 最终开发 GitOps revision:`15a48f1585abc45c7998ed504e0c7696482cddbb`,Argo Synced/Healthy。 +- 最终生产 PipelineRun:`selfmedia-production-nc01-1b8117de839d6274535e43f17bd2fbb7j4w42`,Succeeded,59 秒。 +- 最终生产 GitOps revision:`b80059b19aeb4edf6b018c986f17e9d496915f1f`,Argo Synced/Healthy,运行副本 1/1。 +- 开发入口:`http://152.53.229.148:4317`。 +- 生产入口:`http://152.53.229.148:4319`。 + +## 生产浏览器验收 + +- 登录鉴权和公网 IP 访问正常。 +- 自动刷新实际默认值为 `5000`。 +- 选项为:关闭、5 秒、10 秒、30 秒、1 分钟。 +- 来源证据切换到口播文案: + - 当前期次标题保持; + - 生产信号链 loading 数量 0; + - 生产信号链 loading mutation 数量 0。 +- 手动刷新: + - 当前期次标题和资源内容保持; + - 生产信号链 loading 数量 0; + - loading mutation 数量 0。 +- 未保存草稿在增量刷新后保持,状态为“未保存”,生产信号链 loading 数量和 mutation 数量均为 0。 +- 通过浏览器侧 500ms 延迟目标期次详情请求,明确观察到切期的大范围 loading,随后目标期次正常 ready。 +- 1920×1080 桌面截图通过视觉核验: + - `/tmp/selfmedia-r6-prod-desktop.png` + - SHA-256:`82cd37358a42d077af8a2ffd572b44bbfc0a8175a5c2a94636448e8d0c441725` +- 390×844 移动端: + - viewport、body 与 scrollWidth 均为 390; + - 刷新控件 x=12、y=67、width=366、height=35; + - masthead x=0、y=0、width=390、height=148; + - 无横向溢出。 +- 移动端截图: + - `/tmp/selfmedia-r6-prod-mobile.png` + - SHA-256:`782fd23c7c7b31d67189289edefa5eede14cd02edc41610d5a4b0c260237a9a6` + +## 发布约束 + +全程通过 GitHub PR merge 自动触发 PaC。没有手工 PipelineRun、Argo sync/refresh、Pod patch、mirror sync 或运行面补齐。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R7_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R7_Task_Report.md new file mode 100644 index 00000000..69beb22c --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R7_Task_Report.md @@ -0,0 +1,32 @@ +# R7 任务报告 + +## 结果 + +已修复全局手动/周期增量刷新清空 SelfMedia 内嵌 WebTerm 的问题,并通过正常 PR merge 自动发布到开发 4317 与生产 4319。 + +## 实现 + +- `App` 在 WebTerm 每次打开周期锁定当时的 terminal config;全局刷新产生的新 `system.terminal` 对象不再进入已打开终端的 effect identity。 +- 关闭终端后清除锁定配置,再次打开时采纳最新配置。 +- 新增 WebTerm 自身“刷新终端”按钮,参考 `../webterm`:发送临时行数 resize,60ms 后恢复真实行数 resize,再执行 xterm 全行 refresh 与 focus。 +- 手动重绘不关闭 WebSocket、不 reset、不清历史;原有重新连接、重启 Codex、锁定、字号和 resume 保持独立。 +- 新增纯逻辑测试覆盖两轮全局刷新配置引用保持、双 resize 顺序、延迟、refresh 与 focus。 + +## GitHub + +- Issue:[pikainc/selfmedia#59](https://github.com/pikainc/selfmedia/issues/59) +- 开发 PR:[pikainc/selfmedia#60](https://github.com/pikainc/selfmedia/pull/60),master merge `de66aff05d2055af2515737eaef4586c4b352c6e`。 +- 生产 PR:[pikainc/selfmedia#61](https://github.com/pikainc/selfmedia/pull/61),release merge `dab3b629f58556f03f0b99150dd139db5b6b653f`。 + +## 验证 + +- NC01:`bun test web/tests` 20/20、`bun run --cwd web build`、`git diff --check` 通过。 +- 开发自动 PaC:PipelineRun 成功,env reuse=hit,Argo Synced/Healthy,GitOps revision `2827bf77ac6a…`,runtime digest `sha256:98afdb185cd…`。 +- 生产自动 PaC:PipelineRun 89 秒成功,env reuse=hit,Argo Synced/Healthy,GitOps revision `bb91a645582b…`,runtime digest `sha256:e31a865d16c…`。 +- 4317 与 4319:默认 5 秒自动刷新跨两个周期并再执行一次全局手动刷新,均为 WebSocket `starts=1 / closes=0`、新增 snapshot=0、xterm identity 不变。 +- 4317 与 4319:“刷新终端”均只发送两次 resize,实测行数 `15→16`,WebSocket 不关闭,xterm identity 不变。 +- 4317 与 4319:390×844 下按钮完整可见且终端连接未重建。 + +## 非阻塞观察 + +PaC 状态仍披露既有 `pac-argo-revision-unknown` 只读诊断;实际 PipelineRun、Argo 与 runtime digest 已一致,未阻断滚动,本任务未执行任何人工补跑。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R8.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R8.1_Task_Report.md new file mode 100644 index 00000000..891d4642 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R8.1_Task_Report.md @@ -0,0 +1,17 @@ +# R8.1 任务报告 + +## 交付结果 + +- 创建并关闭 [UniDesk #2113](https://github.com/pikasTech/unidesk/issues/2113)。 +- [UniDesk PR #2114](https://github.com/pikasTech/unidesk/pull/2114) 已合并到 master,merge commit 为 db0020863f63104f93cd039ba0cd003c56d3a691。 +- unidesk-selfmedia skill 的高频流程已固化唯一源码验证入口: + - trans NC01:/root/.worktrees/selfmedia/ argv bun run verify:web。 +- skill 明确禁止先探测 node_modules、手工安装依赖,再分别重复 Web 测试、生产构建和差异检查。 +- skill 明确源码验证不替代浏览器、公网、鉴权、WebTerm 与 PaC/Argo 运行面验收。 + +## 验证证据 + +- unidesk-selfmedia skill quick_validate 通过。 +- git diff --check 通过。 +- SELFMEDIA #62 的全新 worktree 实测已证明首次单调用自动安装、第二次单调用复用依赖。 +- 未修改运行配置,未执行部署。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R8_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R8_Task_Report.md new file mode 100644 index 00000000..216788fd --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R8_Task_Report.md @@ -0,0 +1,35 @@ +# R8 任务报告 + +## 交付结果 + +- 创建并关闭 [SELFMEDIA #62](https://github.com/pikainc/selfmedia/issues/62)。 +- [SELFMEDIA PR #63](https://github.com/pikainc/selfmedia/pull/63) 已合并到 master,merge commit 为 9fb96215c15bc38e700922a328181a1680f1a707。 +- 新增唯一 CLI 子命令 verify web,并提供 package 脚本 bun run verify:web。 +- 现有 CLI 主体迁入 scripts/src/cli-main.ts,scripts/selfmedia-cli.ts 保持为 18 行的无第三方依赖自举入口。 +- 依赖复用身份由根目录或 web 目录的 package.json 与 bun.lock 哈希生成;身份一致时复用 node_modules,变化时执行 bun install --frozen-lockfile。 +- 同一命令依次执行 Web 测试、Web 生产构建与 git diff --check HEAD,按 JSONL 输出阶段、状态、耗时和紧凑失败摘要。 + +## 验证证据 + +- 全新 worktree 的根目录和 web/node_modules 均不存在。 +- 第一次仅调用 bun scripts/selfmedia-cli.ts verify web: + - 根目录依赖 installed,506 ms; + - Web 依赖 installed,426 ms; + - Web 测试 passed,241 ms; + - 生产构建 passed,17024 ms; + - 差异格式检查 passed,19 ms; + - 总耗时 18239 ms。 +- 第二次仅调用同一命令: + - 根目录依赖 reused; + - Web 依赖 reused; + - Web 测试、生产构建和差异格式检查全部通过; + - 没有重复安装依赖。 +- 暂存变更后使用 bun run verify:web 验证失败契约,命令准确发现 scripts/src/cli-main.ts 末尾多余空行并返回 exit 1;修正后再次运行全部通过。 +- 原有 CLI help 和 check 入口仍可运行。 +- 仓库全量 typecheck 仍报告既有 web-auth.e2e.test.ts:105 unknown 类型错误;该文件不在本次改动范围,未将既有问题扩大为新门禁。 +- 未手工触发 PipelineRun、Argo、rollout 或 release 发布。 + +## 调用缩减 + +- 改进前需要依赖存在性探测、根目录安装、Web 安装、Web 测试、生产构建和 diff 检查等多次调用,并可能在首次失败后重复。 +- 改进后源码 Web 验证只需一次 bun run verify:web;首次自动准备依赖,后续自动复用。 diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md index d2710b24..6a6edba8 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -27,3 +27,95 @@ ### R3.1 [completed] 按用户最新要求将非选中 consumer 的全局配置一致性错误降级为结构化 non-blocking warning,避免开发 source-artifact/状态/交付被生产配置阻塞;当前选中 target/consumer 的字段、权限、ownership 与安全校验仍 fail-closed,并由定向测试证明两类边界,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R3.1_Task_Report.md)。 + +### R3.2 [completed] + +依据 [UniDesk #2052](https://github.com/pikasTech/unidesk/issues/2052) 修复 PaC 在 expectedRevision 与 observedRevision 完全相同时仍因 owning Git fetch 失败误判 `relation=unknown`:相同 revision 直接判定 exact,额外 fetch 失败仅输出 `blocking=false` warning,保持真实不一致和 mutation target 安全边界;用独立 worktree/PR 和定向 evaluator/source-artifact/CLI 验证收口,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R3.2_Task_Report.md)。 + +### R3.3 [completed] + +依据 [SELFMEDIA #36](https://github.com/pikainc/selfmedia/issues/36) 修复生产期次 prod-webterm-20260714174507 在 voice 阶段因 utterance-story-04-07 含 Kokoro/Misaki 无法发音字符而失败:读取真实文本与 phoneme 定位字符,在既有 TTS 归一化职责内做最小修复并增加定向测试;先用 step run voice 单步验证,release PR 正常自动交付后从 voice 续跑至 inspect,不重跑已成功阶段,再将同一修复回补 master 并完成公网产物验收,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R3.3_Task_Report.md)。 + +## R4 [in_progress] + +依据用户最新要求为 SELFMEDIA 自动交付增加 120 秒端到端预算可见性并继续优化 env reuse:超过 owning YAML 阈值只输出 blocking=false、mutation=false 的结构化 warning 与 env reuse hint,不能阻断 PipelineRun、GitOps/Argo、runtime 或 health;针对当前 ENV_REUSE=hit 但 imageBuild 仍为 181-184 秒、端到端为 203-210 秒的事实,定位真实耗时并通过正常 master/release PR 自动交付将流水线优先压到 120 秒预算内,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R4_Task_Report.md)。 + +### R4.1 [completed] + +依据 [UniDesk #2064](https://github.com/pikasTech/unidesk/issues/2064) 在 owning YAML 声明 120 秒自动端到端预算;交付超过阈值时在 delivery-timing 与适用状态投影中输出 blocking=false、mutation=false 的 typed warning 和 env reuse 优化 hint,120 秒内不报警、时间证据不完整保持 partial,任何 warning 不得阻断业务滚动,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R4.1_Task_Report.md)。 + +### R4.2 [completed] + +依据 [UniDesk #2065](https://github.com/pikasTech/unidesk/issues/2065) 对 SELFMEDIA 已 ENV_REUSE=hit 但 imageBuild 181-184 秒的问题做目标侧阶段定位,优化依赖、模型/工具层和 BuildKit/cache 复用且保持身份变化正确失效;通过正常 master/release PR merge 验收端到端预算、digest、Argo/runtime health 与 exact verifier,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R4.2_Task_Report.md)。 + +## R5 [completed] + +依据用户最新要求改进 SELFMEDIA 开发集群 Web 工厂:所有用户可见页面全量中文化;每一期和资源提供可刷新恢复的 RESTful 深链,切期时 URL、数据和加载状态严格同步并防止乱序响应覆盖;尽可能提供期次、文稿、来源、时间轴、文章、场景、图片、TTS、视频参数和单步重跑的手动编辑能力,与 WebTerm/CLI 共享同一 edition/resource 真相。全部只合并 master 并在测试集群验收,禁止发布 release、生产 namespace 或公网 4319,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R5_Task_Report.md)。 + +### R5.1 [completed] + +依据 [SELFMEDIA #41](https://github.com/pikainc/selfmedia/issues/41) 将所有用户可见页面、按钮、状态、空态、错误、确认框、终端工具栏和辅助文本统一为中文;技术字段、命令和品牌名保留时补中文语义,建立可审计文案结构和静态/浏览器验证,保持紧凑工业风,只上线 master 测试集群,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R5.1_Task_Report.md)。 + +### R5.2 [completed] + +依据 [SELFMEDIA #40](https://github.com/pikainc/selfmedia/issues/40) 为每一期和资源建立可复制、刷新恢复、前进后退一致的 RESTful 深链;切期/切资源时显示目标期次中文加载态,使用请求 identity/abort 防止乱序覆盖,无效深链显示中文 not-found,并在 master 测试集群完成快速切期和慢请求验证,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R5.2_Task_Report.md)。 + +### R5.3 [completed] + +依据 [SELFMEDIA #39](https://github.com/pikainc/selfmedia/issues/39) 增强 Web 手动编辑工作台,首批覆盖期次元数据、选题、文稿、来源、时间轴、微信公众号文章、场景文本/图片/缩放、TTS 与视频参数;保存和冲突状态中文可见,复用同一 API/CLI source of truth,卡片独立运行按钮与确认框不变,只上线 master 测试集群,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R5.3_Task_Report.md)。 + +#### R5.3.1 [completed] + +依据 [SELFMEDIA #45](https://github.com/pikainc/selfmedia/issues/45) 建立期次资源编辑 REST API 与同路径 CLI:从同一期既有文件读取并通过 revision/If-Match、结构校验和原子写入保存,首批覆盖元数据、来源、文稿、微信 Markdown 与分镜;WebTerm CLI 复用同一 API,禁止任意路径写入、跨期次覆盖、第二状态库和 release/生产发布,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R5.3.1_Task_Report.md)。 + +#### R5.3.2 [completed] + +依据 [SELFMEDIA #47](https://github.com/pikainc/selfmedia/issues/47) 在 RESTful 期次/资源深链中加入紧凑工业风手动编辑工作台:消费 #45 的 revision/ETag API,覆盖元数据、来源、文稿、微信 Markdown、场景/分镜以及只读资源说明,中文显示 dirty/saving/saved/conflict/error,切期取消旧草稿并禁止跨期保存;保留卡片独立运行按钮与确认框,只上线 master 测试集群,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R5.3.2_Task_Report.md)。 + +#### R5.3.3 [completed] + +依据 [SELFMEDIA #49](https://github.com/pikainc/selfmedia/issues/49) 在现有 edition.yaml 权威资源中加入受结构校验的期次级配音、字幕和视频参数覆盖,由 REST API、CLI、Web 与 voice/subtitles/render 单步共享同一真相;缺省继承 config/tools.yaml,只允许 master 开发集群与公网 4317 验收,禁止 release、生产 namespace 和 4319,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R5.3.3_Task_Report.md)。 + +## R6 [completed] + +依据 [SELFMEDIA #52](https://github.com/pikainc/selfmedia/issues/52) 重构 Web 导航加载与全局刷新:同一期切换资源仅加载目标资源,不让当前期次和生产信号链进入重新加载;切期继续显示大范围目标期加载并防乱序;增加 YAML-first 的全局手动刷新和默认 5 秒可选自动刷新,保留旧内容、避免重叠请求并保护 dirty/saving/conflict 草稿。先合并 master 并在 4317 验收,再通过 master 到 release 的正常 PR 自动滚动生产 4319,禁止手工 PipelineRun、Argo sync 或运行面 patch,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R6_Task_Report.md)。 + +### R6.1 [completed] + +修复 SELFMEDIA #56:生产首次缓存未命中时 BuildKit 已推送镜像但收尾 context deadline exceeded;实现仅对该特定错误的一次有界重试,覆盖脚本测试,先走 master/4317 自动 PaC 验收,再走 release/4319,禁止手工补跑,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R6.1_Task_Report.md)。 + +## R7 [completed] + +依据 [SelfMedia #59](https://github.com/pikainc/selfmedia/issues/59) 修复全局手动/周期增量刷新清空内嵌 WebTerm:终端配置、xterm 与 WebSocket 生命周期独立于业务投影刷新,只有 WebTerm 自身“刷新终端”按钮按 ../webterm 的临时行数 resize→真实行数 resize→本地 refresh 语义无损重绘;先在开发入口 4317 验证终端连接/历史不变,再通过 release PR 自动发布并在生产入口 4319 复测,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R7_Task_Report.md)。 + +## R8 [completed] + +依据 [SELFMEDIA #62](https://github.com/pikainc/selfmedia/issues/62) 增加仓库自带的单命令 Web 验证入口:自动按锁文件准备根目录与 web 依赖,依次运行 Web 测试、生产构建和 git diff --check,输出紧凑阶段摘要、耗时、依赖复用状态与失败原因;在全新 worktree 首次调用和同 worktree 第二次调用中证明原有多步探测、安装和重复验证收敛为一次调用,不修改运行配置、不部署开发或生产环境,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R8_Task_Report.md)。 + +### R8.1 [completed] + +依据 [UniDesk #2113](https://github.com/pikasTech/unidesk/issues/2113) 在 unidesk-selfmedia skill 固化 SelfMedia Web 源码验证最短路径:通过 trans 在 NC01 目标 worktree 单次运行 bun run verify:web,替代依赖探测、安装、Web 测试、生产构建和差异格式检查的手工多步调用;明确该入口不替代浏览器、公网、鉴权、WebTerm 与 PaC/Argo 运行面验收,通过 skill quick_validate 并证明同类源码验证只需一次受控调用,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R8.1_Task_Report.md)。 + +## R9 [in_progress] + +依据 [UniDesk #2132](https://github.com/pikasTech/unidesk/issues/2132) 在 D518 部署先进中文语音推理服务并接入 SELFMEDIA 开发集群:先在 D518 临时目录以 Docker GPU smoke 验证 Qwen3-TTS 0.6B CustomVoice,成功后由 owning YAML 和受控 CLI 固化 D518 推理容器、NC01 FRPS、D518 FRPC、Secret sourceRef 与公网直连端口,再扩展 SelfMedia 远程 TTS 引擎并通过正常 master 自动交付在 4317 完成 voice 单步 smoke;禁止修改 release、生产 namespace/4319、手工长期运维或引入 Caddy,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9_Task_Report.md)。 + +### R9.1 [in_progress] + +依据 [UniDesk #2133](https://github.com/pikasTech/unidesk/issues/2133) 由 Artificer 经 trans 在 D518 /tmp 完成 Qwen3-TTS 0.6B CustomVoice Docker GPU smoke:锁定官方上游 ref、镜像/构建、低显存参数和 health/speech API,以固定中文技术资讯文本验证首次与热调用、WAV 解码、实时率和峰值显存;成功后清理临时容器并留下可接力证据,失败则在一次官方低显存参数尝试后停止且不无界更换模型,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.1_Task_Report.md)。 + +### R9.2 [in_progress] + +依据 [SELFMEDIA #65](https://github.com/pikainc/selfmedia/issues/65) 由 Artificer 为 voice 单步增加 YAML 显式选择的 remote-openai-speech 自托管引擎:基础地址只从 YAML 声明的环境变量读取,模型、音色、指令、语速和引擎进入缓存身份,cache miss 调用 /v1/audio/speech 并原子写入 WAV,保留现有分段、edition 覆盖、FFmpeg 语速/响度和 timeline,禁止硬编码公网端口或静默回退 Kokoro,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.2_Task_Report.md)。 + +### R9.3 [in_progress] + +依据 [SELFMEDIA #66](https://github.com/pikainc/selfmedia/issues/66) 由 Artificer 为 SelfMediaDeployment renderer 增加通用 runtime.environment 与 networkPolicy.allowedTcpEndpoints:严格校验并把普通业务环境变量投影到 backend,按 CIDR 和 TCP ports 为被 blockedCidrs 排除的地址生成精确 egress 例外,默认空配置保持现有语义;禁止删除网络隔离、放开全端口、硬编码 D518/语音服务或修改生产配置,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.3_Task_Report.md)。 + +#### R9.3.1 [in_progress] + +依据 [SELFMEDIA #69](https://github.com/pikainc/selfmedia/issues/69) 将部署校验从全仓唯一 PipelineRun 断言改为按 owning target/环境独立验证:参数化解析开发与生产 PaC 制品,保留缺失、重复、错误归属的真实失败,但不得让合法多环境配置互相阻塞;用一个多环境 smoke 证明通用合同后完成本改进,且不得触碰或阻塞 R9 语音运行面,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.3.1_Task_Report.md)。 + +### R9.4 [in_progress] + +依据 [UniDesk #2139](https://github.com/pikasTech/unidesk/issues/2139) 将 D518 先进语音推理与 NC01 直连 FRP 固化为 YAML-first 受控服务:owning YAML 声明 Docker GPU 服务、模型/source ref、持久缓存、NC01 FRPS、D518 FRPC、Secret sourceRef、端口和 health/speech smoke,CLI 提供 plan、secret init、apply、status、logs、validate;可在 R9.1 下载期间完成代码和渲染,但必须等 GPU HTTP/WAV smoke 成功后才允许正式 apply,且不触碰 release、生产 namespace、4319 或 Caddy,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.4_Task_Report.md)。 From 2b385f08f8cc43994eddebd6c514b5206b8c0002 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:52:20 +0200 Subject: [PATCH 20/26] =?UTF-8?q?fix(cicd):=20=E4=BF=9D=E7=95=99=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=20PaC=20bootstrap=20=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R4.1.1_Task_Report.md | 11 +++---- ...-infra-pipelines-as-code-bootstrap.test.ts | 6 ++++ ...platform-infra-pipelines-as-code-remote.sh | 33 +++++++------------ 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md index fcc3135b..a6720ec1 100644 --- a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md @@ -29,14 +29,13 @@ - CEL、marker、creator 与 ServiceAccount; - queue transition、spec/config SHA、版本与 resource epoch; - 默认 RBAC。 -- 上述观察项不再作为以下入口的阻断条件: +- admission/provenance 观察项不再作为以下入口的阻断条件: - 业务 admission 与 bootstrap; - status、history 与 debug; - 外层 PaC delivery eligibility。 -- admission 与 consumer bootstrap 漂移统一输出结构化字段: - - `blocking: false`; - - `warning: true|false`; - - `warnings` / `reasons`。 +- admission 漂移输出结构化 `blocking: false` warning;真实 consumer bootstrap 缺失不降级: + - runner ServiceAccount、RoleBinding 或 Argo repository credential 未就绪时,status 保持 `ok: false`; + - 顶层 status 继续要求 `bootstrap_ready=true`,避免 false green。 - PaC bootstrap 的 `pac-admission` 阶段在 live identity 或 generation 尚未精确收敛时显示 `state: warning`,整体结果仍可成功。 - 外层 GitHub/Gitea PaC push event 继续作为: - 唯一流水线触发; @@ -57,7 +56,7 @@ - `sh -n scripts/src/platform-infra-pipelines-as-code-remote.sh` - `bun test scripts/src/platform-infra-pac-provenance.test.ts scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts` - - 结果:`23 pass, 0 fail, 234 expect()`。 + - 结果:`23 pass, 0 fail, 239 expect()`。 - `node` 调用 `runPacStatusFixtureChecks()`: - 结果:`ok=true, checks=34`。 - `bun scripts/cli.ts platform-infra pipelines-as-code plan --target NC01` diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index e2f89a7a..1a31abba 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -177,6 +177,12 @@ test("PaC apply checks the Gitea repository before dry-run return and cluster mu expect(remote).toContain('"admission":{"applied":%s,"ready":%s}'); expect(remote).toContain('"admissionProvenance":%s'); expect(remote).toContain('"error":"gitea-repository-missing"'); + const statusAction = remote.slice(remote.indexOf("status_action() {"), remote.indexOf("history_action() {")); + expect(statusAction).toContain('[ "$bootstrap_ready" = "true" ]'); + expect(statusAction).toContain("output.code = 'pac-consumer-bootstrap-not-ready'"); + expect(statusAction).toContain("output.ok = false"); + expect(statusAction).toContain("code: 'pac-admission-provenance-not-ready'"); + expect(statusAction).toContain("blocking: false"); }); test("platform bootstrap help advertises the composite entry before low-level apply", async () => { diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 13a6a668..dc8ded1c 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -1612,37 +1612,26 @@ NODE const diagnostics = JSON.parse(process.env.UNIDESK_PAC_DIAGNOSTICS || '{}'); const admissionProvenance = JSON.parse(process.env.UNIDESK_PAC_STATE || '{}'); const consumerBootstrap = JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE || '{}'); +const output = { ...diagnostics, admissionProvenance, consumerBootstrap, valuesPrinted: false }; if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionProvenance.ready !== true) { - process.stdout.write(JSON.stringify({ - ...diagnostics, - warnings: [...(Array.isArray(diagnostics.warnings) ? diagnostics.warnings : []), { + output.warnings = [...(Array.isArray(output.warnings) ? output.warnings : []), { code: 'pac-admission-provenance-not-ready', blocking: false, hint: 'PaC admission policy, binding, desired RBAC, or live default-SA observation is drifted; delivery continues', reasons: Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons : [], - }], - admissionProvenance, - valuesPrinted: false, - })); -} else if (consumerBootstrap.configured === true && consumerBootstrap.ready !== true) { - process.stdout.write(JSON.stringify({ - ...diagnostics, - warnings: [...(Array.isArray(diagnostics.warnings) ? diagnostics.warnings : []), { - code: 'pac-consumer-bootstrap-not-ready', - blocking: false, - hint: 'declared runner ServiceAccount/RoleBinding or Argo repository credential is missing or drifted; status remains observable', - }], - admissionProvenance, - consumerBootstrap, - valuesPrinted: false, - })); -} else { - process.stdout.write(JSON.stringify({ ...diagnostics, admissionProvenance, consumerBootstrap, valuesPrinted: false })); + }]; } +if (consumerBootstrap.configured === true && consumerBootstrap.ready !== true) { + output.ok = false; + output.code = 'pac-consumer-bootstrap-not-ready'; + output.phase = 'consumer-bootstrap-not-ready'; + output.hint = 'declared runner ServiceAccount/RoleBinding or Argo repository credential is missing or drifted'; +} +process.stdout.write(JSON.stringify(output)); NODE ) printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"consumerBootstrap":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \ - "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && echo true || echo false )" \ + "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && [ "$bootstrap_ready" = "true" ] && echo true || echo false )" \ "$( [ -n "$crd" ] && echo true || echo false )" \ "$(json_string "$controller_ready")" \ "$UNIDESK_PAC_ADMISSION_STATE_JSON" \ From b84047b12385fb5e0eb061841c097ddc1fcf0057 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:53:29 +0200 Subject: [PATCH 21/26] fix(pikaoa): share PaC repository across release lanes --- config/platform-infra/pipelines-as-code.yaml | 41 +------------------ .../src/pikaoa-test-delivery-renderer.test.ts | 19 ++++++--- ...-infra-pipelines-as-code-bootstrap.test.ts | 2 +- 3 files changed, 16 insertions(+), 46 deletions(-) diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 442649ef..e3891b0c 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -252,45 +252,6 @@ repositories: runtime_service_port: "8080" health_path: /healthz health_url: http://pikaoa-web.pikaoa-test.svc.cluster.local:8080/healthz - - id: pikaoa-nc01 - name: pikaoa-nc01 - namespace: pikaoa-ci - providerType: gitea - url: https://gitea.pikapython.com/mirrors/pikainc-pikaoa - cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git - owner: mirrors - repo: pikainc-pikaoa - secretName: pac-gitea-pikaoa-nc01 - tokenKey: token - webhookSecretKey: webhook.secret - concurrencyLimit: 1 - params: - git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git - source_branch: release - source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01 - node: NC01 - pipeline_name: pikaoa-nc01-pac - pipeline_run_prefix: pikaoa-nc01 - service_account: pikaoa-nc01-tekton-runner - pipeline_timeout: 20m0s - workspace_pvc_size: 8Gi - image_repository: 127.0.0.1:5000/pikaoa/api - api_image_repository: 127.0.0.1:5000/pikaoa/api - worker_image_repository: 127.0.0.1:5000/pikaoa/worker - web_image_repository: 127.0.0.1:5000/pikaoa/web - registry_probe_base: http://127.0.0.1:5000 - gitops_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git - gitops_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git - gitops_branch: nc01-pikaoa-gitops - gitops_username: unidesk-admin - gitops_secret_name: pac-gitea-pikaoa-nc01 - gitops_manifest_path: deploy/gitops/nc01/resources.yaml - runtime_namespace: pikaoa - runtime_deployment: pikaoa-api - runtime_service: pikaoa-web - runtime_service_port: "8080" - health_path: /healthz - health_url: http://pikaoa-web.pikaoa.svc.cluster.local:8080/healthz consumers: - extends: templates.consumers.agentrunV02 variables: @@ -548,7 +509,7 @@ consumers: dnsPolicy: ClusterFirstWithHostNet fsGroup: 1000 - id: pikaoa-nc01 - repositoryRef: pikaoa-nc01 + repositoryRef: pikaoa-test-nc01 node: NC01 lane: pikaoa namespace: pikaoa-ci diff --git a/scripts/src/pikaoa-test-delivery-renderer.test.ts b/scripts/src/pikaoa-test-delivery-renderer.test.ts index 003b2eb2..c42ceb37 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.test.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.test.ts @@ -16,7 +16,16 @@ function rendererBinding(id = "pikaoa-test-nc01"): Parameters item.id === id); const repository = repositories.find((item) => item.id === consumer?.repositoryRef); if (repository === undefined || consumer === undefined) throw new Error(`PikaOA PaC fixture is required: ${id}`); - return { consumer, repository } as Parameters[0]; + return { + consumer, + repository: { + ...repository, + params: { + ...(repository.params as Record), + ...((consumer.params as Record | undefined) ?? {}), + }, + }, + } as Parameters[0]; } function sourceFixture(): string { @@ -27,21 +36,21 @@ function sourceFixture(): string { } describe("PikaOA test PaC renderer", () => { - test("keeps master test and release production consumers isolated", () => { + test("shares one PaC repository while isolating master and release consumers", () => { const document = pacDocument(); const repositories = document.repositories as Array>; const consumers = document.consumers as Array>; const repository = repositories.find((item) => item.id === "pikaoa-test-nc01"); const testConsumer = consumers.find((item) => item.id === "pikaoa-test-nc01"); const productionConsumer = consumers.find((item) => item.id === "pikaoa-nc01"); - const productionRepository = repositories.find((item) => item.id === "pikaoa-nc01"); + const duplicateProductionRepository = repositories.find((item) => item.id === "pikaoa-nc01"); expect((repository?.params as Record).source_branch).toBe("master"); expect((testConsumer?.sourceArtifact as Record).pipelineRunPath).toBe(".tekton/pikaoa-test-nc01-pac.yaml"); expect((testConsumer?.sourceArtifact as Record).renderer).toBe("pikaoa-test-runtime"); expect((productionConsumer?.params as Record).source_branch).toBe("release"); expect((productionConsumer?.params as Record).source_snapshot_prefix).toBe("refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01"); - expect(productionConsumer?.repositoryRef).toBe("pikaoa-nc01"); - expect((productionRepository?.params as Record).source_branch).toBe("release"); + expect(productionConsumer?.repositoryRef).toBe("pikaoa-test-nc01"); + expect(duplicateProductionRepository).toBeUndefined(); expect((productionConsumer?.sourceArtifact as Record).renderer).toBe("pikaoa-release-runtime"); expect((productionConsumer?.sourceArtifact as Record).configRef).toBe("config/pikaoa.yaml#releaseRuntime.targets.NC01"); }); diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index 99dd5965..bc0a0982 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -133,7 +133,7 @@ test("unselected parse and repository errors remain warnings while selected obje evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u), }); expect(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-test-nc01")).toBe(false); - expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).not.toThrow(); + expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).toThrow("cloneUrl"); const duplicateSelectedRepository = pacConfigDocument(); const repositories = duplicateSelectedRepository.repositories as Array>; From 20436b82902c434c0aaee33f74ce423ea94ec645 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:47:24 +0200 Subject: [PATCH 22/26] =?UTF-8?q?docs:=20=E6=95=B4=E7=90=86=20Sub2API=20?= =?UTF-8?q?=E8=BF=90=E7=BB=B4=E4=BB=BB=E5=8A=A1=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R2.10_Task_Report.md | 22 ++ .../R2.2.16_Task_Report.md | 47 ++-- .../R2.2.37_Task_Report.md | 94 ------- .../R2.2_Task_Report.md | 3 + .../R2.4.1_Task_Report.md | 23 ++ ...7_Task_Report.md => R2.4.2_Task_Report.md} | 2 +- ...8_Task_Report.md => R2.4.3_Task_Report.md} | 2 +- ...9_Task_Report.md => R2.4.4_Task_Report.md} | 4 +- ...0_Task_Report.md => R2.4.5_Task_Report.md} | 2 +- ...1_Task_Report.md => R2.4.6_Task_Report.md} | 2 +- ...2_Task_Report.md => R2.4.7_Task_Report.md} | 2 +- ...3_Task_Report.md => R2.4.8_Task_Report.md} | 2 +- ...4_Task_Report.md => R2.4.9_Task_Report.md} | 2 +- .../R2.4_Task_Report.md | 3 + .../R2.5.10_Task_Report.md | 108 ++++++++ .../R2.5.11_Task_Report.md | 37 +++ .../R2.5.12_Task_Report.md | 5 + ...5_Task_Report.md => R2.5.1_Task_Report.md} | 2 +- ...6_Task_Report.md => R2.5.2_Task_Report.md} | 2 +- ...7_Task_Report.md => R2.5.3_Task_Report.md} | 2 +- ...8_Task_Report.md => R2.5.4_Task_Report.md} | 2 +- ...9_Task_Report.md => R2.5.5_Task_Report.md} | 4 +- ...0_Task_Report.md => R2.5.6_Task_Report.md} | 4 +- ...1_Task_Report.md => R2.5.7_Task_Report.md} | 2 +- ...2_Task_Report.md => R2.5.8_Task_Report.md} | 2 +- .../R2.5.9_Task_Report.md | 68 +++++ .../R2.6.10_Task_Report.md | 41 +++ .../R2.6.11_Task_Report.md | 23 ++ .../R2.6.12_Task_Report.md | 43 ++++ ...3_Task_Report.md => R2.6.1_Task_Report.md} | 2 +- ...4_Task_Report.md => R2.6.2_Task_Report.md} | 2 +- ...5_Task_Report.md => R2.6.3_Task_Report.md} | 2 +- ...6_Task_Report.md => R2.6.4_Task_Report.md} | 2 +- .../R2.6.5_Task_Report.md | 6 + .../R2.6.6_Task_Report.md | 5 + .../R2.6.7_Task_Report.md | 42 ++++ .../R2.6.8_Task_Report.md | 24 ++ .../R2.6.9_Task_Report.md | 34 +++ .../R2.6_Task_Report.md | 3 + .../R2.7.1_Task_Report.md | 7 + .../R2.7.2_Task_Report.md | 41 +++ .../R2.7.3_Task_Report.md | 36 +++ .../R2.7_Task_Report.md | 3 + .../R2.8_Task_Report.md | 26 ++ .../R2.9_Task_Report.md | 22 ++ .../R3.1_Task_Report.md | 36 +++ .../R3.2_Task_Report.md | 44 ++++ .../R3.3_Task_Report.md | 37 +++ .../R3.4_Task_Report.md | 30 +++ .../R3.5_Task_Report.md | 30 +++ .../R3.6_Task_Report.md | 46 ++++ .../R3_Task_Report.md | 7 + docs/MDTODO/sub2api-upstream-reliability.md | 236 +++++++++++++----- 53 files changed, 1076 insertions(+), 202 deletions(-) create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.10_Task_Report.md delete mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.2.37_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.2_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.4.1_Task_Report.md rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.17_Task_Report.md => R2.4.2_Task_Report.md} (98%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.18_Task_Report.md => R2.4.3_Task_Report.md} (98%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.19_Task_Report.md => R2.4.4_Task_Report.md} (89%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.20_Task_Report.md => R2.4.5_Task_Report.md} (99%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.21_Task_Report.md => R2.4.6_Task_Report.md} (98%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.22_Task_Report.md => R2.4.7_Task_Report.md} (99%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.23_Task_Report.md => R2.4.8_Task_Report.md} (98%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.24_Task_Report.md => R2.4.9_Task_Report.md} (99%) create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.4_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.5.10_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.5.11_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.5.12_Task_Report.md rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.25_Task_Report.md => R2.5.1_Task_Report.md} (98%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.26_Task_Report.md => R2.5.2_Task_Report.md} (99%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.27_Task_Report.md => R2.5.3_Task_Report.md} (98%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.28_Task_Report.md => R2.5.4_Task_Report.md} (99%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.29_Task_Report.md => R2.5.5_Task_Report.md} (84%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.30_Task_Report.md => R2.5.6_Task_Report.md} (95%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.31_Task_Report.md => R2.5.7_Task_Report.md} (99%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.32_Task_Report.md => R2.5.8_Task_Report.md} (98%) create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.5.9_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.6.10_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.6.11_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.6.12_Task_Report.md rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.33_Task_Report.md => R2.6.1_Task_Report.md} (99%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.34_Task_Report.md => R2.6.2_Task_Report.md} (99%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.35_Task_Report.md => R2.6.3_Task_Report.md} (99%) rename docs/MDTODO/details/sub2api-upstream-reliability/{R2.2.36_Task_Report.md => R2.6.4_Task_Report.md} (99%) create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.6.5_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.6.6_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.6.7_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.6.8_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.6.9_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.6_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.7.1_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.7.2_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.7.3_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.7_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.8_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.9_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R3.1_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R3.2_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R3.3_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R3.4_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R3.5_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R3.6_Task_Report.md create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R3_Task_Report.md diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.10_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.10_Task_Report.md new file mode 100644 index 00000000..ac4198cb --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.10_Task_Report.md @@ -0,0 +1,22 @@ +# R2.10 任务报告 + +## 范围 + +通过 PK01 Sub2API 受控 CLI 调整手工 runtime 账号 ID `27` 的人类可读成本标注与账号全局优先级;未修改 Sub2API 源码、版本、YAML 管理范围或外部哨兵。 + +## 近期证据 + +近 24 小时原生账号聚合:账号原名称 `https://sub.yjxm1221.top plus 0.01`,尝试 `2472`、成功 `2429`、失败 `43`,可归因上游客户错误 `40`,失败率 `1.74%`;评分 `94.5/A`、置信度 high,TTFT P95 `11709ms`、样本 `1944`;请求 `2429`、Token `20.252M`、标准 API 计费基数 `242.2789 USD`。账号当时 active、schedulable,容量 `50`,优先级 `1`,窗口内临时不可调度命中 `30` 次。 + +## 决策与执行 + +新采购成本为每 1 USD 标准 API 计费对应 `0.025 CNY`。账号质量和时延仍优秀,但新成本高于 `iceiu 0.011`,因此优先级从 `1` 调整到 `3`:排在免费账号优先级 `1` 和 `iceiu` 优先级 `2` 之后,仍排在约 0.03 成本的优先级 `5` 账号之前。 + +- account-name dry-run:精确命中 ID `27`,仅名称从 `https://sub.yjxm1221.top plus 0.01` 变为 `https://sub.yjxm1221.top plus 0.025`。 +- priority dry-run:精确命中 ID `27`,仅优先级从 `1` 变为 `3`。 +- 两项 confirm 均 `writeSucceeded=1`、`writeFailed=0`、`reconciled=1`、`mismatched=0`。 +- 最终只读快照:名称 `https://sub.yjxm1221.top plus 0.025`,优先级 `3`,active、schedulable=true,proxy `3`,容量 `50`,原临时不可调度模板保持不变。 + +## 结论 + +成本标注与优先级已按最新价格、近期质量和时延完成精准 runtime 调优,最终状态与期望一致。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.16_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.16_Task_Report.md index 919aad77..12239acb 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.16_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.16_Task_Report.md @@ -1,23 +1,38 @@ # R2.2.16 任务报告 -## 结论 +## 结果 -用户 `mail0795336304@163.com` 的“卡住约 15 分钟”不发生在 Sub2API 的在途请求、账号排队或 proxy 3 出网阶段。服务端证据显示,上一笔请求于 2026-07-14 14:43:33(UTC+8)成功结束,下一笔直到 14:59:54 才进入 Sub2API,中间约 16 分 21 秒没有该用户的新入站请求。卡点因此位于 Sub2API 入站之前,优先怀疑客户端、本地 Agent 状态机或客户端到入口之间的请求提交链路。 +- PK01 owning YAML 与实际运行镜像均为 `0.1.155`;运行状态 healthy。 +- 上下文窗口超限可仅通过 Sub2API v0.1.155 官方配置实现: + - 官方入口为 `/api/v1/admin/error-passthrough-rules`; + - 新增 YAML 模板 `openai-context-window-client-error`; + - 同时匹配 OpenAI 平台、错误码 `400/502` 与上下文超限关键词; + - 将最终状态码映射为 `400`,保留上游具体响应体并继续记录监控事件。 +- 规则已通过受控 CLI 写入 PK01,并由官方 API 回读为 `ruleId=1`、`state=synced`。 +- “首 Token 超过 180 秒后切号”无法通过 v0.1.155 官方配置等价实现: + - `openai_response_header_timeout` 只约束 HTTP 响应头,不是 TTFT; + - `stream_data_interval_timeout` 可约束流读取静默时间,默认已为 180 秒,但超时返回普通流错误,不会形成账号 failover; + - 因此未设置不等价超时项,也未修改 Sub2API 源码。 +- Grok、账号、分组、优先级和调度状态均未改动。 -## 原生运维证据 +## 持久化 -- 用户 ID:22;账号状态 active;用户并发上限 10,调查时 current concurrency 为 0。 -- API Key:名称 `pika_win`、ID 46、分组 `unidesk-codex-pool`;调查时 current concurrency 为 0。 -- 最近 30 分钟共 7 笔请求,均为成功,无用户可见错误。 -- 14:43:33 完成请求 `client:1daa3f3e-99a5-4fc4-809c-6f6ef854a2b2`,流式、`gpt-5.6-sol`,耗时 28.922 秒,账号 33。 -- 14:59:54 下一笔请求 `client:8d1f8ef9-e703-482c-ace9-0d8f13a5c8e3` 才进入服务端;内部 request ID `efdb251f-8e86-4f18-99ee-fa1094419dc0`,15:00:43 完成,HTTP 200,耗时 49.115 秒。 -- 最新请求使用上游账号 `https://api.iceiu.com plus 0.03`(ID 33)、模型 `gpt-5.6-sol`、proxy 3;没有 failover、select failure 或 forward failure。 -- proxy 3 为 `platform-infra-sub2api-pk01-local-egress-proxy`,127.0.0.1:10809;对应 Hysteria 客户端自 7 月 8 日持续 active,调查窗口无错误日志,当前存在正常连接。 +- PR:https://github.com/pikasTech/unidesk/pull/2072 +- Merge commit:`9a0bf6c7f76b2b736035989a32b7743991da7d73` +- owning YAML:`config/platform-infra/sub2api-codex-pool.yaml#runtime.errorPassthroughRules` +- 受控 CLI:`platform-infra sub2api codex-pool error-passthrough list|apply|delete` +- 技能参考:`.agents/skills/unidesk-sub2api/references/codex-pool.md` +- 官方源码版本:https://github.com/Wei-Shaw/sub2api/tree/v0.1.155 -## 判定边界 +## 验证 -Sub2API v0.1.153 当前原生 request 明细记录请求完成耗时,但未记录流式首字时间和逐事件间隔。因此不能从现有数据精确证明客户端何时展示首字;不过“16 分 21 秒无入站请求”、用户/API Key 并发均为 0,以及随后请求 49 秒正常完成,足以排除 Sub2API 内部排队、上游账号占槽 15 分钟和 proxy 3 在该窗口报错。 - -## 建议 - -让用户侧保留该时段客户端日志,重点检查本地 Agent 是否在上一轮完成后未推进状态、请求是否直到 14:59:54 才真正发出,以及本地 HTTP/SSE 客户端是否错误等待已经关闭的流。后续 CLI 可泛化补充按用户邮箱映射用户 ID、请求间空窗和 TTFT/末事件可见性;本次只读调查未取消请求,也未修改 runtime、YAML、proxy 或调度。 +- TypeScript 相关文件 `bun --check` 全部通过。 +- `git diff --check origin/master...HEAD` 通过。 +- dry-run:目标规则为 `missing`、`mutation=false`。 +- confirm apply:`mutation=true`、`state=synced`。 +- 再次 list:`mutation=false`、`ruleId=1`、`state=synced`。 +- `platform-infra sub2api status --target PK01`: + - `sub2api-app` healthy; + - 镜像 `docker.1panel.live/weishaw/sub2api:0.1.155`。 +- `config/platform-infra/sub2api.yaml`: + - PK01 `image.tag: 0.1.155`。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.37_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.37_Task_Report.md deleted file mode 100644 index 781cfcb7..00000000 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.37_Task_Report.md +++ /dev/null @@ -1,94 +0,0 @@ -# R2.2.37 Sub2API 原生 Grok 接入 Codex 调查报告 - -## 调查边界 - -本任务只读检查 `Wei-Shaw/sub2api` 官方仓库最新发布标签、主线源码、发布说明、issue 和合并 PR;未修改 PK01 runtime、YAML、源码或部署。 - -## 版本基线 - -- 官方仓库最新发布标签为 `v0.1.155`,提交 `41cec0db059ffb82d0efdcfcf07a24ab51fbfe97`,发布时间为 2026-07-14。 -- 官方最新发布说明:。 -- `v0.1.155` 已包含原生 Grok provider、Grok OAuth、xAI API Key、Codex Responses 兼容、Grok 渠道健康监控和近期 Grok 兼容修复。 - -## 核心结论 - -最新 Sub2API 已原生支持 Grok 上游接入 Codex,普通接入不需要修改 Sub2API 源码。正确链路是: - -```text -Codex CLI - -> Sub2API /v1/responses - -> Sub2API API Key 绑定的 platform=grok 分组 - -> platform=grok 的 OAuth 或 API Key 账号 - -> OAuth: cli-chat-proxy.grok.com/v1/responses - API Key: api.x.ai/v1/responses -``` - -不要把 Grok 配置成 `platform=openai` 的自定义 base URL。原生 Grok 路径负责 OAuth 刷新、CLI 身份头、请求字段净化、模型映射、配额观察、429 冷却和 Grok 专属健康监控;伪装成 OpenAI 会绕过这些能力。 - -## 官方源码证据 - -### 路由与分组选择 - -- `backend/internal/server/routes/gateway.go` 把 `PlatformOpenAI` 和 `PlatformGrok` 都识别为 OpenAI Responses 兼容平台。 -- `/v1/responses`、`/responses` 和 `/backend-api/codex/responses` 均可由 Grok 分组进入 OpenAI gateway。 -- API Key 所绑定分组的平台决定请求走哪个 provider,因此客户端使用的是 Sub2API API Key,不是直接使用 xAI Key。 - -### Grok 专用转发 - -- `backend/internal/service/openai_gateway_forward.go` 在账号平台为 `grok` 时直接调用 `forwardGrokResponses`。 -- `backend/internal/service/openai_gateway_grok.go` 同时接受 `oauth` 和 `api_key` 两种 Grok 账号。 -- 转发前会映射模型、净化 xAI 不兼容字段和工具,OAuth 请求还会带 Grok CLI 身份与缓存路由信息。 -- Codex WebSocket Responses 入站被接受,但 Grok 上游实际桥接为 HTTP/SSE,并非 xAI WebSocket 透传。 - -### 模型映射 - -- `backend/internal/pkg/xai/models.go` 默认包含 `grok-4.5`、`grok-4.3`、`grok-build-0.1`、`grok-composer-2.5-fast` 和 Grok 4.20 系列。 -- 默认别名包括 `grok -> grok-4.5`、`grok-latest -> grok-4.5`、`grok-build -> grok-build-0.1`、`grok-composer -> grok-composer-2.5-fast`。 -- 最清晰的 Codex 配置是直接请求 `grok-4.5`。只有消费端必须沿用其他模型名时,才在 Grok 账号的 `model_mapping` 中显式映射;不建议用 `gpt-*` 名称长期伪装 Grok,以免模型归因、价格和排障语义混乱。 - -## Sub2API 配置步骤 - -1. 在管理后台创建原生 Grok 账号: - - 订阅账号:选择 `Grok -> OAuth` 并完成 xAI 授权;推理默认走 `https://cli-chat-proxy.grok.com/v1`。 - - 官方计费 API:选择 `Grok -> API Key`,base URL 使用 `https://api.x.ai/v1`。 -2. 创建平台为 `grok` 的分组,并把 Grok 账号加入该分组。 -3. 创建一个 Sub2API API Key,并把该 Key 指派给 Grok 分组。 -4. 先验证该 Key 的 `/v1/models` 能看到 `grok-4.5`,再做 `/v1/responses` 的同步、流式和工具调用 smoke。 -5. 对 OAuth 账号保持保守并发起步;是否提高并发应由实际首 Token、429 和渠道健康数据决定。 - -## Codex 客户端配置模板 - -`~/.codex/config.toml` 可使用独立 provider,避免覆盖现有默认 provider: - -```toml -model_provider = "sub2api_grok" -model = "grok-4.5" - -[model_providers.sub2api_grok] -name = "Grok via Sub2API" -base_url = "https:///v1" -wire_api = "responses" -requires_openai_auth = true -supports_websockets = false -``` - -认证使用绑定 Grok 分组的 Sub2API API Key,通过 Codex 既有 `auth.json` 或受控凭据入口注入;不要把 xAI Key 直接交给 Codex,也不要把密钥写入仓库。初次接入建议保持 HTTP Responses;Sub2API 虽支持 Codex WebSocket 入站,但 Grok 上游仍为 HTTP/SSE,只有完成真实 WS smoke 后再启用 Codex WebSocket 标记。 - -## 官方 issue 与修复演进 - -- 原生 Grok 订阅支持由 PR #3310 引入,明确覆盖 `/v1/responses`、`/responses` 和 `/backend-api/codex/responses`:。 -- PR #3558 补齐 Codex/OpenAI 兼容路由、WebSocket 入站桥接和 Codex 请求净化:。 -- Issue #3952 记录旧实现缺失 Grok CLI 版本身份头会被 OAuth 上游以 426 拒绝:。 -- PR #4009 修复 CLI 身份头、OAuth 路由、`additional_tools`、Composer 推理参数,并加入 xAI API Key 账号:。 -- `v0.1.152` 已发布上述关键修复,`v0.1.155` 又修复 Grok reasoning `content:null` 422、缓存路由和监控能力,因此新接入应直接以 `v0.1.155` 为基线。 - -## 风险与验收重点 - -- OAuth 与 API Key 是不同上游路径,不能混用 base URL:OAuth 走订阅 CLI proxy,API Key 走 `api.x.ai`。 -- Codex 的私有/新增 Responses 字段可能领先 xAI schema;`v0.1.155` 已清理已知的 `additional_tools`、不兼容 reasoning 字段和 `content:null`,仍应以真实工具调用 smoke 为准。 -- Grok quota 是被动观察上游 rate-limit header;首次成功请求前显示 unknown 属正常现象。 -- `401` 表示凭据问题,`403` 表示权限或订阅 entitlement,`429` 会进入临时不可调度;这些应按真实错误分类,不能统一当网络失败。 - -## 建议 - -直接采用 `v0.1.155` 的原生 Grok provider,优先建立独立 Grok 分组和独立 Sub2API Key,以 `grok-4.5 + Responses + HTTP` 作为首个受控配置。无需 Sub2API 源码变更;下一步若要落地,应先把账号、分组、Key 和模型映射声明进 owning YAML,再走受控 plan/apply 和最小 smoke。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.2_Task_Report.md new file mode 100644 index 00000000..9297c833 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.2_Task_Report.md @@ -0,0 +1,3 @@ +# R2.2 通用故障归因与调优任务报告 + +本分支集中保留客户可见错误归因、所有分组诊断、failover/重试、TTFT、runtime CRUD、临时不可调度模板及账号粒度评分的基础能力。原 R2.2.1-R2.2.15 均已完成;客户专项事件、账号优先级、利润和 Grok 接入已迁到独立职责分支。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.1_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.1_Task_Report.md new file mode 100644 index 00000000..fb16bac6 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.1_Task_Report.md @@ -0,0 +1,23 @@ +# R2.4.1 任务报告 + +## 结论 + +用户 `mail0795336304@163.com` 的“卡住约 15 分钟”不发生在 Sub2API 的在途请求、账号排队或 proxy 3 出网阶段。服务端证据显示,上一笔请求于 2026-07-14 14:43:33(UTC+8)成功结束,下一笔直到 14:59:54 才进入 Sub2API,中间约 16 分 21 秒没有该用户的新入站请求。卡点因此位于 Sub2API 入站之前,优先怀疑客户端、本地 Agent 状态机或客户端到入口之间的请求提交链路。 + +## 原生运维证据 + +- 用户 ID:22;账号状态 active;用户并发上限 10,调查时 current concurrency 为 0。 +- API Key:名称 `pika_win`、ID 46、分组 `unidesk-codex-pool`;调查时 current concurrency 为 0。 +- 最近 30 分钟共 7 笔请求,均为成功,无用户可见错误。 +- 14:43:33 完成请求 `client:1daa3f3e-99a5-4fc4-809c-6f6ef854a2b2`,流式、`gpt-5.6-sol`,耗时 28.922 秒,账号 33。 +- 14:59:54 下一笔请求 `client:8d1f8ef9-e703-482c-ace9-0d8f13a5c8e3` 才进入服务端;内部 request ID `efdb251f-8e86-4f18-99ee-fa1094419dc0`,15:00:43 完成,HTTP 200,耗时 49.115 秒。 +- 最新请求使用上游账号 `https://api.iceiu.com plus 0.03`(ID 33)、模型 `gpt-5.6-sol`、proxy 3;没有 failover、select failure 或 forward failure。 +- proxy 3 为 `platform-infra-sub2api-pk01-local-egress-proxy`,127.0.0.1:10809;对应 Hysteria 客户端自 7 月 8 日持续 active,调查窗口无错误日志,当前存在正常连接。 + +## 判定边界 + +Sub2API v0.1.153 当前原生 request 明细记录请求完成耗时,但未记录流式首字时间和逐事件间隔。因此不能从现有数据精确证明客户端何时展示首字;不过“16 分 21 秒无入站请求”、用户/API Key 并发均为 0,以及随后请求 49 秒正常完成,足以排除 Sub2API 内部排队、上游账号占槽 15 分钟和 proxy 3 在该窗口报错。 + +## 建议 + +让用户侧保留该时段客户端日志,重点检查本地 Agent 是否在上一轮完成后未推进状态、请求是否直到 14:59:54 才真正发出,以及本地 HTTP/SSE 客户端是否错误等待已经关闭的流。后续 CLI 可泛化补充按用户邮箱映射用户 ID、请求间空窗和 TTFT/末事件可见性;本次只读调查未取消请求,也未修改 runtime、YAML、proxy 或调度。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.17_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.2_Task_Report.md similarity index 98% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.17_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.4.2_Task_Report.md index 7e0567d0..c9004700 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.17_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.2_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.17 任务报告 +# R2.4.2 任务报告 ## 结果 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.18_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.3_Task_Report.md similarity index 98% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.18_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.4.3_Task_Report.md index f1bff722..957137a7 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.18_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.3_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.18 任务报告 +# R2.4.3 任务报告 ## 结果 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.19_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.4_Task_Report.md similarity index 89% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.19_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.4.4_Task_Report.md index 79b250dc..3438020a 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.19_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.4_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.19 任务报告 +# R2.4.4 任务报告 ## 结果 @@ -17,4 +17,4 @@ ## 后续 -内部监控超时、请求切号关联和 P0/P1/P2 快速查询分别由 R2.2.23 与 R2.2.21 继续增强;这些后续能力不改变本任务的原生只读边界。 +内部监控超时、请求切号关联和 P0/P1/P2 快速查询分别由 R2.4.8 与 R2.4.6 继续增强;这些后续能力不改变本任务的原生只读边界。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.20_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.5_Task_Report.md similarity index 99% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.20_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.4.5_Task_Report.md index 266d79c9..007231e7 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.20_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.5_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.20 任务报告 +# R2.4.5 任务报告 ## 目标 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.21_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.6_Task_Report.md similarity index 98% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.21_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.4.6_Task_Report.md index d24be5be..c0221c63 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.21_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.6_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.21 任务报告 +# R2.4.6 任务报告 ## 结果 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.22_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.7_Task_Report.md similarity index 99% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.22_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.4.7_Task_Report.md index ddf9d433..3a8343b9 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.22_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.7_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.22 任务报告 +# R2.4.7 任务报告 ## 结论 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.23_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.8_Task_Report.md similarity index 98% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.23_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.4.8_Task_Report.md index 8fc11e18..770c83c9 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.23_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.8_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.23 任务报告 +# R2.4.8 任务报告 ## 结果 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.24_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.9_Task_Report.md similarity index 99% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.24_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.4.9_Task_Report.md index 073a9057..40853a65 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.24_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4.9_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.24 任务报告 +# R2.4.9 任务报告 ## 结论 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.4_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4_Task_Report.md new file mode 100644 index 00000000..7307db94 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.4_Task_Report.md @@ -0,0 +1,3 @@ +# R2.4 客户事件与原生诊断任务报告 + +本分支汇总用户卡顿反馈、一键用户追踪、原生智能诊断与渠道监控、P0/P1/P2 快查,以及错误 #17440、#17490、#17511 的专项调查。迁入的九项任务及报告均保持完成状态。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.10_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.10_Task_Report.md new file mode 100644 index 00000000..a92164aa --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.10_Task_Report.md @@ -0,0 +1,108 @@ +# R2.5.10 任务报告 + +## 范围与来源 + +- 窗口:最近 8 小时。 +- 目标:PK01。 +- 数据源: + - Sub2API 原生 admin/ops 分组概要、账号可用性与并发; + - 原生 admin usage 账号流水、Token 与 `actual_cost`; + - 原生 request-errors 与 system-log 账号评分证据。 +- 全程只读,未修改 runtime、YAML、优先级、账号或运行面。 +- Grok 按现有利润策略排除,不进入 OpenAI 账号成本与利润聚合。 + +## 分组概要 + +- `unidesk-codex-pool`: + - Ops 请求 2,155; + - 客户错误 29,错误率 1.35%; + - 上游错误率 7.33%; + - TTFT P99 893,414 ms; + - usage 流水约 1,940 请求、13.264M Token、189.5743 标准 API USD。 +- `自用`: + - Ops 请求 3,291; + - 客户错误 0; + - 上游错误率 0.21%; + - TTFT P99 24,285 ms; + - usage 流水约 2,942 请求、21.951M Token、344.7493 标准 API USD。 +- 当前账号可用 8/10、并发 5/225、排队 0。 +- 账号 29、30 因余额不足 403 不可用。 + +## 盈利池账号评分与流水 + +- `https://sub2.pokexiao.com pro 0.05`: + - 100/A,高置信度; + - 192 次尝试,失败率 0,TTFT P95 6,797 ms; + - 192 条流水、2.567M Token、26.8695 API USD。 +- `https://api.iceiu.com plus 0.03`: + - 100/A,高置信度; + - 223 次尝试,失败率 0,TTFT P95 4,619 ms; + - 223 条流水、0.435M Token、21.1954 API USD。 +- `https://sub.yjxm1221.top plus 0.01`: + - 99.5/A,高置信度; + - 630 次尝试,失败率 0.16%,TTFT P95 9,806 ms; + - 约 627 条流水、3.988M Token、50.0402 API USD。 +- `https://ai.whistlelads.com plus 0.03`: + - 99/A,高置信度; + - 296 次尝试,失败率 0.34%,TTFT P95 9,070 ms; + - 约 295 条流水、3.009M Token、39.7672 API USD。 +- `https://sub2.pokexiao.com plus 0.03`: + - 88/B,高置信度; + - 627 次尝试,失败率 3.99%,TTFT P95 7,554 ms; + - 25 个客户错误,占盈利池 29 个客户错误的 86.2%; + - 约 602 条流水、3.229M Token、51.4244 API USD。 +- `https://api.recurdream.com plus 0.02`: + - 20/insufficient,低置信度; + - 仅 1 次尝试且发生 failover,最终恢复; + - 盈利池无足够 usage 流水,不能据此判定退役。 +- `lyon9801 0.10`、`https://sub.yjxm1221.top pro 0.06`: + - 该窗口无尝试,评分证据不足。 +- `https://api.lwylink.xyz pro 0.1`、`https://api.lwylink.xyz plus 0.05`: + - 无尝试且当前余额不足,不可用。 + +## 自用池账号评分与流水 + +- `https://sub2.pokexiao.com pro 0.05`: + - 100/A,高置信度; + - 549 次尝试,失败率 0,TTFT P95 9,381 ms; + - 549 条流水、3.757M Token、64.4262 API USD。 +- `https://sub2.pokexiao.com plus 0.03`: + - 99.4/A,高置信度; + - 990 次尝试,失败率 0.20%,一次 failover 成功; + - TTFT P95 7,149 ms; + - 约 988 条流水、6.778M Token、102.8114 API USD。 +- `https://ai.whistlelads.com plus 0.03`: + - 99.1/A,高置信度; + - 770 次尝试,失败率 0.26%,一次 failover 成功; + - TTFT P95 10,806 ms; + - 约 768 条流水、6.238M Token、93.7645 API USD。 +- `https://api.iceiu.com plus 0.03`: + - 99/A,高置信度; + - 301 次尝试,失败率 0.33%,TTFT P95 7,456 ms; + - 301 条流水、0.663M Token、43.9813 API USD。 +- `https://sub.yjxm1221.top plus 0.01`: + - 96.8/A,高置信度; + - 335 次尝试,失败率 0.90%,两次 failover 均恢复; + - TTFT P95 13,377 ms; + - 约 328 条流水、4.471M Token、39.3431 API USD。 +- `https://api.recurdream.com plus 0.02`: + - 40/insufficient,低置信度; + - 9 次尝试、3 次 failover 均恢复,未产生客户错误; + - 约 6 条流水、0.262M Token、1.4470 API USD。 + +## 经济汇总 + +- 盈利池收入:约 ¥18.9574。 +- 盈利池采购成本:约 ¥5.2183。 +- 自用池采购成本:约 ¥10.8493。 +- 不扣自用成本利润:约 ¥13.7392。 +- 扣除自用成本利润:约 ¥2.8899。 +- 单价口径为人民币倍率,即每 1 标准 API USD 对应的人民币售价或采购成本,不是汇率换算。 + +## 结论 + +- 盈利池主要质量问题集中在 `https://sub2.pokexiao.com plus 0.03`,但其自用池表现为 99.4/A,暂不能解释为账号整体不稳定;应继续按盈利池模型、请求类型和极端 TTFT 请求下钻。 +- 盈利池 TTFT P99 约 893 秒,而活跃账号 P95 仅约 4.6–9.8 秒,表明是少量极端长尾,不支持整体降低账号评分或全面降优先级。 +- `https://api.recurdream.com plus 0.02` 样本过少,评分低但 failover 均恢复,不足以退役。 +- 账号 29、30 是明确余额故障,应保持不可用,不纳入质量比较。 +- Ops 请求、评分尝试和 usage 流水来自不同原生接口,时间边界与计费过滤不同,数量不能强行一一相等。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.11_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.11_Task_Report.md new file mode 100644 index 00000000..2769ca10 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.11_Task_Report.md @@ -0,0 +1,37 @@ +# R2.5.11 任务报告 + +## 结论 + +- PK01 `unidesk-codex-pool` 最近 8 小时复核时约有 2295 个请求、32 个客户可见错误,客户错误率 1.39%。 +- 32 个客户错误不是同一种账号故障: + - 17 个为上下文窗口超限,属于客户输入约束,不扣账号分; + - 2 个为 `glm-5.2` 未被分组账号支持,属于模型路由配置,不扣账号分; + - 11 个为 `Upstream service temporarily unavailable`; + - 2 个为 `Upstream request failed`。 +- 状态码分布为 502 共 29 个、404 共 2 个、524 共 1 个;同步 30 个、流式 2 个。 +- 账号 `https://sub2.pokexiao.com plus 0.03` 原始客户错误 25 个,其中 17 个上下文超限被排除,只有 8 个明确上游错误参与评分;修正后评分为 96.2/A,而不是此前把全部客户错误计入后的 88/B。 +- 两个代表性同步 502 均已触发 failover,但请求上下文随后立即 `context canceled`,切号没有剩余执行窗口;这不是当前池并发不足,因为观察时容量 225、在用约 5、排队 0。 + +## Priority 调整 + +- 账号 27 `https://sub.yjxm1221.top plus 0.01` 保持 priority 1,兼顾最低成本和高质量。 +- 账号 28 `https://ai.whistlelads.com plus 0.03`、31 `https://sub2.pokexiao.com plus 0.03`、33 `https://api.iceiu.com plus 0.03` 调整为 priority 5;账号 32 `https://sub2.pokexiao.com pro 0.05` 保持 priority 5。 +- 账号 35 `https://api.recurdream.com plus 0.02` 调整为 priority 20:成本低,但样本少且存在已恢复的上游失败,需要继续观察。 +- `lyon9801 0.10` 保持 priority 20,作为零毛利应急账号;账号 19 `https://sub.yjxm1221.top pro 0.06` 保持 priority 50。 +- 全部写入均通过受控 runtime priority dry-run、confirm 和逐账号自动回读完成;未修改分组、容量、代理、凭据、状态、可调度或临时不可调度规则。 + +## 工具与 SKILL + +- PR #2077 增加 runtime priority 单账号与精准批量 dry-run/confirm、仅字段更新和逐账号自动对账。 +- PR #2082 将评分、请求量、Token、标准 API 美元计费基数、人民币采购倍率和人民币上游成本聚合到一次 `runtime errors --group ... --since ...` 查询。 +- 可靠性扣分改为保守的明确归因:只计入原生 upstream 分类、稳定上游网关消息、failover 和 `forward_failed`;上下文、输入校验、模型路由、内部/业务、无账号和未知归因错误全部保留可见但不扣分。 +- CLI 输出新增 `scoreableUpstreamErrorRequests`、`excludedNonUpstreamErrorRequests`、排除原因桶和账号成本流水表。 +- `runtime.ts` 从超过 3000 行按输出投影、远端脚本头部和远端脚本尾部拆分,相关文件均低于 2000 行。 +- `unidesk-sub2api` SKILL 已固化一次查询完成评分与流水分析、完整账号名称输出和非上游错误不扣分的规则。 + +## 验证 + +- 相关 TypeScript 文件 `bun --check` 全部通过,`git diff --check` 通过。 +- PK01 真实只读回归中,账号 31 一次显示 624 请求、3.607M Token、56.3256 API USD、0.030 CNY/API USD 和 1.6898 CNY 上游成本。 +- `runtime list --target PK01` 回归通过,账号名称、priority 和 runtime 状态正常显示。 +- PR #2082 已合并,merge commit 为 `3464e93acd8a2d688933f03f0f78303cdf9c6c64`;该交付为 CLI/SKILL 轻量变更,`rollout=not-applicable`。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.12_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.12_Task_Report.md new file mode 100644 index 00000000..285439c1 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.12_Task_Report.md @@ -0,0 +1,5 @@ +# R2.5.12 任务报告 + +- 原生 trace 证实并发限额错误发生于 SSE 已提交后的 HTTP 200 阶段,`upstream_error_response_already_written=true`;官方临时不可调度规则依赖 HTTP 状态码,不能挽救当前流。 +- 现有通用模板已覆盖 429/502/503/504/524 的 `concurrency limit exceeded`,继续追加规则无效,因此未修改模板。 +- 已在 `unidesk-sub2api` 禁止事项中固化:未获明确授权时,不修改 Sub2API 源码、版本,也不启用、扩展或调整外部哨兵。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.25_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.1_Task_Report.md similarity index 98% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.25_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.5.1_Task_Report.md index a3cbd995..a7da59f1 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.25_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.1_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.25 任务报告 +# R2.5.1 任务报告 ## 结果 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.26_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.2_Task_Report.md similarity index 99% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.26_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.5.2_Task_Report.md index 699b5da9..be7ae3f9 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.26_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.2_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.26 任务报告 +# R2.5.2 任务报告 ## 范围 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.27_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.3_Task_Report.md similarity index 98% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.27_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.5.3_Task_Report.md index a51d0d32..790c9722 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.27_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.3_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.27 任务报告 +# R2.5.3 任务报告 ## 目标 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.28_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.4_Task_Report.md similarity index 99% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.28_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.5.4_Task_Report.md index abb575fa..bcfbdab8 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.28_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.4_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.28 任务报告 +# R2.5.4 任务报告 ## 范围 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.29_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.5_Task_Report.md similarity index 84% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.29_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.5.5_Task_Report.md index 3e8c297c..92ed07cc 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.29_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.5_Task_Report.md @@ -1,8 +1,8 @@ -# R2.2.29 任务报告 +# R2.5.5 任务报告 ## 目标 -按 R2.2.28 的最近 4 小时评分与用量结论,精准调整 PK01 Sub2API runtime-manual 账号的全局 priority。只修改 priority,不修改分组、容量、代理、凭据、状态、可调度或临时不可调度规则。 +按 R2.5.4 的最近 4 小时评分与用量结论,精准调整 PK01 Sub2API runtime-manual 账号的全局 priority。只修改 priority,不修改分组、容量、代理、凭据、状态、可调度或临时不可调度规则。 ## 执行与保护 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.30_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.6_Task_Report.md similarity index 95% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.30_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.5.6_Task_Report.md index e317b72b..e30fbdef 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.30_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.6_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.30 任务报告 +# R2.5.6 任务报告 ## 目标 @@ -7,7 +7,7 @@ ## 已完成登记 - 子 Issue:https://github.com/pikasTech/unidesk/issues/2037 -- MDTODO:R2.2.30,进行中。 +- MDTODO:R2.5.6,进行中。 - 独立 worktree:`/root/unidesk/.worktree/sub2api-runtime-priority`。 - 分支:`feat/sub2api-runtime-priority`,基于最新 `origin/master` 创建并推送,worktree clean。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.31_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.7_Task_Report.md similarity index 99% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.31_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.5.7_Task_Report.md index 3747a192..daad4b38 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.31_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.7_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.31 任务报告 +# R2.5.7 任务报告 ## 窗口与范围 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.32_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.8_Task_Report.md similarity index 98% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.32_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.5.8_Task_Report.md index 89f20f86..1a427e5a 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.32_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.8_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.32 任务报告 +# R2.5.8 任务报告 ## 经济口径 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.9_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.9_Task_Report.md new file mode 100644 index 00000000..aa2b06ae --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.5.9_Task_Report.md @@ -0,0 +1,68 @@ +# R2.5.9 任务报告 + +## 范围 + +- 目标:PK01 Sub2API v0.1.155。 +- 原生观测窗口:约 2026-07-15 00:20 至 02:20(UTC+8)。 +- 范围:全部 3 个分组;只读使用原生 admin Ops overview、request-errors、upstream-errors、account-availability、usage、system-log 索引及稳定 request trace。 +- 查询有秒级时间漂移:全分组概要为 2214 个请求;随后分组下钻分别为 622、1593、7 个请求。 + +## 全局用户可见错误 + +- 全分组概要:2214 个请求、25 个客户错误,客户错误率 1.13%;27 个上游错误,上游错误率 1.22%。 +- `unidesk-codex-pool`:621 个请求、19 个客户错误,错误率 3.06%;20 个上游错误,错误率 3.22%。 + - 17 个同步 502 来自账号 31 `https://sub2.pokexiao.com plus 0.03`,模型为 `claude-opus-4-8`,原始语义是输入超过模型上下文窗口。 + - 代表请求 `97a552d6-2b4d-45ff-a449-240201e5f127` 最终 502,未触发 failover;这不是随机账号不稳定,切号通常不能解决超上下文输入。 + - 2 个 `glm-5.2` 请求因组内没有支持账号快速返回 404,其中同步、流式各 1 个。 +- `自用`:1586 个请求、0 个客户错误;4 个上游错误全部经 4 次 failover 恢复,客户错误率 0%。 + - 另有 2 个 HTTP 200 后 `forward_failed` 的流式 degraded 请求,稳定请求 ID 为 `abc711f6-aff0-486f-a94b-09426ee4cac1`、`57f1308e-cbe9-4804-bcd9-a2fb5149e944`;它们不计入原生客户错误率,但客户可能感知到流式中断。 +- `grok`:7 个请求、6 个客户错误;SLA 分母排除 3 个 business-limited 后错误率为 75%。 + - 唯一账号 36 `https://sub.yjxm1221.top grok` 出现 3 次上游 502 overload。 + - 3 次均触发 failover,但排除失败账号后没有其他候选;随后另有 3 个请求在路由阶段直接 503。 + - 代表请求 `dfd99f91-52e4-4de9-bea9-d64a3e8e623b` 在 1346 ms 收到上游 502,同毫秒进入切号,2 ms 后因 `no available accounts` 返回 502。因此不是切号超时,而是单账号池没有备用候选。 + +## 首字延迟 + +- `unidesk-codex-pool`:TTFT P50 3729 ms、P95 8983 ms、P99 10688 ms、最大 101398 ms。 +- `自用`:TTFT P50 4076 ms、P95 12768 ms、P99 23386 ms、最大 540382 ms,长尾明显高于商业池。 +- `grok`:成功流式首 token 样本不足,TTFT 不可用;唯一成功请求总耗时 2264 ms,不能代替 TTFT。 +- 当前 Sub2API 原生 Ops 响应未提供 `/v1/responses` 与 `/v1/responses/compact` 的独立 request-level TTFT,因此本窗口不能可靠拆分普通与 compact;总耗时没有被冒充为 TTFT。 +- 账号长尾重点: + - `https://sub.yjxm1221.top plus 0.025`:自用池 TTFT P95 19286 ms、P99 49075 ms、最大 540382 ms。 + - `https://ai.whistlelads.com plus 0.03`:自用池 P95 14827 ms、P99 25735 ms、最大 27701 ms。 + - `https://api.iceiu.com plus 0.03`:自用池 P95 9088 ms、P99 18947 ms、最大 31725 ms。 + - `https://sub2.pokexiao.com plus 0.03`:商业池最大 101398 ms;自用池 P99 14588 ms、最大 59814 ms。 + +## 账号评分 + +评分是分组内、窗口内分析结果;可靠性 60、TTFT 25、当前可用性 15。上下文错误会降低可靠性分,因此不能直接解释为供应商网络质量。 + +### unidesk-codex-pool + +- `https://ai.whistlelads.com plus 0.03`:100/A,高置信,130 次尝试、0 失败,TTFT P95 9906 ms。 +- `https://sub2.pokexiao.com pro 0.05`:100/A,中置信,14 次尝试、0 失败,TTFT P95 4839 ms。 +- `https://api.iceiu.com plus 0.03`:100/A,高置信,55 次尝试、0 失败,TTFT P95 5416 ms。 +- `https://sub2.pokexiao.com plus 0.03`:86.3/B,高置信,373 次尝试、17 次失败,TTFT P95 6557 ms;扣分全部来自超上下文请求,不应据此退役账号。 + +### 自用 + +- `https://sub2.pokexiao.com pro 0.05`:100/A,高置信,113 次尝试、0 失败,TTFT P95 7035 ms。 +- `https://api.iceiu.com plus 0.03`:100/A,高置信,161 次尝试、0 失败,TTFT P95 9088 ms。 +- `https://sub2.pokexiao.com plus 0.03`:99.6/A,高置信,764 次尝试、1 次失败,1 次 failover 成功,TTFT P95 7152 ms。 +- `https://ai.whistlelads.com plus 0.03`:96.3/A,高置信,200 次尝试、2 次失败,1 次 failover 成功,TTFT P95 14827 ms。 +- `https://sub.yjxm1221.top plus 0.025`:94/A,高置信,193 次尝试、3 次失败,2 次 failover 均成功,TTFT P95 19286 ms;主要风险是极端长尾。 +- `https://api.recurdream.com plus 0.02`:100/insufficient,低置信,仅 3 次尝试,不能与高样本账号等价排序。 + +### grok 与无样本账号 + +- `https://sub.yjxm1221.top grok`:20/insufficient,低置信,4 次尝试、3 次失败,3 次 failover 均无候选;虽然样本少,但失败模式明确。 +- `lyon9801 0.10`、`https://sub.yjxm1221.top pro 0.06`、`https://api.recurdream.com plus 0.02` 在商业池等账号本窗口无流量,保持 insufficient,不伪造评分。 +- `https://api.lwylink.xyz pro 0.1` 与 `https://api.lwylink.xyz plus 0.05` 当前均因余额不足 403 不可用;两个 OpenAI 分组均为 8/10 可用。 + +## 结论与建议 + +1. 当前不是 Sub2API 总并发问题:全局查询时使用量分别为 11/225、10/225、0/10,排队均为 0;不建议以降低并发作为主修复。 +2. 商业池最大错误源是超上下文请求被包装成 502。应优先调查 Sub2API 的 Anthropic 兼容错误映射,把确定的输入/上下文错误转换为明确 4xx;不要给账号加临时不可调度,也不要靠切号掩盖客户输入问题。 +3. Grok 是当前真实可靠性短板。单账号结构使 failover 必然失败;源头方案是增加独立可用候选或在没有候选时快速、明确降级。仅增加冷却规则会减少重复撞击,但不能恢复请求。 +4. 自用池的上游 5xx 已被切号吸收,但 2 个流式 degraded 仍需继续观察;账号 27 的 540 秒 TTFT 极端值应在后续窗口确认是否复现,再决定是否调整 priority,不能凭单个最大值自动降级。 +5. 评分上,账号 32、33 最稳定;账号 31 在自用池表现优良,商业池 B 级主要受客户上下文错误污染;账号 27 可靠性尚可但时延长尾最明显;Grok 账号需要优先补冗余。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.10_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.10_Task_Report.md new file mode 100644 index 00000000..9ebb289f --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.10_Task_Report.md @@ -0,0 +1,41 @@ +# R2.6.10 任务报告 + +## 结论 + +若 2026-08 不再采购固定账号 `lyon9801 0.0`,售价保持 `0.15 CNY per API USD`,并且营业流量维持当前日均,预计可以盈利。按其他活跃账号实际加权平均边际成本接管 lyon 流量,预计盈利池利润约 `1442.93 CNY/月`;扣除自用成本后的整体利润约 `1051.71 CNY/月`。 + +## 数据口径 + +- 快照截至 2026-07-15 02:06 UTC,来源为 PK01 Sub2API 原生 admin usage,共 20 次分页读取。 +- 当前日均线性外推至 31 天;预计盈利池标准 API 计费基数约 `11650.914122 API USD`,售价 `0.15 CNY per API USD`,营业额 `1747.637118 CNY`。 +- 下月不计 `lyon9801 0.0` 的 `1300 CNY` 固定成本,也不假设 lyon 继续以零边际成本承接流量。 +- 当前 lyon 在盈利池占 `24.5497%`,在自用池占 `19.0978%`。 + +## 替代成本 + +- 非 lyon 盈利池账号当前加权平均采购倍率:`0.026153228 CNY per API USD`。 +- 非 lyon 自用池账号当前加权平均采购倍率:`0.031014832 CNY per API USD`。 +- lyon 盈利池流量按该平均倍率替代,预计新增采购成本 `74.805089 CNY/月`。 +- lyon 自用流量按该平均倍率替代,预计新增采购成本 `74.713171 CNY/月`。 +- 替代后盈利池总采购成本约 `304.709018 CNY/月`;自用总采购成本约 `391.213866 CNY/月`。 + +## 利润情景 + +- 只删除固定成本、仍假设 lyon 流量零边际成本的理论上限:不扣自用 `1517.733190 CNY`,扣自用 `1201.232495 CNY`。 +- 按其他账号当前加权平均成本接管的现实预测:不扣自用 `1442.928101 CNY`,扣自用 `1051.714235 CNY`。 +- 若 lyon 流量都能由 `0.01` 倍率账号承接:不扣自用 `1489.130568 CNY`,扣自用 `1148.540377 CNY`。 +- 若 lyon 流量只能由 `0.06` 倍率账号承接:不扣自用 `1346.117462 CNY`,扣自用 `885.079784 CNY`。 + +## 日均现实预测 + +- 营业额:约 `56.38 CNY/天`。 +- 盈利池采购成本:约 `9.83 CNY/天`。 +- 自用采购成本:约 `12.62 CNY/天`。 +- 不扣自用利润:约 `46.55 CNY/天`。 +- 扣自用利润:约 `33.93 CNY/天`。 + +## 风险 + +移除 lyon 后,其他盈利池账号需多承接约 `32.54%` 流量,自用池其他账号需多承接约 `23.61%`,合计非 lyon 账号负载约增加 `27.74%`。财务预测成立的前提是剩余账号具有该容量,且错误率、重试和客户流失不恶化;本报告未把可靠性下降造成的收入损失货币化。因此建议将 `1051.71 CNY/月` 视为当前流水不变时的中心估计,而非保证值。 + +本次仅只读评估,未修改账号、YAML、runtime、Sub2API 或外部哨兵。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.11_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.11_Task_Report.md new file mode 100644 index 00000000..f5bdcff4 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.11_Task_Report.md @@ -0,0 +1,23 @@ +# R2.6.11 任务报告 + +## 交付 + +- 已通过 [PR #2087](https://github.com/pikasTech/unidesk/pull/2087) 合并到 `master`,合并提交 `062faeb81f680728d08cd4eabef01f5e3fdf5372`。 +- `profit` 新增 `--sale-rate `,只覆盖本次只读候选售价,不修改 YAML。 +- `profit` 新增可重复的 `--scenario-without-account `,使用精确账号 ID 或完整名称模拟账号退出。 +- 情景在同一次 Sub2API 原生 usage 聚合中自动移除选中账号绑定的期间成本,并把其盈利池和自用池流量分别按各自剩余账号的实际加权采购倍率重分配。 +- 默认 Kubernetes 风格汇总和显式 JSON 直接披露理论利润上限、重分配利润、日均营业额与成本、账号流量占比、替代成本和剩余账号负载增幅,不再需要 `--accounts` 分页及手工算术。 +- selector 不存在时失败;剩余账号有未知成本、没有替代计费基数或仍有未分类流量时输出 `partial`,不把未知成本当作零。 +- Sub2API skill 描述和 `references/codex-pool.md` 已同步通用入口、计算语义与容量风险边界;核心代码和文档入口未固化具体账号、月份或售价。 + +## 验证 + +- PK01 真实只读命令一次返回 `status=complete`、两种利润、日均值、两池账号占比和负载增幅:`profit --month 2026-07 --forecast --sale-rate 0.15 --scenario-without-account 'lyon9801 0.0'`。 +- 显式 `--json` 保留精确十进制、完整账号名称、替代成本和负载增幅。 +- 非法 `--sale-rate -1` 在发起远端请求前明确拒绝。 +- `quick_validate.py .agents/skills/unidesk-sub2api` 通过。 +- `git diff --check` 通过。 + +## 边界 + +本次只修改 UniDesk CLI 与 skill 文档;未修改 Sub2API 源码、版本、YAML、runtime、外部哨兵或线上账号。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.12_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.12_Task_Report.md new file mode 100644 index 00000000..ddda27e2 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.12_Task_Report.md @@ -0,0 +1,43 @@ +# R2.6.12 任务报告 + +## 综合结论 + +- 本月 `lyon9801 0.0` 的 `1300 CNY` 已经发生,属于沉没成本;本月应继续保持高利用率,优先消耗其零边际成本能力,不建议提前停用。 +- 下月默认不续固定号。按当前流量和其他账号实际加权成本接管,售价 `0.15 CNY/API USD` 时预计不扣自用利润 `1447.41 CNY/月`,扣自用利润 `1055.78 CNY/月`。 +- 即使售价仍为 YAML 当前 `0.10 CNY/API USD`,不续固定号后预计不扣自用利润 `863.06 CNY/月`,扣自用利润 `471.44 CNY/月`,仍为正。 + +## 固定号经济性 + +- 当前 lyon 流量份额只避免约 `150.35 CNY/月`替代采购成本,远低于 `1300 CNY`固定费;在当前份额下,相关流量需约增长 `8.65` 倍才覆盖固定费。 +- 即使现有盈利池和自用池全部由 lyon 承接,按当前其他账号成本最多避免约 `697.25 CNY/月`采购成本,仍低于固定费;总流量需约增长 `1.86` 倍且 lyon 能承接全部流量,才接近纯成本保本。 +- 当前流量下合理续费上限:保持当前份额约 `150 CNY/月`;若能稳定承接全部现有两池流量,理论上限约 `700 CNY/月`。`1300 CNY/月`不具备纯财务合理性。 +- 保留 lyon 相对不续的月度增量代价约为:不扣自用 `1224.78 CNY`,扣自用 `1149.65 CNY`。只有其可靠性预计能避免超过约 `1150 CNY/月`的客户流失、补偿、故障和支持成本时,续费才可能合理。 + +## 售价建议 + +- 财务转正不依赖把售价从 `0.10`提高到 `0.15`;不续固定号后,两个售价下均盈利。 +- `0.15`相较 `0.10`把扣自用利润从约 `471.44 CNY`提高到约 `1055.78 CNY`,增加约 `584.34 CNY/月`安全垫。 +- 建议把 `0.15`作为新销售或可调整套餐的目标核算价;存量客户是否调整应结合市场、承诺和流失风险,不应仅凭成本立即统一涨价。 +- YAML 当前 `saleRateCnyPerApiUsd` 仍为 `0.10`;本轮 `0.15`只是只读情景,不代表实际售价已变更。 + +## 容量与可靠性 + +- 移除 lyon 后,盈利池其他账号需增加约 `32.64%`负载,自用池其他账号需增加约 `23.77%`,因此不能只看利润直接切除。 +- 续费决策前应使用 Sub2API 原生账号级评分、客户可见错误、普通请求与压缩请求 TTFT、并发限制和 failover 成功率,确认剩余账号能承接增量。 +- 不默认使用外部哨兵;如需运行时试切或调整 priority,必须另获明确授权,并继续使用官方 runtime 能力和受控 CLI。 +- 优先级原则应为“质量达标后按边际成本排序”:稳定的 `0.01`/`0.02`账号优先,质量合格的 `0.03`账号承担主力与冗余,`0.05`/`0.06`账号作为容量和故障兜底;不得在没有最新评分时仅按账号名称直接批量调整。 + +## 自用成本 + +- 不续固定号后的自用采购成本预计约 `391.62 CNY/月`,应作为独立预算披露,不用盈利池利润掩盖。 +- 建议日常同时观察“不扣自用”和“扣自用”两种口径:前者判断销售业务,后者判断整体现金收益。 + +## 建议执行顺序 + +1. 本月继续充分使用已付费 lyon,不提前退场。 +2. 月末前用原生账号级指标确认剩余账号容量和质量,不做未经授权的试切。 +3. 下月默认不续;只有报价降至约 `700 CNY/月`以内,或流量与可量化可靠性价值显著提升时重新评估。 +4. 新销售按 `0.15`核算,存量价格单独评估。 +5. 不续后持续观察客户可见错误、TTFT、重试与切号成功率;若可靠性损失接近约 `1150 CNY/月`,再评估固定直连账号或其他高质量容量方案。 + +本任务只形成运营建议,未修改 YAML、runtime、Sub2API、外部哨兵、账号或售价。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.33_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.1_Task_Report.md similarity index 99% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.33_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.6.1_Task_Report.md index 1db76dc6..f6538eca 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.33_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.1_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.33 任务报告 +# R2.6.1 任务报告 ## 结果 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.34_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.2_Task_Report.md similarity index 99% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.34_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.6.2_Task_Report.md index 75a275d0..27bfb380 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.34_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.2_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.34 任务报告 +# R2.6.2 任务报告 ## 修正结论 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.35_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.3_Task_Report.md similarity index 99% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.35_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.6.3_Task_Report.md index cea5b49e..90ab4d21 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.35_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.3_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.35 任务报告 +# R2.6.3 任务报告 ## 结论 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.36_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.4_Task_Report.md similarity index 99% rename from docs/MDTODO/details/sub2api-upstream-reliability/R2.2.36_Task_Report.md rename to docs/MDTODO/details/sub2api-upstream-reliability/R2.6.4_Task_Report.md index 23643775..71355363 100644 --- a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.36_Task_Report.md +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.4_Task_Report.md @@ -1,4 +1,4 @@ -# R2.2.36 任务报告 +# R2.6.4 任务报告 ## 交付 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.5_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.5_Task_Report.md new file mode 100644 index 00000000..2895fbe3 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.5_Task_Report.md @@ -0,0 +1,6 @@ +# R2.6.5 任务报告 + +- owning YAML 增加 `2026-07` 的 lyon9801 一次性期间成本 1300 CNY;账号流量边际倍率改为 0.0。 +- 利润 CLI 增加 `--month YYYY-MM`,使用 Sub2API 原生 `/api/v1/admin/usage/stats` 按分组和账号聚合,约 8 秒完成;短时 `--since` 保留逐条 usage 精确窗口。 +- 账号 15 已通过 runtime CRUD 从 `lyon9801 0.10` 改为 `lyon9801 0.0`,全局 priority 从 20 调为 1,写后自动回读均 reconciled。 +- 7 月至今:收入 525.078160 CNY,盈利池流量成本 103.817293 CNY,自用流量成本 142.692022 CNY,期间成本 1300 CNY;不扣自用净利润 -878.739133 CNY,扣自用净利润 -1021.431155 CNY。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.6_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.6_Task_Report.md new file mode 100644 index 00000000..2d0a5806 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.6_Task_Report.md @@ -0,0 +1,5 @@ +# R2.6.6 任务报告 + +- 利润 CLI 增加 `--month YYYY-MM --forecast`,按月初至当前实际秒数线性外推收入、盈利池流量成本和自用流量成本;一次性期间成本只扣一次。 +- 2026-07 实测缩放系数 2.20319165:预计整月收入 1157.554135 CNY,盈利池流量成本 228.743080 CNY,自用流量成本 315.207563 CNY,一次性成本 1300 CNY。 +- 预计整月不扣自用利润 -371.188945 CNY,扣除自用利润 -686.396508 CNY。该结果是 run-rate 预测,不是已实现收益。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.7_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.7_Task_Report.md new file mode 100644 index 00000000..353d7836 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.7_Task_Report.md @@ -0,0 +1,42 @@ +# R2.6.7 任务报告 + +## 结论 + +亏损的决定性原因不是 `lyon9801 0.0` 占比过低,而是当前整月销售流水不足以覆盖其 `1300 CNY` 月度一次性成本。提高 lyon 占比可以降低其他账号边际成本,但在当前收入规模下不能单独扭亏。 + +## 数据口径 + +- 统计月:2026-07,截至 2026-07-15 01:53 UTC,按月内流水线性外推,缩放系数 `2.20184522`。 +- 整月预计收入:`1159.137600 CNY`。 +- 盈利池流量成本:`228.951233 CNY`。 +- 自用池流量成本:`316.145204 CNY`。 +- `lyon9801 0.0` 一次性成本:`1300 CNY`,边际成本按 `0.0` 计算。 +- 不扣自用预计利润:`-369.813633 CNY`。 +- 扣除自用预计利润:`-685.958837 CNY`。 + +## lyon9801 占比与价值 + +- 盈利池月初至今标准 API 美元计费基数合计 `5264.391840 USD`,其中 `lyon9801 0.0` 为 `1291.868865 USD`,占 `24.54%`。 +- 自用池月初至今标准 API 美元计费基数合计 `5719.246077 USD`,其中 `lyon9801 0.0` 为 `1092.319869 USD`,占 `19.10%`。 +- lyon 盈利池整月预计收入贡献约 `284.45 CNY`,只覆盖固定成本的 `21.9%`。 +- 按其他自用账号平均边际成本估算,lyon 在自用池整月约避免 `74.64 CNY` 采购成本;收入贡献与避免成本合计约 `359.09 CNY`,只覆盖固定成本的 `27.6%`。 + +## 亏损归因 + +1. 固定成本是决定性约束。即使盈利池和自用池全部由零边际成本的 lyon 承接,整月收入仍只有 `1159.14 CNY`,理论最好利润为 `1159.14 - 1300 = -140.86 CNY`。 +2. 盈利池其他账号边际成本扩大亏损。当前 `228.95 CNY` 盈利池流量成本使不扣自用亏损从理论最低 `-140.86 CNY` 扩大到 `-369.81 CNY`。 +3. 自用池成本进一步扩大管理口径亏损。当前 `316.15 CNY` 自用成本使扣自用亏损达到 `-685.96 CNY`。 +4. lyon 占比有提升空间,但不是根因。当前盈利池占比 `24.54%` 不算低;即使提升至 `100%`,最多消除现有盈利池边际成本,仍亏 `140.86 CNY`。 + +## 盈亏平衡 + +- 理想零边际成本:整月收入至少达到 `1300 CNY`,比当前预测增加约 `12.15%`。 +- 保持当前盈利池成本率且不扣自用:整月收入约需 `1619.98 CNY`,比当前预测增加约 `39.76%`。 +- 保持当前成本结构并扣除自用:整月收入约需 `2013.93 CNY`,比当前预测增加约 `73.74%`。 + +## 建议 + +- 保持 `lyon9801 0.0` 的高优先级,让新增盈利池流量优先落到该账号,以降低边际成本;不应把提高占比误判为足以单独扭亏。 +- 首要经营目标应是增加盈利池销售流水:理想状态至少增长 `12.2%`,按当前账号组合更实际的保本目标约为增长 `40%`。 +- 自用池单独核算预算和成本,不用盈利池收入掩盖自用消耗;扣自用口径用于整体现金成本,不扣自用口径用于盈利池经营判断。 +- 本次仅只读分析,未修改 runtime、Sub2API 源码、版本或外部哨兵。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.8_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.8_Task_Report.md new file mode 100644 index 00000000..e4f3170b --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.8_Task_Report.md @@ -0,0 +1,24 @@ +# R2.6.8 任务报告 + +## 口径 + +沿用 R2.6.7 截至 2026-07-15 01:53 UTC 的 2026-07 整月线性预测:盈利池标准 API 美元计费基数 `11591.376000 USD`,盈利池流量成本 `228.951233 CNY`,自用流量成本 `316.145204 CNY`,`lyon9801 0.0` 本月一次性成本 `1300 CNY`。售价按 `0.15 CNY per API USD` 计算,不做汇率换算。 + +## 结果 + +- 整月预计收入:`1738.706400 CNY`。 +- 不扣自用预计利润:`1738.706400 - 228.951233 - 1300 = 209.755167 CNY`。 +- 扣除自用预计利润:`209.755167 - 316.145204 = -106.390037 CNY`。 +- 相比售价 `0.10`,两种利润口径均改善 `579.568800 CNY`。 +- 若盈利池和自用池全部由零边际成本的 `lyon9801 0.0` 承接,理论利润为 `438.706400 CNY`。 + +## 保本售价 + +- 保持当前盈利池账号组合、不扣自用:约 `0.131904 CNY per API USD`。 +- 保持当前账号组合并覆盖自用成本:约 `0.159178 CNY per API USD`。 + +## 判断 + +售价 `0.15` 足以使盈利池经营口径转正,预计利润约 `209.76 CNY`;但不足以同时覆盖当前自用池成本,整体现金成本口径仍预计亏损约 `106.39 CNY`。提高 `lyon9801 0.0` 占比会继续改善结果:若边际成本充分降至零,`0.15` 售价下理论利润可达约 `438.71 CNY`。 + +本次只读计算,未修改 YAML、runtime、Sub2API 源码、版本或外部哨兵。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.9_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.9_Task_Report.md new file mode 100644 index 00000000..9ad37d5b --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6.9_Task_Report.md @@ -0,0 +1,34 @@ +# R2.6.9 任务报告 + +## 新鲜快照 + +使用最新 `origin/master` 的利润 CLI,从 PK01 Sub2API 原生 `/api/v1/admin/usage/stats` 拉取 20 次分页查询。统计窗口为 2026-07-01 00:00 UTC 至 2026-07-15 02:01:35 UTC,实际已过 `14.084440241` 天;盈利池计费基数 `5282.65742955 API USD`,盈利池采购成本 `104.2007966605 CNY`,自用采购成本 `143.759478531 CNY`。缺失成本账号和未分类流量均为零。 + +售价按 `0.15 CNY per API USD` 重算;`lyon9801 0.0` 的 `1300 CNY` 是本月一次性成本,日均仅用于利润摊销,不代表每天发生现金支出。 + +## 实际日均与整月日均 + +线性外推不会改变日均值:实际已过时长计算和整月预测除以 31 天结果一致。 + +- 每日标准 API 计费基数:`375.070456 API USD`。 +- 每日营业额:`56.260568 CNY`。 +- 盈利池每日变动采购成本:`7.398292 CNY`。 +- 自用池每日变动采购成本:`10.206971 CNY`。 +- lyon 本月固定成本日均摊销:`41.935484 CNY`。 +- 不扣自用的每日总成本:`49.333776 CNY`,每日利润 `6.926793 CNY`。 +- 扣除自用的每日总成本:`59.540747 CNY`,每日利润 `-3.280178 CNY`。 + +## 月度对应值 + +- 整月预计营业额:`1744.077622 CNY`。 +- 盈利池变动成本:`229.347041 CNY`。 +- 自用变动成本:`316.416113 CNY`。 +- 固定成本:`1300 CNY`。 +- 不扣自用预计利润:`214.730581 CNY`。 +- 扣除自用预计利润:`-101.685532 CNY`。 + +## 判断 + +每日经营在固定成本前是正贡献:不扣自用时为 `48.862277 CNY/天`,扣自用时为 `38.655305 CNY/天`。前者高于固定成本日均摊销 `41.935484 CNY/天`,因此盈利池口径盈利;后者低于固定成本摊销,因此整体口径每天约亏 `3.28 CNY`。此前计算方向正确,新快照将整体预计亏损从约 `106.39 CNY` 更新为约 `101.69 CNY`。 + +本次只读复核,未修改 YAML、runtime、Sub2API 或外部哨兵。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.6_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6_Task_Report.md new file mode 100644 index 00000000..0856575e --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.6_Task_Report.md @@ -0,0 +1,3 @@ +# R2.6 成本与利润统计任务报告 + +本分支汇总 YAML-first 利润 CLI、CNY per API USD 口径修正、最近 7 天统计和单次聚合优化。四项历史任务及报告均已迁入并保持完成状态。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.7.1_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.7.1_Task_Report.md new file mode 100644 index 00000000..daf996b8 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.7.1_Task_Report.md @@ -0,0 +1,7 @@ +# R2.7.1 Sub2API 原生 Grok 接入 Codex 调查报告 + +只读核查官方最新发布 `v0.1.155`、源码和相关 issue/PR。最新版本原生支持 `platform=grok`,账号类型包括 xAI OAuth 和 API Key;公开 `/v1/responses`、`/responses` 与 `/backend-api/codex/responses` 可服务 Codex Responses 请求。正确拓扑是 Codex 使用绑定 Grok 分组的 Sub2API API Key,由分组选择 Grok provider,再转发到 OAuth subscription proxy 或 xAI API。 + +普通接入无需修改 Sub2API 源码。客户端应直接使用 `grok-4.5` 与 Responses wire API;只有消费端必须保留别名时才配置 `model_mapping`。不应把 Grok 伪装成 `platform=openai`,否则会绕过 Grok 专属 OAuth、身份头、请求净化、配额和监控逻辑。 + +官方演进证据包括 Grok subscription PR #3310、Codex compatibility PR #3558、CLI identity issue #3952 与综合修复 PR #4009。任务未修改 runtime、YAML、代码或部署。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.7.2_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.7.2_Task_Report.md new file mode 100644 index 00000000..e1bcfefa --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.7.2_Task_Report.md @@ -0,0 +1,41 @@ +# R2.7.2 Codex Grok profile 接入 Sub2API 报告 + +## 结果 + +已把 `/root/.codex/config.toml.grok` 调整为独立的 Sub2API Grok Responses provider: + +- `model_provider = "Sub2API_Grok"` +- `model = "grok-4.5"` +- `review_model = "grok-4.5"` +- `model_reasoning_effort = "high"` +- `base_url = "https://api.pikapython.com/v1"` +- `wire_api = "responses"` +- `requires_openai_auth = true` +- `supports_websockets = false` + +`/root/.codex/auth.json.grok` 原本已经是 Codex 需要的 `OPENAI_API_KEY` 结构,且其中 Key 正是绑定 Sub2API Grok 分组的 Key,因此未替换密钥,只做结构、fingerprint 和权限验证。两个文件权限均保持 `0600`,未输出完整密钥。 + +## 验证 + +- TOML 解析成功,provider、模型、base URL 和 Responses transport 均符合预期。 +- JSON 解析成功,仅包含 `OPENAI_API_KEY`;验证前后 fingerprint 均为 `2d90208c41f3`。 +- 使用该 Key 请求 `https://api.pikapython.com/v1/models` 返回 HTTP 200,共 22 个 Grok 模型,包含 `grok-4.5`、`grok-4.3`、`grok-build-0.1` 和 Grok Composer,证明 Key 已绑定原生 Grok 分组。 + +## 最小推理 smoke + +第一次 `grok-4.5` Responses smoke: + +- 最终 HTTP 502。 +- Sub2API request ID:`5fb6cb9f-5243-4f6a-a4c9-70a908b12a3e`。 +- 请求已进入 `grok` 分组和账号 `https://sub.yjxm1221.top grok`(账号 36)。 +- 上游返回 502 后触发 failover,但该分组只有一个账号,排除原账号后出现 `no available accounts`。 + +第二次有界复测: + +- 最终 HTTP 503。 +- Sub2API request ID:`25064661-32e1-42ad-9ec3-84c154bf870e`。 +- trace 结论为 `account-select-failed`,6ms 内出现 `no available accounts`,说明前一次失败后当前 Grok 池无可调度候选。 + +## 结论 + +Codex profile、认证文件、Sub2API API Key 和 Grok 分组路由均已正确配置。当前不能完成成功推理验收的原因是 Grok 分组唯一上游账号返回 502 后没有第二候选,而不是本地配置或鉴权格式错误。没有为掩盖该问题继续盲目重试,也未修改 Sub2API runtime、分组或账号状态。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.7.3_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.7.3_Task_Report.md new file mode 100644 index 00000000..dd027e28 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.7.3_Task_Report.md @@ -0,0 +1,36 @@ +# R2.7.3 Grok 账号测试与网关路径复核报告 + +## 新证据 + +用户在 Sub2API 管理端对 `https://sub.yjxm1221.top grok` 执行账号连接测试成功:账号类型为 API Key,模型为 `grok-4.5`,上游返回正常文本。这证明账号凭据、base URL 和最小 xAI Responses 请求可用。 + +## 网关复测 + +使用绑定 Grok 分组的 Sub2API Key,经公开 `/v1/responses` 复测: + +- 结构化 Codex 风格请求返回 502,请求 ID `86069838-2559-45a7-a683-8b029f03b447`。 +- 账号连接测试同形态的 `input:"hi" + stream:true` 请求,在调度恢复后仍返回 502,请求 ID `dfd99f91-52e4-4de9-bea9-d64a3e8e623b`。 +- 502 后 `v0.1.155` 会把 Grok 账号临时不可调度 2 分钟;期间请求返回 503 `no available accounts`。这解释了中间的 503,但不是首个 502 的根因。 +- 原生窗口显示账号 36 可恢复为 `AVAILABLE=true`,因此不是永久禁用或认证错误。 + +## `v0.1.155` 源码路径差异 + +管理端 `testGrokAccountConnection`: + +- 直接使用账号的 API Key 和 base URL。 +- 请求体固定为 `model + input:"hi" + stream:true`。 +- 不经过分组调度。 +- 不注入 Grok prompt-cache identity。 + +公开 Grok gateway 的 `forwardGrokResponses`: + +- 先经过分组调度和模型映射。 +- 调用 `resolveGrokCacheIdentity`,按 Sub2API API Key、模型和请求内容生成租户隔离 identity。 +- 即使是 Grok API Key 账号,也会向 Responses body 写入 `prompt_cache_key`,并写入 `X-Grok-Conv-Id` header;只有 Free OAuth 专属工具注入受 `IsGrokOAuth()` 限制。 +- 第三方 base URL 因而收到的载荷不等同于管理端连接测试。 + +## 结论 + +“账号连接测试成功”与“Codex 经 Sub2API Grok 分组成功”并不矛盾。前者证明最小直连可用;后者当前稳定在上游账号 36 返回 502 后失败。根据 `v0.1.155` 源码,最值得优先验证的差异是公开网关为第三方 Grok API Key 上游增加的 `prompt_cache_key` / `X-Grok-Conv-Id` 及其他 Responses 净化,而不是本地 `config.toml.grok`、`auth.json.grok` 或账号凭据。 + +本任务保持只读,没有修改账号、runtime、分组或 Sub2API 源码。当前 Codex profile 配置仍正确,但该第三方上游尚未通过真实 Codex 网关载荷验收。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.7_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.7_Task_Report.md new file mode 100644 index 00000000..f2df01b8 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.7_Task_Report.md @@ -0,0 +1,3 @@ +# R2.7 Grok 原生接入与 Codex 兼容任务报告 + +本分支汇总官方 Grok provider 源码调查、`~/.codex` Grok profile 配置,以及管理端账号直连测试与公开 Responses 网关差异复核。三项任务均已形成独立报告;当前结论是本地 profile 和分组 Key 正确,第三方 Grok API Key 上游仍需验证对网关缓存 identity 与真实 Codex 载荷的兼容性。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.8_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.8_Task_Report.md new file mode 100644 index 00000000..580b0b98 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.8_Task_Report.md @@ -0,0 +1,26 @@ +# R2.8 MDTODO 层级治理任务报告 + +## Skill 规则 + +已在通用 `mdtodo-edit` skill 中加入层级约束:同一个 SUBITEM 下的直接 SUBSUBITEM 一般不超过 20;接近上限时必须按稳定职责、阶段或交付面拆成兄弟 SUBITEM,禁止机械按每 20 条分桶;已超限时先重组层级和编号,并保留状态、语义与报告关联。 + +## 文件重组 + +将原 `R2.2.1-R2.2.38` 按职责重组: + +- `R2.2`:通用故障归因、failover、runtime CRUD 与临时不可调度,15 项。 +- `R2.3`:system-log 索引能力,保持原编号。 +- `R2.4`:客户反馈、专项事件与原生诊断,9 项。 +- `R2.5`:版本、账号质量评分、用量归因和调度优先级,8 项。 +- `R2.6`:成本与利润统计,4 项。 +- `R2.7`:Grok 原生接入与 Codex 兼容,3 项。 + +已迁移并重新编号历史报告,保留完成/进行中状态;原进行中的账号质量聚合和 runtime priority CLI 分别迁为 `R2.5.3` 与 `R2.5.6`。已消除并行 stash 遗留的冲突标记和重复 `R2.2.37`,为官方 Grok 调查、Codex profile 配置和网关复核分配独立编号与报告。 + +## 验证 + +- `mdtodo-edit` skill 通过 `quick_validate.py`。 +- MDTODO 无重复 ID、无 Git 冲突标记。 +- 直接 SUBSUBITEM 数量为 `R2.2=15`、`R2.4=9`、`R2.5=8`、`R2.6=4`、`R2.7=3`。 +- 没有职责分支超过 20。 +- 已完成任务的报告链接均存在;仍进行中的父任务允许尚无总结报告。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.9_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.9_Task_Report.md new file mode 100644 index 00000000..179ee948 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.9_Task_Report.md @@ -0,0 +1,22 @@ +# R2.9 任务报告 + +## 范围 + +通过 PK01 Sub2API 受控 CLI 调整手工 runtime 账号 ID `33` 的人类可读成本标注与账号全局优先级;未修改 Sub2API 源码、版本、YAML 管理范围或外部哨兵。 + +## 近期证据 + +近 24 小时原生账号聚合:账号原名称 `https://api.iceiu.com plus 0.03`,尝试 `1039`、成功 `1030`、失败 `9`,可归因上游客户错误 `6`,失败率 `0.87%`;评分 `90.3/A`、置信度 high,TTFT P95 `58247ms`、样本 `915`;请求 `1030`、Token `3.512M`、标准 API 计费基数 `103.3889 USD`。账号当时 active、schedulable,容量 `20`,优先级 `5`。 + +## 决策与执行 + +新采购成本为每 1 USD 标准 API 计费对应 `0.011 CNY`。考虑其 A 级可靠性、成本显著低于 0.03 档但 TTFT 慢于最优账号,将优先级从 `5` 适度提高到 `2`,保留在免费账号优先级 `1` 之后。 + +- account-name dry-run:精确命中 ID `33`,仅名称从 `https://api.iceiu.com plus 0.03` 变为 `https://api.iceiu.com plus 0.011`。 +- priority dry-run:精确命中 ID `33`,仅优先级从 `5` 变为 `2`。 +- 两项 confirm 均 `writeSucceeded=1`、`writeFailed=0`、`reconciled=1`、`mismatched=0`。 +- 最终只读快照:名称 `https://api.iceiu.com plus 0.011`,优先级 `2`,active、schedulable=true,proxy `3`,容量 `20`,原临时不可调度模板保持不变。 + +## 结论 + +成本标注与优先级已按近期质量和成本完成精准 runtime 调优,最终状态与期望一致。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R3.1_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R3.1_Task_Report.md new file mode 100644 index 00000000..5708f077 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R3.1_Task_Report.md @@ -0,0 +1,36 @@ +# R3.1 任务报告 + +## 结果 + +- PK01 Sub2API 原生最近 24 小时 usage 选中 17 名活跃非管理员用户。 +- 每人计划增加 `10 USD`,计划总额 `170 USD`。 +- 模式为 `dry-run`,`mutation=false`。 +- `writeAttempted=0`、`writeSucceeded=0`、`writeFailed=0`、`reconciled=0`,没有实际充值。 + +## 用户名单 + +| 邮箱 | 用户 ID | 请求数 | 当前余额 USD | 计划余额 USD | +|---|---:|---:|---:|---:| +| Bernard@test.com | 23 | 2 | 99.96938600 | 109.96938600 | +| MacRsh@test.com | 11 | 341 | 42.92200430 | 52.92200430 | +| darrendreamc@163.com | 13 | 182 | 30.52278440 | 40.52278440 | +| huiguo@test.com | 15 | 472 | 56.49675000 | 66.49675000 | +| hyglql@test.com | 10 | 935 | 546.01330750 | 556.01330750 | +| laogen@test.com | 7 | 1678 | 370.04147180 | 380.04147180 | +| mail0795336304@163.com | 22 | 227 | 662.40832140 | 672.40832140 | +| miuser@test.com | 21 | 54 | 688.02232555 | 698.02232555 | +| name@test.com | 9 | 274 | 421.88935290 | 431.88935290 | +| phuohuo163@outlook.com | 20 | 248 | 565.16767640 | 575.16767640 | +| ren@test.com | 3 | 155 | 201.19074850 | 211.19074850 | +| ric@test.com | 4 | 23 | 42.35029270 | 52.35029270 | +| skylin008@gmail.com | 17 | 68 | 245.71857260 | 255.71857260 | +| wenrui@test.com | 5 | 68 | 30.73914185 | 40.73914185 | +| xiaoyang@test.com | 2 | 2012 | 5413.81344685 | 5423.81344685 | +| yinl0801@qq.com | 19 | 483 | 851.18146340 | 861.18146340 | +| zhuo@test.com | 16 | 89 | 149.21926750 | 159.21926750 | + +## 精确确认名单 + +用户 ID:`23,11,13,15,10,7,22,21,9,20,3,4,17,5,2,19,16`。 + +该名单仅用于后续明确授权时固定选择范围。本任务未使用 `--confirm`,不得把本报告解释为实际充值授权或完成记录。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R3.2_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R3.2_Task_Report.md new file mode 100644 index 00000000..8cfd65b3 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R3.2_Task_Report.md @@ -0,0 +1,44 @@ +# R3.2 任务报告 + +## 结果 + +- 已通过 [Issue #2097](https://github.com/pikasTech/unidesk/issues/2097) 和 [PR #2102](https://github.com/pikasTech/unidesk/pull/2102) 增加 Sub2API 原生公告受控 CLI。 +- PR 已合并到 `master`,merge commit 为 `43f34ccfe82c3be55b731d0e5c7e416de1bcccb5`。 +- CLI 支持公告 `list`、`get`、`create`:默认 Kubernetes 风格紧凑文本,显式 `--json` 输出机器结构。 +- `create` 支持标题、Markdown 正文、状态、通知模式、原生 targeting 以及 Unix 秒或 RFC3339 时间窗。 +- `create` 默认 dry-run;只有显式 `--confirm` 才调用原生创建 API。 +- 确认创建后的回读对账覆盖 ID、`title`、`content`、`status`、`notify_mode`、`targeting`、`starts_at` 和 `ends_at`;任一差异均返回失败并披露 `mismatchedFields`。 + +## 原生能力依据 + +- Sub2API 官方仓库 `Wei-Shaw/sub2api` 的 `v0.1.155` 原生提供 `/api/v1/admin/announcements`。 +- 原生后端支持分页列表、按 ID 查看、创建、更新、删除和阅读状态;本次按授权范围接入列表、查看和受控创建。 +- 未修改 Sub2API 源码、版本、外部哨兵或运行面配置。 + +## 补偿公告预览 + +- 标题:`服务稳定性补偿说明`。 +- 内容语义:计划向最近 24 小时有实际使用记录的 17 名活跃用户每人增加 10 USD,总计 170 USD,确认后发放。 +- 文案使用“计划”和“确认后发放”,没有表述为已经到账。 +- dry-run 返回 `mutation=false`、`writeAttempted=0`,完整预览正确。 +- PK01 原生公告列表只读验证返回 `mode=read-only`、`mutation=false`、`total=0`、`returned=0`。 + +## 验证 + +- scoped CLI help 通过。 +- 显式 JSON dry-run 输出合同通过。 +- 字段级对账纯内存 fixture 通过:全匹配无差异,多字段差异和 ID 差异均准确披露;`network=false`。 +- `git diff --check` 通过。 +- 子代理交付 commit:`2ab5fb87a2693ad6076e3abdeb3801c74269be69`。 +- 主代理纠偏 commit:`2c0cf9479197a37728a3c697da230fb9e7ce28de`。 + +## 写入边界 + +- 本轮没有使用 `--confirm`。 +- 公告发布数为 0。 +- 充值写入数为 0。 +- 实际公告发布和每人 10 USD 充值仍需用户对具体公告参数及固定用户名单再次明确授权。 + +## 后续改进 + +- post-task 已登记 [Issue #2106](https://github.com/pikasTech/unidesk/issues/2106),用于改进 `platform-infra` 深层 scoped help 的渐进披露,减少后续无效调用;不作为本任务合并门禁。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R3.3_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R3.3_Task_Report.md new file mode 100644 index 00000000..477bd3ce --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R3.3_Task_Report.md @@ -0,0 +1,37 @@ +# R3.3 任务报告 + +## 结果 + +通过 Sub2API 受控 CLI 精确查询了北京时间 `2026-07-14 17:00:00` 至 `18:00:00` 的活跃非管理员用户。该窗口按 `Asia/Shanghai` 转换为 UTC `2026-07-14T09:00:00Z` 至 `2026-07-14T10:00:00Z`,使用半开区间 `[start,end)`。 + +共选中 7 名用户: + +| 邮箱 | 用户 ID | 请求数 | +|---|---:|---:| +| MacRsh@test.com | 11 | 1 | +| huiguo@test.com | 15 | 31 | +| hyglql@test.com | 10 | 126 | +| laogen@test.com | 7 | 158 | +| mail0795336304@163.com | 22 | 26 | +| yinl0801@qq.com | 19 | 3 | +| zhuo@test.com | 16 | 35 | + +## CLI 改进 + +- owning YAML `config/platform-infra/sub2api-codex-pool.yaml#credits.timezone` 新增默认时区 `Asia/Shanghai`。 +- `credits grant` 新增通用 `--active-start/--active-end` 精确时间窗。 +- 未带偏移的 ISO 时间由 CLI 按 YAML IANA 时区换算为 UTC;本地与 UTC 边界同时披露。 +- 远端继续使用 Sub2API 原生 admin usage 和用户 API,并按 UTC 半开区间逐条过滤。 +- PK01 旧 Python 缺少 `zoneinfo`,因此时区换算在 Bun/Intl 控制面完成,远端只接收已验证 UTC 边界。 + +实现通过 [PR #2117](https://github.com/pikasTech/unidesk/pull/2117) 合并,merge commit 为 `7b43da1ab842183d36476e174e17fb7f45891b57`。 + +## 验证 + +- `bun --check scripts/src/platform-infra-sub2api-codex/credits.ts`:通过。 +- `bun --check scripts/src/platform-infra-sub2api-codex/config.ts`:通过。 +- `git diff --check`:通过。 +- PK01 原入口 dry-run:`ok=true`、`selection=active-window`、`selected=7`。 +- 写入证据:`mutation=false`、`writeAttempted=0`、`writeSucceeded=0`。 + +没有执行充值,没有修改 Sub2API 源码、版本、哨兵或运行面配置。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R3.4_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R3.4_Task_Report.md new file mode 100644 index 00000000..8bda6096 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R3.4_Task_Report.md @@ -0,0 +1,30 @@ +# R3.4 任务报告 + +## 结果 + +在 `config/platform-infra/sub2api-codex-pool.yaml#credits.excludedUsers` 中加入: + +- `zhuo@test.com` +- `huiguo@test.com` + +排除列表是通用精确 selector 配置,支持用户 ID 或邮箱,邮箱按大小写不敏感匹配。活跃时间窗与显式 `--users` 模式均在生成补偿计划和确认 selector 前应用该列表。 + +## CLI 可见性 + +默认 Kubernetes 风格输出新增 `EXCLUDED` 表格,披露邮箱、用户 ID、窗口请求数、`yaml-excluded-user` 原因和匹配 selector。汇总首行同时披露 `selected` 与 `excluded`。全部候选被排除时显示 `--users `,不再给出可执行的空确认提示。 + +## 验证 + +- Bun 语法检查和 `git diff --check` 通过。 +- 北京时间 `2026-07-14 17:00:00` 至 `18:00:00` 固定窗口 dry-run: + - `selected=5`; + - `huiguo@test.com` 请求数 31,按 YAML 排除; + - `zhuo@test.com` 请求数 35,按 YAML 排除; + - `mutation=false`、`writeAttempted=0`。 +- 显式 `--users 15,16` dry-run: + - `selected=0`、`excluded=2`、`total-usd=0`; + - `mutation=false`、`writeAttempted=0`。 + +实现通过 [PR #2119](https://github.com/pikasTech/unidesk/pull/2119) 合并,merge commit 为 `7920322030270a49164d78971dae2f0a6454997c`。 + +没有执行充值,没有修改 Sub2API 源码、版本、哨兵或运行面。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R3.5_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R3.5_Task_Report.md new file mode 100644 index 00000000..b272acaf --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R3.5_Task_Report.md @@ -0,0 +1,30 @@ +# R3.5 任务报告 + +## 查询窗口 + +- 本地时间:`2026-07-14 17:00:00` 至 `18:00:00 Asia/Shanghai`。 +- UTC:`2026-07-14T09:00:00Z` 至 `10:00:00Z`。 +- 区间:半开区间 `[start,end)`。 +- 数据源:Sub2API 原生 `/api/v1/admin/usage` 的 `input_tokens`、`output_tokens`。 +- 所有 1,267 条 usage 的 Token 字段覆盖均为 `complete`。 + +## 用户用量 + +| 用户 | ID | 分类 | 请求数 | 输入 Token | 输出 Token | 总 Token | +|---|---:|---|---:|---:|---:|---:| +| MacRsh@test.com | 11 | 补偿候选 | 1 | 506 | 12,309 | 12,815 | +| hyglql@test.com | 10 | 补偿候选 | 126 | 593,470 | 187,782 | 781,252 | +| laogen@test.com | 7 | 补偿候选 | 158 | 1,608,741 | 180,207 | 1,788,948 | +| mail0795336304@163.com | 22 | 补偿候选 | 26 | 29,735 | 21,641 | 51,376 | +| yinl0801@qq.com | 19 | 补偿候选 | 3 | 52,270 | 3,396 | 55,666 | +| huiguo@test.com | 15 | YAML 排除员工 | 31 | 61,069 | 30,623 | 91,692 | +| zhuo@test.com | 16 | YAML 排除员工 | 35 | 701,758 | 26,110 | 727,868 | +| admin@sub2api.platform-infra.local | 1 | 管理员排除 | 887 | 2,327,308 | 706,151 | 3,033,459 | + +## CLI 改进 + +credits 固定窗口现在按用户聚合请求数、输入 Token、输出 Token、总 Token,以及 Token 字段覆盖行和缺失行。缺失字段会标记 `partial`,精确用户模式没有 usage 窗口时标记 `unavailable`。原有角色、状态、余额和对账列保持不变。 + +实现通过 [PR #2122](https://github.com/pikasTech/unidesk/pull/2122) 合并,merge commit 为 `2add8477e4ea95070e896a66b29769ad1597f9bf`。 + +查询为 dry-run:`mutation=false`、`writeAttempted=0`,没有执行充值。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R3.6_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R3.6_Task_Report.md new file mode 100644 index 00000000..ce975a03 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R3.6_Task_Report.md @@ -0,0 +1,46 @@ +# R3.6 任务报告 + +## 执行范围 + +按用户明确授权,仅对固定用户 ID `11,10,7,22,19` 每人增加 `30 USD`,总额 `150 USD`;并发发布服务稳定性补偿公告。未使用移动活跃窗口,未包含 YAML 排除员工、管理员或其他用户。 + +## 充值结果 + +受控入口:`platform-infra sub2api codex-pool credits grant`,目标 `PK01`,显式 `--confirm`。 + +- selected: 5 +- writeAttempted: 5 +- writeSucceeded: 5 +- writeFailed: 0 +- reconciled: 5 +- mutation: true + +| 用户 | ID | 充值前 | 增加 | 充值后 | 对账 | +|---|---:|---:|---:|---:|---| +| MacRsh@test.com | 11 | 42.92200430 USD | 30 USD | 72.92200430 USD | true | +| hyglql@test.com | 10 | 542.68010850 USD | 30 USD | 572.68010850 USD | true | +| laogen@test.com | 7 | 349.15434580 USD | 30 USD | 379.15434580 USD | true | +| mail0795336304@163.com | 22 | 662.40832140 USD | 30 USD | 692.40832140 USD | true | +| yinl0801@qq.com | 19 | 841.79106140 USD | 30 USD | 871.79106140 USD | true | + +充值原因为 `2026-07-14 service stability compensation: 30 USD for five authorized active users`,批次使用受控 CLI 幂等键并逐用户回读余额。 + +## 公告结果 + +受控入口:`platform-infra sub2api codex-pool announcements create`,目标 `PK01`,显式 `--confirm`。 + +- announcement ID: 1 +- status: active +- notify mode: popup +- targeting: 全体用户(空条件) +- starts/ends: 无限制 +- writeAttempted: 1 +- mutation: true +- reconciled: true +- mismatchedFields: 空 + +标题为“服务稳定性补偿说明”。正文说明符合本次补偿条件的用户已到账 `30 USD`。 + +## 结论 + +充值和公告两条并发操作均成功到达终态并通过 Sub2API 原生回读对账,无失败用户、无公告字段漂移、无范围扩大。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R3_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R3_Task_Report.md new file mode 100644 index 00000000..b1d2c420 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R3_Task_Report.md @@ -0,0 +1,7 @@ +# R3 任务报告 + +- 新增受控 `credits grant`:支持 `--active-since` 选取原生 usage 中的活跃非管理员用户,也支持精确用户 ID/邮箱 selector;默认 dry-run。 +- 活跃窗口只允许生成计划;确认必须固定使用 dry-run 返回的精确用户 ID 列表,防止移动窗口改变名单。 +- 每个用户的余额 add 写入使用由精确名单、金额和原因生成的确定性 `Idempotency-Key`,随后逐用户回读余额。 +- 24 小时 dry-run 选中 18 个用户,每人拟增 20 USD,总额 360 USD;`writeAttempted=0`、`mutation=false`,未实际充值。确认 selector 为 `18,23,11,13,15,10,7,22,21,9,20,3,4,17,5,2,19,16`。 +- 单用户精确 selector dry-run 验证通过,移动活跃窗口带 `--confirm` 会在本地拒绝。实际充值必须等待用户对具体名单、金额和原因再次明确授权。 diff --git a/docs/MDTODO/sub2api-upstream-reliability.md b/docs/MDTODO/sub2api-upstream-reliability.md index 89cd3d13..11199d48 100644 --- a/docs/MDTODO/sub2api-upstream-reliability.md +++ b/docs/MDTODO/sub2api-upstream-reliability.md @@ -15,9 +15,9 @@ 增强 CLI 使用 Sub2API 原生 admin/ops 数据诊断请求错误、账号可用性、并发和 request trace,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.1_Task_Report.md)。 -### R2.2 [in_progress] +### R2.2 [completed] -在连续新窗口复核临时不可调度、failover、客户可见错误率和流式 degraded 请求,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2_Task_Report.md)。 +集中跟踪 Sub2API 通用客户错误归因、failover、临时不可调度模板、runtime CRUD 和所有分组诊断能力,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2_Task_Report.md)。 #### R2.2.1 [completed] @@ -69,73 +69,171 @@ #### R2.2.16 [completed] -紧急只读追踪用户 mail0795336304@163.com 反馈请求卡住15分钟:定位用户ID、近30分钟请求、当前长请求/并发、request ID、账号与模型,区分排队、上游首字、流式间隔、代理网络或客户连接,不取消请求且不修改runtime、YAML、proxy或调度,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.16_Task_Report.md)。 -#### R2.2.17 [completed] - -复盘用户直接反馈的只读追踪过程并交由 Artificer 实现 [Issue #2000](https://github.com/pikasTech/unidesk/issues/2000):增加通用的一键用户追踪 CLI,以邮箱或用户ID关联请求空窗、用户/API Key并发、client/internal request ID、上游账号名称、proxy基础设施和证据化归因,采用Kubernetes列表风格与稳定ID渐进披露,减少手工API与重复trace调用,不改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.17_Task_Report.md)。 -#### R2.2.18 [completed] - -将 PK01 的 Sub2API 通过 owning YAML 从 v0.1.153 受控升级到 v0.1.155:只修改 PK01 target镜像版本,依次执行状态核对、plan、apply dry-run、镜像预拉、受控apply、状态与公开健康验证,保持账号池runtime和Codex pool配置原样,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.18_Task_Report.md)。 -#### R2.2.19 [completed] - -交由 Artificer 实现 [Issue #2004](https://github.com/pikasTech/unidesk/issues/2004):增强 Sub2API CLI 拉取 v0.1.155 原生智能诊断与渠道监控状态,泛化所有分组/渠道概要和指定对象下钻,以Kubernetes列表风格呈现TTFT、上游与客户错误率、原生建议和证据边界,以及7/15/30天渠道运行状态、平台/模型、对话延迟、端点PING、可用率、最近记录和刷新事实,使用稳定ID渐进披露,不重算第二套诊断且不修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.19_Task_Report.md)。 - -#### R2.2.20 [completed] - -快速缓解错误 #17440:基于 request e5c08dde-9e60-4a14-9458-5574cec00b65 的真实链路,将精确短语 Input must be a list 补入通用 504 临时不可调度规则,对全部非 lyon9801 账号精准批量 dry-run、确认下发和自动回读对账,不改容量、优先级、代理或 Sub2API 源码,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.20_Task_Report.md)。 -#### R2.2.21 [completed] - -交由 Artificer 实现 [Issue #2013](https://github.com/pikasTech/unidesk/issues/2013):定义并实现 Sub2API P0/P1/P2 故障快速查询 CLI,P0 为上游故障穿透客户,P1 为首字延迟偏高且普通与 compact 分开统计并突出极端离群,P2 为上游错误率偏高;支持所有分组概要与按等级、分组、账号名称、模型、请求类型、稳定 request ID 下钻,默认 Kubernetes 列表风格和固定分页,不靠手工 limit,不修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.21_Task_Report.md)。 - -#### R2.2.22 [completed] - -主代理只读追踪客户错误 #17490(request ID 4eb9a84b-7346-47a8-bb66-fb7b44317279):使用 Sub2API 原生 Ops、trace、账号完整名称、failover/retry/临时不可调度和近期窗口证据,定位账号 https://sub2.pokexiao.com plus 0.03 的同步502为何穿透客户,区分候选耗尽、context取消、不可重试阶段或模板未命中;先调查,不盲目新增generic 502规则,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.22_Task_Report.md)。 -#### R2.2.23 [completed] - -交由 Artificer 实现 [Issue #2016](https://github.com/pikasTech/unidesk/issues/2016):增强 CLI 对 Sub2API 内部账号/渠道监控的原生只读访问,展示探测 timeout、cadence、阶段耗时、账号选择、上游首字节、切号/重试和最终状态,支持所有监控对象概要与稳定 ID 下钻,默认 Kubernetes 列表风格且不靠手工 limit;不修改运行面、不发新探针,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.23_Task_Report.md)。 -#### R2.2.24 [completed] - -只读调查错误 #17511(request ID `f7ff0efe-64d3-4dd7-8716-20bdfc3a2f90`)为何上游 502 后未成功切号,重建流式请求的账号选择、首字节、响应提交、failover、排除列表、context deadline 与最终客户错误链,并与内部 monitor 请求 #17490 对照,不修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.24_Task_Report.md)。 -#### R2.2.25 [completed] - -将 PK01 Sub2API 从 v0.1.155 受控回退到 v0.1.147:只修改 owning YAML 版本事实,在最新 master 的独立干净 worktree提交并通过受控 GitHub PR 合并,随后使用原 platform-infra Sub2API 交付入口完成 PK01 rollout,并核验实际镜像、版本、健康状态和原入口可用性,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.25_Task_Report.md)。 -#### R2.2.26 [completed] - -只读执行 PK01 Sub2API 最近4小时所有分组的上游账号质量评分:使用原生 Ops 账号请求量、客户/上游错误、切号、重试、临时不可调度、TTFT 和当前可用性,完整报告账号名称、评分、等级、置信度和扣分原因;兼容 v0.1.147,不修改 runtime、YAML 或调度配置,完成任务后将详细报告写入任务报告,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.26_Task_Report.md)。 -#### R2.2.27 [in_progress] - -交由 Artificer 实现 [Issue #2035](https://github.com/pikasTech/unidesk/issues/2035):增强 Sub2API 最近窗口账号质量分析,在同一分组、同一账号、同一窗口的一张 Kubernetes 列表中聚合展示评分/等级/置信度/失败率/TTFT 与请求数/Token(M/B)/金额($);复用 v0.1.147 原生 admin usage 分页,JSON 保留精确值与来源,字段缺失不伪造为零,并修复 `--group ` 精确下钻错误,不修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.27_Task_Report.md)。 - -#### R2.2.28 [completed] - -只读核查 PK01 Sub2API 当前全部 OpenAI 上游账号的实际 priority、分组成员关系、可调度与可用状态,并结合最近4小时分组内评分、失败率、TTFT、Token和金额给出分层的推荐优先级调整方案;报告完整账号名称,区分立即建议、观察建议和证据不足,不直接修改 runtime、YAML或调度配置,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.28_Task_Report.md)。 -#### R2.2.29 [completed] - -按 R2.2.28 的最近4小时评分与用量结论,精准调整 PK01 Sub2API 账号全局 priority:只更新明确列出的 runtime-manual 账号,变更前全量预检、失败自动回滚、变更后批量回读对账;不修改分组、容量、代理、凭据、状态、可调度或临时不可调度规则,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.29_Task_Report.md)。 -#### R2.2.30 [in_progress] - -交由 Artificer 实现 [Issue #2037](https://github.com/pikasTech/unidesk/issues/2037):为 Sub2API runtime 接入账号全局 priority 的单账号与精准批量 dry-run/confirm、所有 selector 预检、写后自动回读对账、所有权提示和 Kubernetes 列表/显式 JSON 输出;明确 lower-is-higher、没有分组优先级,兼容 v0.1.147,不自动调整评分、不部署或修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.30_Task_Report.md)。 -#### R2.2.31 [completed] - -只读复核 PK01 Sub2API 全局 priority 调整生效后约25分钟的新窗口:按全部分组和完整账号名称统计请求量、流量份额、客户/上游错误、失败率、TTFT、切号、Token与原生金额,并将账号名称尾部0.02/0.025/0.03/0.05/0.06/0.1作为上游采购成本权重综合评价当前分层,给出下一轮优先级建议;不修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.31_Task_Report.md)。 -#### R2.2.32 [completed] - -按用户确认的经济口径修正 PK01 Sub2API runtime:将账号15名称从lyon9801标注为lyon9801 0.10,并按净售价0.10减采购成本重新调整全局priority,将零毛利账号15从1降至20、将成本0.02且近期表现良好的账号35从20升至5;执行前校验名称/旧值,变更后回读,不修改分组、容量、代理、凭据、状态、可调度或临时不可调度规则,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.32_Task_Report.md)。 -#### R2.2.33 [completed] - -实现 YAML-first 的 Sub2API 利润统计 CLI:默认读取最近24小时原生 admin usage,按账号完整名称聚合请求、Token(M/B)和 actual_cost,并依据 YAML 声明的盈利分组、自用分组、净收入系数与账号名称尾部采购成本系数,输出不扣除自用成本和扣除自用成本两种利润口径;缺失成本或未分类流量必须标记 partial,默认 Kubernetes 列表风格、显式 --json 保留精确值,兼容 v0.1.147,不修改 runtime,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.33_Task_Report.md)。 -#### R2.2.34 [completed] - -纠正 Sub2API 利润 CLI 的币种与倍率语义:原生 actual_cost 是标准 API 美元计费基数,售价 0.10 和账号名称尾部 0.02/0.025/0.03/0.05/0.06/0.10 都表示每 1 美元标准 API 计费对应的人民币单价;将 YAML、字段名、Kubernetes 表格、JSON 和 skill 统一改为 CNY per API USD,重新统计最近24小时人民币利润,不做汇率换算且不修改 runtime,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.34_Task_Report.md)。 -#### R2.2.35 [completed] - -只读计算 PK01 Sub2API 最近7天成本与利润:使用已修正的 CNY per API USD 口径,从原生 admin usage 按完整账号名称聚合标准 API 美元计费基数、人民币收入和人民币采购成本,分别给出不扣除自用成本与扣除自用成本的人民币利润;不修改 runtime,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.35_Task_Report.md)。 -#### R2.2.36 [completed] - -由 Artificer 优化 Sub2API 利润统计 CLI 的单次聚合与渐进披露,减少 7 天成本利润计算中的重复查询、外部解析和手工算术;默认 Kubernetes 风格与显式 JSON 都应直接保留紧凑汇总,账号明细语义化下钻,缺失历史成本时输出参数化利润公式;不修改 runtime 或部署,关联 [GitHub issue #2043](https://github.com/pikasTech/unidesk/issues/2043),完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.36_Task_Report.md)。 -#### R2.2.37 [in_progress] - -把 `~/.codex/config.toml.grok` 和 `~/.codex/auth.json.grok` 调整为通过 Sub2API 原生 Grok 分组接入 Codex;保留密钥脱敏和文件权限,先验证现有 Key 的分组/模型能力,再修改并做最小只读配置验收,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.37_Task_Report.md)。 +核对 Sub2API v0.1.155 官方源码与配置能力并按支持边界实施两项源头调优:将确定的模型上下文超限错误避免包装为通用上游502,以及在官方配置确实支持响应首 token 超过180秒触发当前请求failover时通过 owning YAML和受控CLI启用;Grok不处理,不修改Sub2API源码,不用隐藏UniDesk补丁伪造上游能力,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.16_Task_Report.md)。 ### R2.3 [completed] 调查并收敛 Sub2API 原生 system-log 索引未保留策略事件的能力缺口,关联 [Issue #2027](https://github.com/pikasTech/unidesk/issues/2027),完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.3_Task_Report.md)。 + +### R2.4 [completed] + +集中跟踪客户直接反馈、专项请求事件和 Sub2API 原生诊断能力,形成按用户、请求、账号和监控对象快速下钻的运维入口,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.4_Task_Report.md)。 + +#### R2.4.1 [completed] + +紧急只读追踪用户 mail0795336304@163.com 反馈请求卡住15分钟:定位用户ID、近30分钟请求、当前长请求/并发、request ID、账号与模型,区分排队、上游首字、流式间隔、代理网络或客户连接,不取消请求且不修改runtime、YAML、proxy或调度,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.4.1_Task_Report.md)。 +#### R2.4.2 [completed] + +复盘用户直接反馈的只读追踪过程并交由 Artificer 实现 [Issue #2000](https://github.com/pikasTech/unidesk/issues/2000):增加通用的一键用户追踪 CLI,以邮箱或用户ID关联请求空窗、用户/API Key并发、client/internal request ID、上游账号名称、proxy基础设施和证据化归因,采用Kubernetes列表风格与稳定ID渐进披露,减少手工API与重复trace调用,不改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.4.2_Task_Report.md)。 +#### R2.4.3 [completed] + +将 PK01 的 Sub2API 通过 owning YAML 从 v0.1.153 受控升级到 v0.1.155:只修改 PK01 target镜像版本,依次执行状态核对、plan、apply dry-run、镜像预拉、受控apply、状态与公开健康验证,保持账号池runtime和Codex pool配置原样,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.4.3_Task_Report.md)。 +#### R2.4.4 [completed] + +交由 Artificer 实现 [Issue #2004](https://github.com/pikasTech/unidesk/issues/2004):增强 Sub2API CLI 拉取 v0.1.155 原生智能诊断与渠道监控状态,泛化所有分组/渠道概要和指定对象下钻,以Kubernetes列表风格呈现TTFT、上游与客户错误率、原生建议和证据边界,以及7/15/30天渠道运行状态、平台/模型、对话延迟、端点PING、可用率、最近记录和刷新事实,使用稳定ID渐进披露,不重算第二套诊断且不修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.4.4_Task_Report.md)。 +#### R2.4.5 [completed] + +快速缓解错误 #17440:基于 request e5c08dde-9e60-4a14-9458-5574cec00b65 的真实链路,将精确短语 Input must be a list 补入通用 504 临时不可调度规则,对全部非 lyon9801 账号精准批量 dry-run、确认下发和自动回读对账,不改容量、优先级、代理或 Sub2API 源码,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.4.5_Task_Report.md)。 +#### R2.4.6 [completed] + +交由 Artificer 实现 [Issue #2013](https://github.com/pikasTech/unidesk/issues/2013):定义并实现 Sub2API P0/P1/P2 故障快速查询 CLI,P0 为上游故障穿透客户,P1 为首字延迟偏高且普通与 compact 分开统计并突出极端离群,P2 为上游错误率偏高;支持所有分组概要与按等级、分组、账号名称、模型、请求类型、稳定 request ID 下钻,默认 Kubernetes 列表风格和固定分页,不靠手工 limit,不修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.4.6_Task_Report.md)。 +#### R2.4.7 [completed] + +主代理只读追踪客户错误 #17490(request ID 4eb9a84b-7346-47a8-bb66-fb7b44317279):使用 Sub2API 原生 Ops、trace、账号完整名称、failover/retry/临时不可调度和近期窗口证据,定位账号 https://sub2.pokexiao.com plus 0.03 的同步502为何穿透客户,区分候选耗尽、context取消、不可重试阶段或模板未命中;先调查,不盲目新增generic 502规则,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.4.7_Task_Report.md)。 +#### R2.4.8 [completed] + +交由 Artificer 实现 [Issue #2016](https://github.com/pikasTech/unidesk/issues/2016):增强 CLI 对 Sub2API 内部账号/渠道监控的原生只读访问,展示探测 timeout、cadence、阶段耗时、账号选择、上游首字节、切号/重试和最终状态,支持所有监控对象概要与稳定 ID 下钻,默认 Kubernetes 列表风格且不靠手工 limit;不修改运行面、不发新探针,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.4.8_Task_Report.md)。 +#### R2.4.9 [completed] + +只读调查错误 #17511(request ID `f7ff0efe-64d3-4dd7-8716-20bdfc3a2f90`)为何上游 502 后未成功切号,重建流式请求的账号选择、首字节、响应提交、failover、排除列表、context deadline 与最终客户错误链,并与内部 monitor 请求 #17490 对照,不修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.4.9_Task_Report.md)。 +### R2.5 [in_progress] + +集中跟踪 Sub2API 版本基线、上游账号质量评分、用量归因和账号全局调度优先级,保留只读评估与受控 runtime 调整边界,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5_Task_Report.md)。 + +#### R2.5.1 [completed] + +将 PK01 Sub2API 从 v0.1.155 受控回退到 v0.1.147:只修改 owning YAML 版本事实,在最新 master 的独立干净 worktree提交并通过受控 GitHub PR 合并,随后使用原 platform-infra Sub2API 交付入口完成 PK01 rollout,并核验实际镜像、版本、健康状态和原入口可用性,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.1_Task_Report.md)。 +#### R2.5.2 [completed] + +只读执行 PK01 Sub2API 最近4小时所有分组的上游账号质量评分:使用原生 Ops 账号请求量、客户/上游错误、切号、重试、临时不可调度、TTFT 和当前可用性,完整报告账号名称、评分、等级、置信度和扣分原因;兼容 v0.1.147,不修改 runtime、YAML 或调度配置,完成任务后将详细报告写入任务报告,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.2_Task_Report.md)。 +#### R2.5.3 [in_progress] + +交由 Artificer 实现 [Issue #2035](https://github.com/pikasTech/unidesk/issues/2035):增强 Sub2API 最近窗口账号质量分析,在同一分组、同一账号、同一窗口的一张 Kubernetes 列表中聚合展示评分/等级/置信度/失败率/TTFT 与请求数/Token(M/B)/金额($);复用 v0.1.147 原生 admin usage 分页,JSON 保留精确值与来源,字段缺失不伪造为零,并修复 `--group ` 精确下钻错误,不修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.3_Task_Report.md)。 +#### R2.5.4 [completed] + +只读核查 PK01 Sub2API 当前全部 OpenAI 上游账号的实际 priority、分组成员关系、可调度与可用状态,并结合最近4小时分组内评分、失败率、TTFT、Token和金额给出分层的推荐优先级调整方案;报告完整账号名称,区分立即建议、观察建议和证据不足,不直接修改 runtime、YAML或调度配置,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.4_Task_Report.md)。 +#### R2.5.5 [completed] + +按 R2.5.4 的最近4小时评分与用量结论,精准调整 PK01 Sub2API 账号全局 priority:只更新明确列出的 runtime-manual 账号,变更前全量预检、失败自动回滚、变更后批量回读对账;不修改分组、容量、代理、凭据、状态、可调度或临时不可调度规则,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.5_Task_Report.md)。 + +#### R2.5.6 [in_progress] + +交由 Artificer 实现 [Issue #2037](https://github.com/pikasTech/unidesk/issues/2037):为 Sub2API runtime 接入账号全局 priority 的单账号与精准批量 dry-run/confirm、所有 selector 预检、写后自动回读对账、所有权提示和 Kubernetes 列表/显式 JSON 输出;明确 lower-is-higher、没有分组优先级,兼容 v0.1.147,不自动调整评分、不部署或修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.6_Task_Report.md)。 +#### R2.5.7 [completed] + +只读复核 PK01 Sub2API 全局 priority 调整生效后约25分钟的新窗口:按全部分组和完整账号名称统计请求量、流量份额、客户/上游错误、失败率、TTFT、切号、Token与原生金额,并将账号名称尾部0.02/0.025/0.03/0.05/0.06/0.1作为上游采购成本权重综合评价当前分层,给出下一轮优先级建议;不修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.7_Task_Report.md)。 +#### R2.5.8 [completed] + +按用户确认的经济口径修正 PK01 Sub2API runtime:将账号15名称从lyon9801标注为lyon9801 0.10,并按净售价0.10减采购成本重新调整全局priority,将零毛利账号15从1降至20、将成本0.02且近期表现良好的账号35从20升至5;执行前校验名称/旧值,变更后回读,不修改分组、容量、代理、凭据、状态、可调度或临时不可调度规则,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.8_Task_Report.md)。 +#### R2.5.9 [completed] + +只读分析 PK01 Sub2API 最近2小时所有分组的用户可见错误、普通与 compact 请求首字延迟分布及完整账号名称质量评分,关联请求量、上游错误、failover、重试、临时不可调度、当前可用性和样本置信度,给出主因与调度观察建议,不修改 runtime、YAML 或运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.9_Task_Report.md)。 +#### R2.5.10 [completed] + +只读统计 PK01 Sub2API 最近8小时所有分组的账号质量评分与流水,按完整账号名称聚合请求量、Token(M/B)、标准API美元计费基数、客户/上游错误、failover、重试、临时不可调度、TTFT、当前可用性、评分等级与置信度,并区分盈利池和自用池;不修改runtime、YAML或运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.10_Task_Report.md)。 +#### R2.5.11 [completed] + +精确分析 PK01 最近8小时盈利池客户可见错误:按状态码、响应详情、账号、模型、同步/流式、端点、阶段、failover/retry和代表request ID重建具体原因;随后结合两分组账号评分、样本置信度、采购成本和盈利贡献,生成精确全局priority方案,先dry-run再确认写入并批量回读,不修改分组、容量、代理、凭据、可调度或临时不可调度规则,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.11_Task_Report.md)。 +#### R2.5.12 [completed] + +只读确认流式并发限额错误发生于 SSE 已提交后的 HTTP 200 degraded 阶段,核验官方临时不可调度配置无法挽救当前流后停止追加无效规则;固化默认不修改 Sub2API 源码、版本或外部哨兵的边界,仅在用户明确授权时允许这些手段,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.5.12_Task_Report.md)。 + +### R2.6 [completed] + +集中跟踪 Sub2API 标准 API 美元计费基数、人民币采购成本、售价和多口径利润统计,保持 YAML-first 的经济口径,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6_Task_Report.md)。 + +#### R2.6.1 [completed] + +实现 YAML-first 的 Sub2API 利润统计 CLI:默认读取最近24小时原生 admin usage,按账号完整名称聚合请求、Token(M/B)和 actual_cost,并依据 YAML 声明的盈利分组、自用分组、净收入系数与账号名称尾部采购成本系数,输出不扣除自用成本和扣除自用成本两种利润口径;缺失成本或未分类流量必须标记 partial,默认 Kubernetes 列表风格、显式 --json 保留精确值,兼容 v0.1.147,不修改 runtime,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.1_Task_Report.md)。 +#### R2.6.2 [completed] + +纠正 Sub2API 利润 CLI 的币种与倍率语义:原生 actual_cost 是标准 API 美元计费基数,售价 0.10 和账号名称尾部 0.02/0.025/0.03/0.05/0.06/0.10 都表示每 1 美元标准 API 计费对应的人民币单价;将 YAML、字段名、Kubernetes 表格、JSON 和 skill 统一改为 CNY per API USD,重新统计最近24小时人民币利润,不做汇率换算且不修改 runtime,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.2_Task_Report.md)。 +#### R2.6.3 [completed] + +只读计算 PK01 Sub2API 最近7天成本与利润:使用已修正的 CNY per API USD 口径,从原生 admin usage 按完整账号名称聚合标准 API 美元计费基数、人民币收入和人民币采购成本,分别给出不扣除自用成本与扣除自用成本的人民币利润;不修改 runtime,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.3_Task_Report.md)。 +#### R2.6.4 [completed] + +由 Artificer 优化 Sub2API 利润统计 CLI 的单次聚合与渐进披露,减少 7 天成本利润计算中的重复查询、外部解析和手工算术;默认 Kubernetes 风格与显式 JSON 都应直接保留紧凑汇总,账号明细语义化下钻,缺失历史成本时输出参数化利润公式;不修改 runtime 或部署,关联 [GitHub issue #2043](https://github.com/pikasTech/unidesk/issues/2043),完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.4_Task_Report.md)。 +#### R2.6.5 [completed] + +扩展 YAML-first 利润算法:将 lyon9801 的边际采购倍率改为 0.0,并把 2026-07 的 1300 元一次性账号成本作为期间固定成本单列;利润 CLI 支持按月聚合并分别披露流量成本、一次性成本和扣除后的两种利润口径,再依据近期账号质量与边际利润调整账号全局 priority,不修改 Sub2API 源码、版本、外部哨兵、分组、容量、代理、凭据或可调度状态,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.5_Task_Report.md)。 +#### R2.6.6 [completed] + +基于本月已有 Sub2API 原生流水增加整月收益 run-rate 估算:将月初至当前的收入、盈利池流量成本和自用流量成本按已过时间线性外推至月末,一次性期间成本只扣一次,输出不扣自用与扣除自用两种整月预计利润、缩放系数和 MTD 实际基准;保持 YAML-first 经济口径,不修改 runtime 或 Sub2API,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.6_Task_Report.md)。 +#### R2.6.7 [completed] + +只读分析 2026-07 整月收益预测亏损根因:按盈利池与自用池、完整账号名称、标准 API 美元计费基数、边际成本和一次性成本拆解,量化 lyon9801 当前流量占比、固定成本覆盖率、全量切向 lyon 的理论上限和当前组合盈亏平衡流水,判断亏损主要来自 lyon 占比、总流水不足、自用成本还是其他账号边际成本;不修改 runtime 或 Sub2API,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.7_Task_Report.md)。 +#### R2.6.8 [completed] + +按售价 0.15 CNY per API USD 只读重算 2026-07 整月预测收入、盈利池利润、扣除自用后的整体利润、当前组合保本售价和全量 lyon 理论利润;沿用 R2.6.7 同一流水快照与成本结构,不修改 YAML、runtime 或 Sub2API,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.8_Task_Report.md)。 +#### R2.6.9 [completed] + +复核售价 0.15 CNY per API USD 的整月预测:重新拉取当前 Sub2API 原生流水,并按实际已过时长计算每日标准 API 计费基数、营业额、盈利池变动成本、自用成本、lyon9801 固定成本日均摊销及两种日利润,核对月度外推是否一致;只读,不修改配置或运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.9_Task_Report.md)。 +#### R2.6.10 [completed] + +评估 2026-08 不再采购固定账号 lyon9801 0.0 时的预期盈利:售价保持 0.15 CNY per API USD,按最新日均流水外推 31 天,分别计算直接取消 1300 元固定成本的理论上限,以及 lyon 当前盈利池和自用池流量按其他活跃账号实际加权平均边际成本接管的现实预测;披露容量与质量风险,只读且不调整账号,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.10_Task_Report.md)。 +#### R2.6.11 [completed] + +将固定账号退出核算流程沉淀到通用利润 CLI 和 Sub2API skill:增加可复用的排除账号情景参数,一次原生 usage 聚合直接输出售价覆盖、理论上限、按剩余账号分组加权成本重分配的现实利润、日均营业额与成本、账号流量占比和剩余账号负载增幅;保持默认 Kubernetes 列表风格与显式 JSON,避免账号分页和手工算术,不修改 Sub2API 运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.11_Task_Report.md)。 +#### R2.6.12 [completed] + +基于最新 0.15 售价情景、当前 0.10 YAML 口径、lyon9801 固定成本、两池替代成本、账号负载增幅和可靠性价值,形成综合运营建议:明确本月沉没成本利用、下月续费决策、可接受固定号价格、售价策略、容量与质量门槛、自用预算和账号优先级原则;只做建议,不修改配置或运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.6.12_Task_Report.md)。 +### R2.7 [completed] + +集中跟踪 Sub2API 原生 Grok 账号、分组、Codex Responses 接入和第三方 Grok 上游兼容性,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.7_Task_Report.md)。 + +#### R2.7.1 [completed] + +只读调查 Sub2API 官方源码和 issue,确认最新原生 Grok provider 通过 Sub2API 接入 Codex 的受支持路径,区分 OAuth、API Key、Responses、模型映射和源码能力边界;不修改 runtime、YAML、代码或部署,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.7.1_Task_Report.md)。 +#### R2.7.2 [completed] + +把 `~/.codex/config.toml.grok` 和 `~/.codex/auth.json.grok` 调整为通过 Sub2API 原生 Grok 分组接入 Codex;保留密钥脱敏和文件权限,验证现有 Key 的分组与模型能力,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.7.2_Task_Report.md)。 +#### R2.7.3 [completed] + +结合管理端 `https://sub.yjxm1221.top grok` API Key 账号连接测试成功的新证据,复核 Codex Grok profile 经 Sub2API `/v1/responses` 的网关链路,区分账号直连测试与分组调度路径,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.7.3_Task_Report.md)。 +### R2.8 [completed] + +整理本 MDTODO FILE 的职责层级和编号,并把同一 SUBITEM 下的直接 SUBSUBITEM 一般不超过 20 固化到 mdtodo-edit skill,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.8_Task_Report.md)。 + +### R2.9 [completed] + +将 `https://api.iceiu.com/` 对应上游账号的人民币采购成本标注调整为 `0.011`,结合 Sub2API 原生近期账号评分、错误率、TTFT、用量和当前 runtime 优先级选择适度提高后的优先级;手工账号使用精准 runtime CRUD,变更前 dry-run、确认写入后逐字段回读对账,不修改 Sub2API 源码、版本或外部哨兵,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.9_Task_Report.md)。 +### R2.10 [completed] + +将 `https://sub.yjxm1221.top/` 对应上游账号的人民币采购成本标注调整为 `0.025`,结合 Sub2API 原生近期账号评分、错误率、TTFT、用量和当前 runtime 优先级适当调整优先级;手工账号使用精准 runtime CRUD,变更前 dry-run、确认写入后逐字段回读对账,不修改 Sub2API 源码、版本或外部哨兵,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.10_Task_Report.md)。 +## R3 [completed] + +增加 Sub2API 客户余额精准批量充值 CLI:支持从原生最近24小时用量提取活跃用户并生成每人增加20美元的补偿计划,也支持显式用户ID或账号 selector;默认 dry-run 输出完整账号、用户ID、当前余额、增额和目标余额,写入必须显式 --confirm 并自动回读对账。本轮只实现并验证 dry-run,禁止实际充值,必须等待用户再次明确授权,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R3_Task_Report.md)。 + +### R3.1 [completed] + +从 PK01 Sub2API 原生最近24小时 usage 精确列出活跃非管理员用户,并生成每人增加10 USD额度的批量补偿 dry-run:披露完整邮箱、用户ID、请求数、当前余额、增额和目标余额,实际写入数必须为0;不使用 --confirm,不执行充值,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R3.1_Task_Report.md)。 + +### R3.2 [completed] + +交由 Artificer 实现 [Issue #2097](https://github.com/pikasTech/unidesk/issues/2097):对接 Sub2API 原生公告后端并增加受控公告 CLI,支持公告列表、精确查看、创建 dry-run、显式确认发布和写后回读对账;为最近24小时17名活跃用户每人10 USD、总额170 USD的补偿生成公告预览,本轮禁止实际发布公告或充值,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R3.2_Task_Report.md)。 + +### R3.3 [completed] + +通过 Sub2API 受控 CLI 精确拉取北京时间 2026-07-14 17:00:00 至 18:00:00 的活跃非管理员用户;时间解释必须读取 owning YAML 的默认 Asia/Shanghai 时区,不用相对窗口近似,不执行充值或其他运行面写入;若 CLI 缺少绝对时间窗能力,先按通用方式补齐并验证后再查询,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R3.3_Task_Report.md)。 + +### R3.4 [completed] + +在 Sub2API credits owning YAML 的排除列表中加入内部员工账号 zhuo@test.com 与 huiguo@test.com,并让活跃用户补偿 dry-run 按通用精确 selector 排除并披露 employee-excluded 原因;复测北京时间固定窗口不再选中这两个账号,不执行充值,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R3.4_Task_Report.md)。 + +### R3.5 [completed] + +增强 Sub2API credits 固定时间窗查询,按原生 usage 的实际字段为每名用户聚合请求次数、输入 Token、输出 Token 和总 Token,并重新列出北京时间 2026-07-14 17:00 至 18:00 的客户与 YAML 排除账号;必须披露字段来源和缺失值,不执行充值,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R3.5_Task_Report.md)。 + +### R3.6 [completed] + +按用户明确授权,对固定补偿候选用户 ID 11、10、7、22、19 每人增加 30 USD,总额 150 USD,并并发发布服务稳定性补偿公告;两条操作都必须通过 Sub2API 原生受控 CLI 显式 confirm,使用精确名单、写后回读和公告字段对账,禁止扩大到 YAML 排除员工或管理员,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R3.6_Task_Report.md)。 From b5a76f55ef1357ae239aace0a875f129b980f4e7 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:55:42 +0200 Subject: [PATCH 23/26] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E4=B8=8A?= =?UTF-8?q?=E6=B5=B7=E5=A4=A7=E5=85=AC=E5=8F=B8=E6=B1=82=E8=81=8C=E8=B0=83?= =?UTF-8?q?=E6=9F=A5=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../personal-job-search/R1.1_Task_Report.md | 25 +++++++++++++ .../personal-job-search/R1.2_Task_Report.md | 26 ++++++++++++++ .../personal-job-search/R1_Task_Report.md | 36 +++++++++++++++++++ docs/MDTODO/personal-job-search.md | 16 +++++++++ 4 files changed, 103 insertions(+) create mode 100644 docs/MDTODO/details/personal-job-search/R1.1_Task_Report.md create mode 100644 docs/MDTODO/details/personal-job-search/R1.2_Task_Report.md create mode 100644 docs/MDTODO/details/personal-job-search/R1_Task_Report.md create mode 100644 docs/MDTODO/personal-job-search.md diff --git a/docs/MDTODO/details/personal-job-search/R1.1_Task_Report.md b/docs/MDTODO/details/personal-job-search/R1.1_Task_Report.md new file mode 100644 index 00000000..cab63322 --- /dev/null +++ b/docs/MDTODO/details/personal-job-search/R1.1_Task_Report.md @@ -0,0 +1,25 @@ +# R1.1 任务报告 + +## 调查范围 + +- 上海工业自动化、能源、电气和大型外企公开岗位。 +- 资料收集由 `gpt-5.6-luna`、低思考等级子代理执行。 +- 子任务及原始资料:。 + +## 核验结论 + +- 第一批保留: + - 施耐德供应链规划数字化转型负责人 `009KHM`,涉及 AI Agent、Skill、Harness、机器学习与全栈能力,投递前确认管理职责占比; + - 采埃孚算法工程师 `Req 89163`,涉及控制、规划、PID/MPC、C++、Linux、MATLAB/Simulink/ROS。 +- 补充核验: + - 施耐德电子硬件设计 `009LNN` 需要继续核对完整职责和年限门槛; + - 博世 IoT Computing 需补齐稳定详情页,强化学习研究岗有博士或顶会成果等明显硬门槛。 +- 观察池: + - Rockwell 当前只有搜索页级证据; + - GE、ABB、Siemens 暂无足够稳定的上海具体岗位证据。 + +## 主代理复核 + +- 最终优先级按硬门槛、方向匹配和证据稳定性重排。 +- 未修改源码、未创建 PR。 +- 任务后复盘未发现值得扩展为独立工程 issue 的重复性问题。 diff --git a/docs/MDTODO/details/personal-job-search/R1.2_Task_Report.md b/docs/MDTODO/details/personal-job-search/R1.2_Task_Report.md new file mode 100644 index 00000000..035379f2 --- /dev/null +++ b/docs/MDTODO/details/personal-job-search/R1.2_Task_Report.md @@ -0,0 +1,26 @@ +# R1.2 任务报告 + +## 调查范围 + +- 上海 AI、机器人、汽车智能化、嵌入式和系统软件公开岗位。 +- 资料收集由 `gpt-5.6-luna`、低思考等级子代理执行。 +- 子任务及原始资料:。 + +## 核验结论 + +- 第一批保留: + - Apple Embedded Software Engineer `200644032-3715`,核心要求为 C/C++、ARM、驱动和通信协议,公开页未显示明确年限硬门槛; + - 华为上海 AI 底层软件栈岗位 `jobId 32189`,投递前先确认 HC、校招或社招资格和页面有效性。 +- 第二批或挑战岗: + - NVIDIA Isaac Lab `JR2013813` 仍有约 3 年机器学习或仿真基础设施要求; + - NVIDIA Senior Isaac Lab 明确 4 年以上要求; + - Apple Senior ML/AI 明确约 8 年经验要求。 +- 观察池: + - 小米当前主要是动态机会池; + - Tesla、蔚来尚无可稳定复核的当前上海具体岗位证据。 + +## 主代理复核 + +- 纠正了子代理原始结果中对 Senior、高年限岗位的过高排序。 +- 未修改源码、未创建 PR。 +- 子代理反馈的 CLI 参数疑问不属于本次求职资料范围,未扩展为工程支线。 diff --git a/docs/MDTODO/details/personal-job-search/R1_Task_Report.md b/docs/MDTODO/details/personal-job-search/R1_Task_Report.md new file mode 100644 index 00000000..542dd73a --- /dev/null +++ b/docs/MDTODO/details/personal-job-search/R1_Task_Report.md @@ -0,0 +1,36 @@ +# R1 任务报告 + +## 完成结果 + +- 使用两个 `gpt-5.6-luna`、低思考等级子代理并行收集上海大公司岗位资料。 +- 主任务:。 +- 主代理复核:。 +- 全程只处理公开招聘资料,没有在 GitHub issue 中写入候选人隐私信息。 + +## 最终岗位分层 + +- 第一批: + - Apple Embedded Software Engineer; + - 采埃孚 Algorithm Engineer; + - 施耐德供应链规划数字化转型负责人; + - 华为上海 AI 底层软件栈岗位,先确认 HC 和资格。 +- 第二批或挑战岗: + - NVIDIA Isaac Lab; + - 博世 IoT Computing 或强化学习研究岗位; + - 施耐德电子硬件设计岗位。 +- 观察池: + - Rockwell、GE、ABB、Siemens; + - 小米、Tesla、蔚来; + - 获得稳定具体岗位页并完成硬门槛核验后才升级。 + +## 执行计划 + +- 2026-07-16 09:00 完成岗位证据矩阵,记录硬门槛、项目证据、简历版本和投递结论。 +- 2026-07-16 13:30 投递第一批岗位,分别使用嵌入式、算法控制、Agent 或工业智能、AI 系统软件简历版本。 +- 2026-07-17 13:30 处理挑战岗,对高年限、博士或顶会门槛明确记录差距,不盲投。 +- 上述节奏已写入 Decision Center 的“找工作”待办及三个子待办。 + +## 交付边界 + +- 本任务没有源码修改或 PR。 +- 两个子代理都完成了任务后复盘,没有选出需要在当前范围推进的工程改进项。 diff --git a/docs/MDTODO/personal-job-search.md b/docs/MDTODO/personal-job-search.md new file mode 100644 index 00000000..d2d02b9a --- /dev/null +++ b/docs/MDTODO/personal-job-search.md @@ -0,0 +1,16 @@ +# 个人求职信息管理 + +- 本文件承载公开岗位调查、岗位证据核验、投递节奏和求职资料维护。 +- 个人敏感材料不进入 GitHub issue、AgentRun prompt 或公开调查报告。 + +## R1 [completed] + +并行调查上海大公司高匹配公开岗位并形成可投递清单,关联 [GitHub issue #2141](https://github.com/pikasTech/unidesk/issues/2141),完成任务后将详细报告写入[任务报告](./details/personal-job-search/R1_Task_Report.md)。 + +### R1.1 [completed] + +使用 `gpt-5.6-luna` 低思考等级核验上海工业自动化、能源、电气与大型外企岗位,关联 [GitHub issue #2143](https://github.com/pikasTech/unidesk/issues/2143),完成任务后将详细报告写入[任务报告](./details/personal-job-search/R1.1_Task_Report.md)。 + +### R1.2 [completed] + +使用 `gpt-5.6-luna` 低思考等级核验上海 AI、机器人、汽车智能化与系统软件岗位,关联 [GitHub issue #2142](https://github.com/pikasTech/unidesk/issues/2142),完成任务后将详细报告写入[任务报告](./details/personal-job-search/R1.2_Task_Report.md)。 From da5fe0bfe6e616120d7a2c76f6c65c752b57d224 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 09:01:13 +0200 Subject: [PATCH 24/26] fix(pac): skip shared repository reapply for secondary consumers --- scripts/src/platform-infra-pipelines-as-code-remote.sh | 6 +++++- scripts/src/platform-infra-pipelines-as-code.ts | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index dc8ded1c..4d8b8c6f 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -345,7 +345,11 @@ apply_action() { { printf '%s' "$token"; printf '\0'; printf '%s' "$UNIDESK_PAC_WEBHOOK_SECRET"; } \ | pac_repository_secret_manifest \ | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-repository-secret" -f - >/dev/null - repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null + if [ "${UNIDESK_PAC_REPOSITORY_APPLY:-1}" = "1" ]; then + repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null + else + kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get repository.pipelinesascode.tekton.dev "$UNIDESK_PAC_REPOSITORY_NAME" >/dev/null + fi argo_bootstrap=false if [ -n "${UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64:-}" ]; then if [ -n "${UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME:-}" ]; then diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 4a183e26..90e3fa83 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -1722,6 +1722,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac const admissionIdentity = pacAdmissionDesiredIdentity(target.id); const rbacIdentity = pacConsumerRbacDesiredIdentity(target.id); const consumerConfig = historyConsumerConfig(pac, consumer); + const repositoryOwner = pac.consumers.find((item) => item.repositoryRef === repository.id); const env: Record = { UNIDESK_PAC_ACTION: action, UNIDESK_PAC_EVALUATOR_B64: Buffer.from(readFileSync(evaluatorFile, "utf8"), "utf8").toString("base64"), @@ -1743,6 +1744,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac UNIDESK_PAC_WEBHOOK_URL: webhookUrl, UNIDESK_PAC_WEBHOOK_SECRET: secrets.webhookSecret, UNIDESK_PAC_REPOSITORY_NAME: repository.name, + UNIDESK_PAC_REPOSITORY_APPLY: repositoryOwner?.id === consumer.id ? "1" : "0", UNIDESK_PAC_REPOSITORY_URL: repository.url, UNIDESK_PAC_SECRET_NAME: repository.secretName, UNIDESK_PAC_TOKEN_KEY: repository.tokenKey, From 4d5e1e1933cb7d541b57f78b60302c381d79eae5 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 09:06:25 +0200 Subject: [PATCH 25/26] =?UTF-8?q?docs(sub2api):=20=E5=9B=BA=E5=8C=96?= =?UTF-8?q?=E5=8D=95=E8=B4=A6=E5=8F=B7=E8=B0=83=E4=BC=98=E6=9C=80=E7=9F=AD?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unidesk-sub2api/references/codex-pool.md | 9 ++++++ .../R2.11_Task_Report.md | 28 +++++++++++++++++++ docs/MDTODO/sub2api-upstream-reliability.md | 3 ++ 3 files changed, 40 insertions(+) create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R2.11_Task_Report.md diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index a3c86904..36d9256b 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -189,6 +189,15 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - 账号人类可读名称使用 `runtime apply --kind account-name --name `: - 只接受单个精确账号 selector,先 dry-run,确认后自动回读名称; - 只更新名称,不携带 credentials、priority、分组、容量、代理、状态或可调度字段。 +- 单账号成本标注与优先级调优使用以下最短路径: + - 已知账号 selector 时,首个诊断入口固定为: + `runtime errors --target --since --group --account `; + - 同一次账号级查询直接读取评分、可归因上游失败率、TTFT、用量、Token、标准 API 计费基数和当前名称成本标注; + - 只有需要确认 priority、capacity、schedulable、proxy 或临时不可调度规则时,才补一次 `runtime get --account `; + - 只有需要跨账号比较时才使用全组 `runtime errors --group `;单账号任务不得先跑全组大报表,再重复账号级查询; + - priority 数值越小越优先。成本只参与人工经济性判断,必须同时考虑可靠性、TTFT、容量、可调度状态和样本置信度,不得按成本自动排序; + - 名称成本标注和 priority 是两个独立 runtime 字段:分别先 dry-run,再顺序 confirm;禁止并发更新同一账号,避免整对象更新互相覆盖; + - 两项 confirm 已各自回读一致后,最后只执行一次 `runtime get` 验收完整状态;不重复 confirm,不用全组查询代替账号回读。 - 模型映射先用 `runtime models --account ...` 获取原生配置与实时上游能力: - 只有实时模型列表或真实模型请求证明支持时才确认写入。 - 禁止把名称相近的模型猜测为别名。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.11_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.11_Task_Report.md new file mode 100644 index 00000000..a7416308 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.11_Task_Report.md @@ -0,0 +1,28 @@ +# R2.11 任务报告 + +## 改进目标 + +关联 UniDesk #2144,将单账号成本标注与优先级调优从“先全组聚合、再账号下钻”的重复流程收敛为账号级首入口,减少无关数据聚合、输出预算触发和重复查询。 + +## 固化路径 + +Sub2API skill 的账号池权威 reference 已明确: + +- 已知账号 selector 时,首次调用 `runtime errors --target --since --group --account `。 +- 一次读取账号评分、可归因上游失败率、TTFT、用量、Token、标准 API 计费基数和名称成本标注。 +- 仅在需要当前 priority、capacity、schedulable、proxy 或临时不可调度规则时补一次 `runtime get`。 +- 全组查询仅用于跨账号比较;单账号任务不再以全组大报表作为前置。 +- 名称与 priority 分别 dry-run、顺序 confirm,最后一次 `runtime get` 验收,禁止并发更新同一账号。 +- 成本不自动决定 priority,仍需结合可靠性、TTFT、容量、可调度状态和样本置信度人工判断。 + +## 验证 + +- 使用参数化账号级命令完成 1 小时只读 smoke,单次返回评分、失败归因、TTFT、用量、Token 和成本。 +- smoke 未调用独立 usage、profit 或全组大报表,未触发 stdout 预算保护。 +- `quick_validate.py .agents/skills/unidesk-sub2api` 通过。 +- `git diff --check` 通过。 +- 本改进仅修改 skill reference 与 MDTODO,不修改 Sub2API 运行面。 + +## 结论 + +同类单账号调优的必要查询从“全组诊断加账号诊断加 runtime 读取”收敛为“账号诊断,按需补 runtime 读取”;写入和最终验收仍保持受控 dry-run、顺序 confirm 与回读边界。 diff --git a/docs/MDTODO/sub2api-upstream-reliability.md b/docs/MDTODO/sub2api-upstream-reliability.md index 11199d48..e5fda9b4 100644 --- a/docs/MDTODO/sub2api-upstream-reliability.md +++ b/docs/MDTODO/sub2api-upstream-reliability.md @@ -210,6 +210,9 @@ ### R2.10 [completed] 将 `https://sub.yjxm1221.top/` 对应上游账号的人民币采购成本标注调整为 `0.025`,结合 Sub2API 原生近期账号评分、错误率、TTFT、用量和当前 runtime 优先级适当调整优先级;手工账号使用精准 runtime CRUD,变更前 dry-run、确认写入后逐字段回读对账,不修改 Sub2API 源码、版本或外部哨兵,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.10_Task_Report.md)。 +### R2.11 [completed] + +依据 [UniDesk #2144](https://github.com/pikasTech/unidesk/issues/2144) 将单账号成本标注与优先级调优的最短路径固化到 Sub2API skill:账号级 `runtime errors --group --account --since` 作为评分、可靠性、TTFT、用量和成本首入口,仅在读取当前 runtime 字段时补一次 `runtime get`;全组概要只用于跨账号比较,名称和 priority 分别 dry-run、顺序 confirm、最终单次回读,不修改运行面,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.11_Task_Report.md)。 ## R3 [completed] 增加 Sub2API 客户余额精准批量充值 CLI:支持从原生最近24小时用量提取活跃用户并生成每人增加20美元的补偿计划,也支持显式用户ID或账号 selector;默认 dry-run 输出完整账号、用户ID、当前余额、增额和目标余额,写入必须显式 --confirm 并自动回读对账。本轮只实现并验证 dry-run,禁止实际充值,必须等待用户再次明确授权,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R3_Task_Report.md)。 From 0a8e6cf4672e8656d0848182decafbf74b484299 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 09:05:38 +0200 Subject: [PATCH 26/26] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20D518=20Qwen?= =?UTF-8?q?=20=E8=AF=AD=E9=9F=B3=E5=AE=9E=E9=AA=8C=E7=BB=93=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R9.1_Task_Report.md | 32 +++++++++++++++++++ docs/MDTODO/selfmedia-production-delivery.md | 5 ++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R9.1_Task_Report.md diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R9.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R9.1_Task_Report.md new file mode 100644 index 00000000..1ee98751 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R9.1_Task_Report.md @@ -0,0 +1,32 @@ +# R9.1 D518 Qwen3-TTS 0.6B 临时 Docker GPU 实验报告 + +## 结论 + +实验按预设停止边界完成,未解锁正式部署。Qwen3-TTS 模型和 NGC 基础镜像均成功获取,临时镜像也完成构建;容器首次启动在导入阶段因 Torch/TorchAudio CUDA ABI 不兼容退出,尚未进入模型加载,因此没有把该候选用于 6 GiB RTX 2060 的适配结论外推为成功或显存失败。 + +## 锁定身份 + +- Qwen 源码 ref:022e286b98fbec7e1e916cb940cdf532cd9f488e。 +- ModelScope 模型 ref:70b274883e68023af521a9199603192d58ccdd3f。 +- Python 包:qwen-tts 0.1.1。 +- 基础镜像:nvcr.io/nvidia/pytorch:25.05-py3。 +- 基础镜像 digest:sha256:a0b4dc04f3ca1e539a5c6ea06f79bc45a33d5acd5cbf87dce7910365a924227c。 +- 临时镜像:sha256:2019d93f4d55915c7ca388f6dfce13decda61417fd66dd10fd727a963e2e8ab9。 +- 低显存参数:CUDA 0、float16、SDPA。 + +## 现场证据 + +- D518 Docker、NVIDIA runtime 和 RTX 2060 6144 MiB 可用。 +- 官方 ModelScope 权重 14/14 文件完成,缓存约 2.4 GiB。 +- NGC 镜像 59 个层完成,临时 Docker 镜像构建成功。 +- 首次容器启动报缺少 torchaudio;加入官方 torchaudio 2.8.0 后确认其与 NGC Torch 2.8.0a0+5228986c39.nv25.05 的 libtorchaudio CUDA ABI 不兼容。 +- 容器 OOMKilled=false,GPU 仍为约 780 MiB 基线;失败发生在模型加载之前。 +- health、冷/热请求、WAV 与 ffprobe 因服务未就绪未执行。 + +## 清理与接力 + +- 临时容器、自定义网络、临时构建镜像和 NGC 基础镜像均已清理。 +- 保留 D518 /tmp/selfmedia-voice-r9-1/model 的模型缓存及有界日志证据。 +- 子 issue 最终证据:https://github.com/pikasTech/unidesk/issues/2133#issuecomment-4977796868 +- 下一候选已登记为 R9.1.1 / UniDesk #2148:CosyVoice3 0.5B 使用稳定匹配的 Torch/TorchAudio 组合继续验证。 +- 正式 YAML-first apply、FRP、公网和 SelfMedia 接入仍保持锁定。 diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md index 6a6edba8..cf350f68 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -100,10 +100,13 @@ 依据 [UniDesk #2132](https://github.com/pikasTech/unidesk/issues/2132) 在 D518 部署先进中文语音推理服务并接入 SELFMEDIA 开发集群:先在 D518 临时目录以 Docker GPU smoke 验证 Qwen3-TTS 0.6B CustomVoice,成功后由 owning YAML 和受控 CLI 固化 D518 推理容器、NC01 FRPS、D518 FRPC、Secret sourceRef 与公网直连端口,再扩展 SelfMedia 远程 TTS 引擎并通过正常 master 自动交付在 4317 完成 voice 单步 smoke;禁止修改 release、生产 namespace/4319、手工长期运维或引入 Caddy,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9_Task_Report.md)。 -### R9.1 [in_progress] +### R9.1 [completed] 依据 [UniDesk #2133](https://github.com/pikasTech/unidesk/issues/2133) 由 Artificer 经 trans 在 D518 /tmp 完成 Qwen3-TTS 0.6B CustomVoice Docker GPU smoke:锁定官方上游 ref、镜像/构建、低显存参数和 health/speech API,以固定中文技术资讯文本验证首次与热调用、WAV 解码、实时率和峰值显存;成功后清理临时容器并留下可接力证据,失败则在一次官方低显存参数尝试后停止且不无界更换模型,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.1_Task_Report.md)。 +#### R9.1.1 [in_progress] + +依据 [UniDesk #2148](https://github.com/pikasTech/unidesk/issues/2148) 在 Qwen3-TTS 因 Torch/TorchAudio ABI 不兼容停止后,由 Artificer 经 trans 在 D518 /tmp 验证下一候选 CosyVoice3 0.5B:锁定官方 source/model ref 和 Apache-2.0 许可,使用稳定匹配的 Torch/TorchAudio Docker GPU 组合与本地自有或开源生成的中文参考 WAV,提供 health/speech API并以固定技术资讯文本完成 WAV、实时率和峰值显存 smoke;成功后为 R9.4 提供正式模型身份,失败则记录首个确定阻塞并停止无界试错,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.1.1_Task_Report.md)。 ### R9.2 [in_progress] 依据 [SELFMEDIA #65](https://github.com/pikainc/selfmedia/issues/65) 由 Artificer 为 voice 单步增加 YAML 显式选择的 remote-openai-speech 自托管引擎:基础地址只从 YAML 声明的环境变量读取,模型、音色、指令、语速和引擎进入缓存身份,cache miss 调用 /v1/audio/speech 并原子写入 WAV,保留现有分段、edition 覆盖、FFmpeg 语速/响度和 timeline,禁止硬编码公网端口或静默回退 Kokoro,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R9.2_Task_Report.md)。