From 8374757b1fda879b39261126acf7466cf3c47621 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 04:17:13 +0200 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=E9=85=8D=E7=BD=AE=20Webterm=20?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=81=A2=E5=A4=8D=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/webterm.yaml | 11 +++++++++++ scripts/src/platform-infra-webterm.ts | 25 ++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml index b1cb181d..874b61da 100644 --- a/config/platform-infra/webterm.yaml +++ b/config/platform-infra/webterm.yaml @@ -34,6 +34,17 @@ targets: - source: /root/webterm/scripts/host-shell.sh target: /usr/local/bin/host-shell readOnly: true + autoStartSessions: + - title: 决策中心 + cwd: /root/unidesk + command: mycx resume 019f4937-9820-7b31-9178-344ae93d5a4c + cols: 100 + rows: 30 + - title: 71-FILTER + cwd: /root/unidesk + command: mycx resume 019f4b5f-b5ce-76b3-8ccb-508790e9662d + cols: 100 + rows: 30 environment: HOST: 0.0.0.0 PORT: "7681" diff --git a/scripts/src/platform-infra-webterm.ts b/scripts/src/platform-infra-webterm.ts index 495e366e..3893dc7d 100644 --- a/scripts/src/platform-infra-webterm.ts +++ b/scripts/src/platform-infra-webterm.ts @@ -41,6 +41,7 @@ interface WebtermTarget { privileged: boolean; pid: "host" | "container"; sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>; + autoStartSessions: Array<{ title: string; cwd: string; command: string; cols: number; rows: number }>; environment: Record; }; publicExposure: { @@ -252,6 +253,7 @@ function parseTarget(record: Record, path: string): WebtermTarg const pk01 = recordField(exposure, "pk01", `${path}.publicExposure`); const environment = parseEnvironment(recordField(runtime, "environment", `${path}.runtime`), `${path}.runtime.environment`); const sourceMounts = parseSourceMounts(runtime.sourceMounts, `${path}.runtime.sourceMounts`); + const autoStartSessions = parseAutoStartSessions(runtime.autoStartSessions, `${path}.runtime.autoStartSessions`); const target: WebtermTarget = { id: stringField(record, "id", path), enabled: booleanField(record, "enabled", path), @@ -269,6 +271,7 @@ function parseTarget(record: Record, path: string): WebtermTarg privileged: booleanField(runtime, "privileged", `${path}.runtime`), pid: parsePid(stringField(runtime, "pid", `${path}.runtime`), `${path}.runtime.pid`), sourceMounts, + autoStartSessions, environment, }, publicExposure: { @@ -306,7 +309,11 @@ function resolveTarget(targetId: string | null): WebtermTarget { } function renderCompose(target: WebtermTarget): string { - const envLines = Object.entries(target.runtime.environment) + const environment = { + ...target.runtime.environment, + AUTO_START_SESSIONS_JSON: JSON.stringify(target.runtime.autoStartSessions), + }; + const envLines = Object.entries(environment) .map(([key, value]) => ` ${key}: ${quoteYaml(value)}`) .join("\n"); const volumeLines = target.runtime.sourceMounts @@ -378,6 +385,7 @@ function targetSummary(target: WebtermTarget): Record { composeProject: target.runtime.composeProject, containerName: target.runtime.containerName, image: target.runtime.image, + autoStartSessions: target.runtime.autoStartSessions.map((session) => session.title), sourceMounts: target.runtime.sourceMounts.map((mount) => `${mount.source}->${mount.target}${mount.readOnly ? ":ro" : ""}`), hostPort: target.runtime.hostPort, containerPort: target.runtime.containerPort, @@ -481,6 +489,21 @@ function parseSourceMounts(value: unknown, path: string): WebtermTarget["runtime }); } +function parseAutoStartSessions(value: unknown, path: string): WebtermTarget["runtime"]["autoStartSessions"] { + if (!Array.isArray(value)) throw new Error(`${path} must be an array`); + return value.map((item, index) => { + const itemPath = `${path}[${index}]`; + const record = asRecord(item, itemPath); + return { + title: stringField(record, "title", itemPath), + cwd: stringField(record, "cwd", itemPath), + command: stringField(record, "command", itemPath), + cols: integerField(record, "cols", itemPath), + rows: integerField(record, "rows", itemPath), + }; + }); +} + function parseLiteral(value: string, expected: T, path: string): T { if (value !== expected) throw new Error(`${path} must be ${expected}`); return expected; From fdd55ade335912bae50f3bdee8671491cc3b76ab Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 04:38:53 +0200 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=E9=85=8D=E7=BD=AE=20Webterm=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=90=AF=E5=8A=A8=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/webterm.yaml | 15 +++++++++- scripts/src/platform-infra-webterm.ts | 43 +++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml index 874b61da..4479a82a 100644 --- a/config/platform-infra/webterm.yaml +++ b/config/platform-infra/webterm.yaml @@ -5,6 +5,19 @@ metadata: id: webterm-public owner: unidesk +sessionStartModes: + defaultMode: mycx + options: + bash: + label: Bash + command: exec bash -l + mycx: + label: mycx + command: mycx + oncx: + label: oncx + command: oncx + defaults: targetId: local-7683 @@ -40,7 +53,7 @@ targets: command: mycx resume 019f4937-9820-7b31-9178-344ae93d5a4c cols: 100 rows: 30 - - title: 71-FILTER + - title: CONSTAR cwd: /root/unidesk command: mycx resume 019f4b5f-b5ce-76b3-8ccb-508790e9662d cols: 100 diff --git a/scripts/src/platform-infra-webterm.ts b/scripts/src/platform-infra-webterm.ts index 3893dc7d..3830cdbc 100644 --- a/scripts/src/platform-infra-webterm.ts +++ b/scripts/src/platform-infra-webterm.ts @@ -21,9 +21,15 @@ const serviceName = "webterm"; interface WebtermConfig { defaults: { targetId: string }; + sessionStartModes: SessionStartModes; targets: WebtermTarget[]; } +interface SessionStartModes { + defaultMode: string; + options: Array<{ id: string; label: string; command: string }>; +} + interface WebtermTarget { id: string; enabled: boolean; @@ -42,6 +48,7 @@ interface WebtermTarget { pid: "host" | "container"; sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>; autoStartSessions: Array<{ title: string; cwd: string; command: string; cols: number; rows: number }>; + sessionStartModes: SessionStartModes; environment: Record; }; publicExposure: { @@ -237,15 +244,17 @@ function validate(targetId: string | null): Record { function readWebtermConfig(): WebtermConfig { const yaml = readYamlRecord(configPath, "platform-infra-webterm"); const defaults = recordField(yaml, "defaults", "webterm"); + const sessionStartModes = parseSessionStartModes(recordField(yaml, "sessionStartModes", "webterm"), "webterm.sessionStartModes"); const targetsRaw = yaml.targets; if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array"); return { defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") }, - targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`)), + sessionStartModes, + targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`, sessionStartModes)), }; } -function parseTarget(record: Record, path: string): WebtermTarget { +function parseTarget(record: Record, path: string, sessionStartModes: SessionStartModes): WebtermTarget { const runtime = recordField(record, "runtime", path); const exposure = recordField(record, "publicExposure", path); const dns = recordField(exposure, "dns", `${path}.publicExposure`); @@ -272,6 +281,7 @@ function parseTarget(record: Record, path: string): WebtermTarg pid: parsePid(stringField(runtime, "pid", `${path}.runtime`), `${path}.runtime.pid`), sourceMounts, autoStartSessions, + sessionStartModes, environment, }, publicExposure: { @@ -312,6 +322,8 @@ function renderCompose(target: WebtermTarget): string { const environment = { ...target.runtime.environment, AUTO_START_SESSIONS_JSON: JSON.stringify(target.runtime.autoStartSessions), + SESSION_START_MODES_JSON: JSON.stringify(target.runtime.sessionStartModes.options), + SESSION_START_DEFAULT_MODE: target.runtime.sessionStartModes.defaultMode, }; const envLines = Object.entries(environment) .map(([key, value]) => ` ${key}: ${quoteYaml(value)}`) @@ -386,6 +398,10 @@ function targetSummary(target: WebtermTarget): Record { containerName: target.runtime.containerName, image: target.runtime.image, autoStartSessions: target.runtime.autoStartSessions.map((session) => session.title), + sessionStartModes: { + defaultMode: target.runtime.sessionStartModes.defaultMode, + options: target.runtime.sessionStartModes.options.map((mode) => mode.id), + }, sourceMounts: target.runtime.sourceMounts.map((mount) => `${mount.source}->${mount.target}${mount.readOnly ? ":ro" : ""}`), hostPort: target.runtime.hostPort, containerPort: target.runtime.containerPort, @@ -504,6 +520,29 @@ function parseAutoStartSessions(value: unknown, path: string): WebtermTarget["ru }); } +function parseSessionStartModes(record: Record, path: string): SessionStartModes { + const defaultMode = stringField(record, "defaultMode", path); + const optionsRecord = recordField(record, "options", path); + const options = Object.entries(optionsRecord).map(([id, item]) => { + const itemPath = `${path}.options.${id}`; + const option = asRecord(item, itemPath); + return { + id, + label: stringField(option, "label", itemPath), + command: commandField(option, itemPath), + }; + }); + if (options.length === 0) throw new Error(`${path}.options must be a non-empty record`); + if (!options.some((option) => option.id === defaultMode)) throw new Error(`${path}.defaultMode must reference an option id`); + return { defaultMode, options }; +} + +function commandField(record: Record, path: string): string { + const value = record.command; + if (typeof value !== "string") throw new Error(`${path}.command must be a string`); + return value.trim(); +} + function parseLiteral(value: string, expected: T, path: string): T { if (value !== expected) throw new Error(`${path} must be ${expected}`); return expected; From c5bfd97656a170f3bd226ccded41335683c3276d Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 04:59:39 +0200 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20=E9=85=8D=E7=BD=AE=20Webterm=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=81=A2=E5=A4=8D=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/webterm.yaml | 13 ++++++++ scripts/src/platform-infra-webterm.ts | 45 +++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml index 4479a82a..1aadc69f 100644 --- a/config/platform-infra/webterm.yaml +++ b/config/platform-infra/webterm.yaml @@ -18,6 +18,17 @@ sessionStartModes: label: oncx command: oncx +autoResumePrompt: + enabled: true + prompt: "[自动提醒]如果有未完成任务则继续,没有则直接结束" + sendEnter: true + enterDelayMs: 500 + readiness: + minDelayMs: 2000 + minOutputBytes: 64 + quietPeriodMs: 1500 + timeoutMs: 60000 + defaults: targetId: local-7683 @@ -53,11 +64,13 @@ targets: command: mycx resume 019f4937-9820-7b31-9178-344ae93d5a4c cols: 100 rows: 30 + resumePrompt: true - title: CONSTAR cwd: /root/unidesk command: mycx resume 019f4b5f-b5ce-76b3-8ccb-508790e9662d cols: 100 rows: 30 + resumePrompt: true environment: HOST: 0.0.0.0 PORT: "7681" diff --git a/scripts/src/platform-infra-webterm.ts b/scripts/src/platform-infra-webterm.ts index 3830cdbc..f2374746 100644 --- a/scripts/src/platform-infra-webterm.ts +++ b/scripts/src/platform-infra-webterm.ts @@ -22,6 +22,7 @@ const serviceName = "webterm"; interface WebtermConfig { defaults: { targetId: string }; sessionStartModes: SessionStartModes; + autoResumePrompt: AutoResumePrompt; targets: WebtermTarget[]; } @@ -30,6 +31,14 @@ interface SessionStartModes { options: Array<{ id: string; label: string; command: string }>; } +interface AutoResumePrompt { + enabled: boolean; + prompt: string; + sendEnter: boolean; + enterDelayMs: number; + readiness: { minDelayMs: number; minOutputBytes: number; quietPeriodMs: number; timeoutMs: number }; +} + interface WebtermTarget { id: string; enabled: boolean; @@ -47,8 +56,9 @@ interface WebtermTarget { privileged: boolean; pid: "host" | "container"; sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>; - autoStartSessions: Array<{ title: string; cwd: string; command: string; cols: number; rows: number }>; + autoStartSessions: Array<{ title: string; cwd: string; command: string; cols: number; rows: number; resumePrompt: boolean }>; sessionStartModes: SessionStartModes; + autoResumePrompt: AutoResumePrompt; environment: Record; }; publicExposure: { @@ -245,16 +255,18 @@ function readWebtermConfig(): WebtermConfig { const yaml = readYamlRecord(configPath, "platform-infra-webterm"); const defaults = recordField(yaml, "defaults", "webterm"); const sessionStartModes = parseSessionStartModes(recordField(yaml, "sessionStartModes", "webterm"), "webterm.sessionStartModes"); + const autoResumePrompt = parseAutoResumePrompt(recordField(yaml, "autoResumePrompt", "webterm"), "webterm.autoResumePrompt"); const targetsRaw = yaml.targets; if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array"); return { defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") }, sessionStartModes, - targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`, sessionStartModes)), + autoResumePrompt, + targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`, sessionStartModes, autoResumePrompt)), }; } -function parseTarget(record: Record, path: string, sessionStartModes: SessionStartModes): WebtermTarget { +function parseTarget(record: Record, path: string, sessionStartModes: SessionStartModes, autoResumePrompt: AutoResumePrompt): WebtermTarget { const runtime = recordField(record, "runtime", path); const exposure = recordField(record, "publicExposure", path); const dns = recordField(exposure, "dns", `${path}.publicExposure`); @@ -282,6 +294,7 @@ function parseTarget(record: Record, path: string, sessionStart sourceMounts, autoStartSessions, sessionStartModes, + autoResumePrompt, environment, }, publicExposure: { @@ -324,6 +337,7 @@ function renderCompose(target: WebtermTarget): string { AUTO_START_SESSIONS_JSON: JSON.stringify(target.runtime.autoStartSessions), SESSION_START_MODES_JSON: JSON.stringify(target.runtime.sessionStartModes.options), SESSION_START_DEFAULT_MODE: target.runtime.sessionStartModes.defaultMode, + AUTO_RESUME_PROMPT_JSON: JSON.stringify(target.runtime.autoResumePrompt), }; const envLines = Object.entries(environment) .map(([key, value]) => ` ${key}: ${quoteYaml(value)}`) @@ -402,6 +416,14 @@ function targetSummary(target: WebtermTarget): Record { defaultMode: target.runtime.sessionStartModes.defaultMode, options: target.runtime.sessionStartModes.options.map((mode) => mode.id), }, + autoResumePrompt: { + enabled: target.runtime.autoResumePrompt.enabled, + taggedSessions: target.runtime.autoStartSessions.filter((session) => session.resumePrompt).map((session) => session.title), + readiness: target.runtime.autoResumePrompt.readiness, + promptBytes: Buffer.byteLength(target.runtime.autoResumePrompt.prompt), + sendEnter: target.runtime.autoResumePrompt.sendEnter, + enterDelayMs: target.runtime.autoResumePrompt.enterDelayMs, + }, sourceMounts: target.runtime.sourceMounts.map((mount) => `${mount.source}->${mount.target}${mount.readOnly ? ":ro" : ""}`), hostPort: target.runtime.hostPort, containerPort: target.runtime.containerPort, @@ -516,6 +538,7 @@ function parseAutoStartSessions(value: unknown, path: string): WebtermTarget["ru command: stringField(record, "command", itemPath), cols: integerField(record, "cols", itemPath), rows: integerField(record, "rows", itemPath), + resumePrompt: booleanField(record, "resumePrompt", itemPath), }; }); } @@ -543,6 +566,22 @@ function commandField(record: Record, path: string): string { return value.trim(); } +function parseAutoResumePrompt(record: Record, path: string): AutoResumePrompt { + const readiness = recordField(record, "readiness", path); + return { + enabled: booleanField(record, "enabled", path), + prompt: stringField(record, "prompt", path), + sendEnter: booleanField(record, "sendEnter", path), + enterDelayMs: integerField(record, "enterDelayMs", path), + readiness: { + minDelayMs: integerField(readiness, "minDelayMs", `${path}.readiness`), + minOutputBytes: integerField(readiness, "minOutputBytes", `${path}.readiness`), + quietPeriodMs: integerField(readiness, "quietPeriodMs", `${path}.readiness`), + timeoutMs: integerField(readiness, "timeoutMs", `${path}.readiness`), + }, + }; +} + function parseLiteral(value: string, expected: T, path: string): T { if (value !== expected) throw new Error(`${path} must be ${expected}`); return expected; From b01cf0dd61c031e38f546c03f9a1265592f7114e Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 05:01:29 +0200 Subject: [PATCH 04/13] =?UTF-8?q?docs:=20=E5=9B=BA=E5=8C=96=20worktree=20?= =?UTF-8?q?=E7=9F=AD=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E5=8E=9F=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 94e3ee5d..1f85f724 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,9 @@ - P0: `/root/unidesk` 主 worktree 规则: - 固定停留在 `master`; - 未预期修改默认属于并行任务,禁止 reset/checkout/删除; + - 任务 worktree 只允许短生命周期使用,不得作为长期开发分支或常驻工作区; + - 任务提交语义进入 `master` 后,必须立即核查并清理对应 worktree 和已合并本地分支; + - 存在未提交修改、未吸收提交或语义不确定时禁止强行清理; - 非 `master` 开发、语义合并和 worktree 清理由 `docs/reference/devops-hygiene.md` 约束。 - P0: Master server 构建规则: - 禁止通用 Docker/Rust/Go/前端重型构建; From 5549333bc84f02c1e4f04ce7e7409116d3e3da4f Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 05:23:15 +0200 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=E9=85=8D=E7=BD=AE=20Webterm=20?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E9=9D=99=E9=BB=98=E9=98=88=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/webterm.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml index 1aadc69f..426ee14e 100644 --- a/config/platform-infra/webterm.yaml +++ b/config/platform-infra/webterm.yaml @@ -84,6 +84,7 @@ targets: TERMINAL_SNAPSHOT_BYTES: "524288" BROWSER_KEEPALIVE_INTERVAL_MS: "30000" BROWSER_STALE_TIMEOUT_MS: "180000" + BROWSER_SESSION_OUTPUT_IDLE_MS: "60000" publicExposure: enabled: true publicBaseUrl: https://term.pikapython.com From e80168f97c5e5cbf9c0e83b6ee860614cc699602 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 05:34:18 +0200 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=E5=90=AF=E5=8A=A8=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=BC=9A=E8=AF=9D=E6=97=B6=E7=9B=B4=E6=8E=A5=E4=BC=A0?= =?UTF-8?q?=E5=85=A5=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/webterm.yaml | 4 ++-- scripts/src/platform-infra-webterm.ts | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml index 426ee14e..bd89db05 100644 --- a/config/platform-infra/webterm.yaml +++ b/config/platform-infra/webterm.yaml @@ -19,7 +19,7 @@ sessionStartModes: command: oncx autoResumePrompt: - enabled: true + enabled: false prompt: "[自动提醒]如果有未完成任务则继续,没有则直接结束" sendEnter: true enterDelayMs: 500 @@ -84,7 +84,7 @@ targets: TERMINAL_SNAPSHOT_BYTES: "524288" BROWSER_KEEPALIVE_INTERVAL_MS: "30000" BROWSER_STALE_TIMEOUT_MS: "180000" - BROWSER_SESSION_OUTPUT_IDLE_MS: "60000" + BROWSER_SESSION_OUTPUT_IDLE_MS: "3000" publicExposure: enabled: true publicBaseUrl: https://term.pikapython.com diff --git a/scripts/src/platform-infra-webterm.ts b/scripts/src/platform-infra-webterm.ts index f2374746..4bf92fc4 100644 --- a/scripts/src/platform-infra-webterm.ts +++ b/scripts/src/platform-infra-webterm.ts @@ -334,7 +334,10 @@ function resolveTarget(targetId: string | null): WebtermTarget { function renderCompose(target: WebtermTarget): string { const environment = { ...target.runtime.environment, - AUTO_START_SESSIONS_JSON: JSON.stringify(target.runtime.autoStartSessions), + AUTO_START_SESSIONS_JSON: JSON.stringify(target.runtime.autoStartSessions.map((session) => ({ + ...session, + command: resumeCommandWithPrompt(session, target.runtime.autoResumePrompt), + }))), SESSION_START_MODES_JSON: JSON.stringify(target.runtime.sessionStartModes.options), SESSION_START_DEFAULT_MODE: target.runtime.sessionStartModes.defaultMode, AUTO_RESUME_PROMPT_JSON: JSON.stringify(target.runtime.autoResumePrompt), @@ -365,6 +368,21 @@ ${envLines} `; } +function resumeCommandWithPrompt( + session: WebtermTarget["runtime"]["autoStartSessions"][number], + prompt: AutoResumePrompt, +): string { + if (!session.resumePrompt) return session.command; + if (!/^\s*(?:mycx|oncx)\s+resume\s+\S+\s*$/.test(session.command)) { + throw new Error(`resumePrompt requires a mycx/oncx resume command: ${session.title}`); + } + return `${session.command.trim()} ${quoteShellArg(prompt.prompt)}`; +} + +function quoteShellArg(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + function policyChecks(target: WebtermTarget, compose: string): Array & { ok: boolean }> { return [ { name: "enabled", ok: target.enabled, detail: "target must be enabled" }, From ef36368502e13f5e851deb4e50de2e235ed5e013 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 05:39:44 +0200 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=E9=85=8D=E7=BD=AE=20Webterm=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/webterm.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml index bd89db05..bb6e1b85 100644 --- a/config/platform-infra/webterm.yaml +++ b/config/platform-infra/webterm.yaml @@ -85,6 +85,8 @@ targets: BROWSER_KEEPALIVE_INTERVAL_MS: "30000" BROWSER_STALE_TIMEOUT_MS: "180000" BROWSER_SESSION_OUTPUT_IDLE_MS: "3000" + FILE_UPLOAD_DIR: /root/user_uploads + FILE_UPLOAD_MAX_BYTES: "104857600" publicExposure: enabled: true publicBaseUrl: https://term.pikapython.com From 49fcddd28c429b01f351838c9a547e2fb7b5cc65 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 05:46:04 +0200 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20=E6=8C=82=E8=BD=BD=20Webterm=20?= =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E4=B8=8A=E4=BC=A0=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/webterm.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml index bb6e1b85..3494841d 100644 --- a/config/platform-infra/webterm.yaml +++ b/config/platform-infra/webterm.yaml @@ -58,6 +58,9 @@ targets: - source: /root/webterm/scripts/host-shell.sh target: /usr/local/bin/host-shell readOnly: true + - source: /root/user_uploads + target: /root/user_uploads + readOnly: false autoStartSessions: - title: 决策中心 cwd: /root/unidesk From d810e854f0c01522c199cc19d45ba40549c7fd38 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 06:23:13 +0200 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20=E9=85=8D=E7=BD=AE=20Webterm=20?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E8=84=89=E5=86=B2=E9=97=B4=E9=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/webterm.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml index 3494841d..5f980961 100644 --- a/config/platform-infra/webterm.yaml +++ b/config/platform-infra/webterm.yaml @@ -88,6 +88,7 @@ targets: BROWSER_KEEPALIVE_INTERVAL_MS: "30000" BROWSER_STALE_TIMEOUT_MS: "180000" BROWSER_SESSION_OUTPUT_IDLE_MS: "3000" + BROWSER_OUTPUT_PULSE_MIN_INTERVAL_MS: "100" FILE_UPLOAD_DIR: /root/user_uploads FILE_UPLOAD_MAX_BYTES: "104857600" publicExposure: From ecd6764931799f321226378a6918496cea3e63d8 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 06:23:13 +0200 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20=E6=94=B9=E8=BF=9B=20Windows=20SSH?= =?UTF-8?q?=20PowerShell=20=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/remote-ssh.ts | 5 +++++ scripts/src/ssh-runtime.ts | 17 +++++++++++++++++ scripts/src/ssh.test.ts | 16 ++++++++++++++++ scripts/src/ssh.ts | 29 +++++++++++++++++++++-------- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/scripts/src/remote-ssh.ts b/scripts/src/remote-ssh.ts index 8cad47fa..9983a545 100644 --- a/scripts/src/remote-ssh.ts +++ b/scripts/src/remote-ssh.ts @@ -24,6 +24,7 @@ import { sshRuntimeTimeoutMs, sshRuntimeTimingHint, sshWindowsPlaneHint, + sshWindowsPowerShellPipelineHint, wrapSshRemoteCommand, type SshCaptureResult, } from "./ssh"; @@ -435,6 +436,8 @@ async function runRemoteSshWebSocket( if (!timedOut) { const windowsPlaneHint = sshWindowsPlaneHint(invocation, code, stderrTail); if (windowsPlaneHint) process.stderr.write(windowsPlaneHint); + const pipelineHint = sshWindowsPowerShellPipelineHint(invocation, code, stderrTail); + if (pipelineHint) process.stderr.write(pipelineHint); } const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ invocation, @@ -615,6 +618,8 @@ async function runRemoteSshWebSocketCaptureRemoteCommand( startedAtMs, })); if (timingHint) stderr += timingHint; + stderr += sshWindowsPlaneHint(invocation, code, stderr); + stderr += sshWindowsPowerShellPipelineHint(invocation, code, stderr); resolve({ exitCode: code, stdout, stderr }); }; const openTimer = setTimeout(() => { diff --git a/scripts/src/ssh-runtime.ts b/scripts/src/ssh-runtime.ts index 6a762edf..57976f5c 100644 --- a/scripts/src/ssh-runtime.ts +++ b/scripts/src/ssh-runtime.ts @@ -117,6 +117,20 @@ export function sshWindowsPlaneHint(invocation: ParsedSshInvocation, exitCode: n })}\n`; } +export function sshWindowsPowerShellPipelineHint(invocation: ParsedSshInvocation, exitCode: number, stderrText: string): string { + if (invocation.route.plane !== "win" || exitCode === 0 || !/An empty pipe element is not allowed/iu.test(stderrText)) return ""; + return `UNIDESK_SSH_HINT ${JSON.stringify({ + code: "windows-powershell-statement-pipeline-boundary", + providerId: safeProviderId(invocation.providerId), + route: invocation.route.raw, + exitCode, + message: "PowerShell cannot pipe a statement such as foreach directly at this syntax boundary.", + action: "Wrap the statement in @(...), then pipe the resulting values, or use a multiline quoted PowerShell heredoc.", + examples: ["@(foreach (...) { ... }) | Format-List", "trans ps <<'PS'"], + note: "This hint intentionally does not echo the submitted PowerShell source.", + })}\n`; +} + export function sshSlowWarningThresholdMs(env: NodeJS.ProcessEnv = process.env): number { const raw = env.UNIDESK_SSH_SLOW_WARNING_MS; const parsed = raw === undefined ? NaN : Number(raw); @@ -1015,6 +1029,7 @@ async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: Par stderrText: stderr, })); stderr += sshWindowsPlaneHint(invocation, exitCode, stderr); + stderr += sshWindowsPowerShellPipelineHint(invocation, exitCode, stderr); resolve({ exitCode, stdout, stderr }); }; const runtimeTimer = setTimeout(() => { @@ -1538,6 +1553,8 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st if (!timedOut) { const windowsPlaneHint = sshWindowsPlaneHint(invocation, exitCode, stderrTail); if (windowsPlaneHint) process.stderr.write(windowsPlaneHint); + const pipelineHint = sshWindowsPowerShellPipelineHint(invocation, exitCode, stderrTail); + if (pipelineHint) process.stderr.write(pipelineHint); } if (!timedOut) { const tcpPoolHint = formatSshTcpPoolHint(sshTcpPoolHint({ diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index b28779c8..eca36817 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -18,6 +18,7 @@ import { sshStdoutStreamMaxBytes, sshStdoutTruncationHint, sshWindowsPlaneHint, + sshWindowsPowerShellPipelineHint, windowsFsReadOnlyScript, windowsPowerShellScriptPrelude, } from "./ssh"; @@ -95,10 +96,25 @@ describe("ssh windows fs read-only operations", () => { expect(decoded).toContain("update-ref"); expect(decoded).toContain("unselected worktree/index fingerprint"); expect(decoded).toContain("old-value CAS"); + expect(decoded).toContain("$ErrorActionPreference='Continue'"); + expect(decoded).toContain("2>&1"); + expect(decoded).toContain("Management.Automation.ErrorRecord"); + expect(decoded).toContain("warnings=@($gitWarnings)"); + expect(decoded.trimEnd()).toEndWith("exit 0"); expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "run", "--path", "src/main.c", "--message", "fix: exact"])).toThrow("requires --confirm"); expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "plan", "--path", "../secret"])).toThrow("must stay inside"); }); + test("adds a bounded hint for the PowerShell statement-to-pipeline parser boundary", () => { + const invocation = parseSshInvocation("D601:win/c/test", ["ps", "foreach ($x in $xs) { $x } | Format-List"]); + const hint = sshWindowsPowerShellPipelineHint(invocation, 1, "An empty pipe element is not allowed"); + + expect(hint).toContain("windows-powershell-statement-pipeline-boundary"); + expect(hint).toContain("@(foreach (...) { ... }) | Format-List"); + expect(hint).not.toContain("$x in $xs"); + expect(sshWindowsPowerShellPipelineHint(invocation, 0, "An empty pipe element is not allowed")).toBe(""); + }); + test("builds bounded UTF-8 scripts for cat, ls, and rg", () => { const catScript = windowsFsReadOnlyScript("F:\\Work\\demo", "cat", ["--max-bytes", "4096", "中文.md"]); const lsScript = windowsFsReadOnlyScript("F:\\Work\\demo", "ls", ["-la", "--limit=5"]); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 114af8df..04b000c6 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -657,10 +657,21 @@ Set-Location -LiteralPath ${cwd} $operation=${powerShellSingleQuote(operation)} $paths=@(${pathList}) $message=${powerShellSingleQuote(message)} +$gitWarnings=[Collections.Generic.List[string]]::new() function Invoke-Git([string[]]$GitArgs) { - $output = @(& git @GitArgs 2>&1) - if ($LASTEXITCODE -ne 0) { throw (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine) } - return (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine).Trim() + $previousErrorActionPreference=$ErrorActionPreference + try { + $ErrorActionPreference='Continue' + $records=@(& git @GitArgs 2>&1) + $nativeExitCode=$LASTEXITCODE + $stderr=(($records | Where-Object { $_ -is [Management.Automation.ErrorRecord] } | ForEach-Object { $_.Exception.Message }) -join [Environment]::NewLine).Trim() + $stdout=(($records | Where-Object { $_ -isnot [Management.Automation.ErrorRecord] } | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine).Trim() + if($nativeExitCode -ne 0){throw ((@($stdout,$stderr) | Where-Object { $_ }) -join [Environment]::NewLine)} + if($stderr){[void]$gitWarnings.Add($stderr)} + return $stdout + } finally { + $ErrorActionPreference=$previousErrorActionPreference + } } function Object-Status { $values=@{} @@ -671,13 +682,13 @@ $before=Object-Status $head=Invoke-Git @('rev-parse','--verify','HEAD^{commit}') $branch=Invoke-Git @('symbolic-ref','-q','HEAD') if($operation -eq 'status') { - [ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;objects=$before;safeStop='read-only; no objects were pruned'} | ConvertTo-Json -Depth 8 -Compress + [ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;objects=$before;warnings=@($gitWarnings);safeStop='read-only; no objects were pruned'} | ConvertTo-Json -Depth 8 -Compress exit 0 } if($operation -eq 'gc-auto') { $gcOutput=Invoke-Git @('gc','--auto') $after=Object-Status - [ordered]@{ok=$true;operation=$operation;head=$head;before=$before;after=$after;gcOutput=$gcOutput;safeStop='git gc --auto only; Git recent-object and reflog retention remain authoritative; no direct prune'} | ConvertTo-Json -Depth 8 -Compress + [ordered]@{ok=$true;operation=$operation;head=$head;before=$before;after=$after;gcOutput=$gcOutput;warnings=@($gitWarnings);safeStop='git gc --auto only; Git recent-object and reflog retention remain authoritative; no direct prune'} | ConvertTo-Json -Depth 8 -Compress exit 0 } $gitDir=Invoke-Git @('rev-parse','--path-format=absolute','--git-dir') @@ -693,7 +704,7 @@ try { $parentTree=Invoke-Git @('rev-parse',($head+'^{tree}')) if($tree -eq $parentTree){throw 'selected paths do not change the HEAD tree'} if($operation -eq 'plan') { - [ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;tree=$tree;paths=$paths;tempIndex=$tempIndex;objectsBefore=$before;safeStop='plan only; ref and original index unchanged'} | ConvertTo-Json -Depth 8 -Compress + [ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;tree=$tree;paths=$paths;tempIndex=$tempIndex;objectsBefore=$before;warnings=@($gitWarnings);safeStop='plan only; ref and original index unchanged'} | ConvertTo-Json -Depth 8 -Compress exit 0 } $commit=($message | & git commit-tree $tree -p $head).Trim() @@ -704,12 +715,13 @@ try { $unrelatedAfter=Invoke-Git (@('status','--porcelain=v2','--')+$unrelatedPathspec) if($unrelatedAfter -ne $unrelatedBefore){throw 'unselected worktree/index fingerprint changed after exact commit'} $after=Object-Status - [ordered]@{ok=$true;operation=$operation;oldHead=$head;commit=$commit;tree=$tree;branch=$branch;paths=$paths;tempIndex=$tempIndex;unselectedStateUnchanged=$true;selectedPathsIndexUpdated=$true;objectsBefore=$before;objectsAfter=$after;safeStop='ref updated with old-value CAS; no prune performed'} | ConvertTo-Json -Depth 8 -Compress + [ordered]@{ok=$true;operation=$operation;oldHead=$head;commit=$commit;tree=$tree;branch=$branch;paths=$paths;tempIndex=$tempIndex;unselectedStateUnchanged=$true;selectedPathsIndexUpdated=$true;objectsBefore=$before;objectsAfter=$after;warnings=@($gitWarnings);safeStop='ref updated with old-value CAS; no prune performed'} | ConvertTo-Json -Depth 8 -Compress } finally { Remove-Item Env:GIT_INDEX_FILE -ErrorAction SilentlyContinue Remove-Item -LiteralPath $tempIndex -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath ($tempIndex+'.lock') -Force -ErrorAction SilentlyContinue -}`; +} +exit 0`; } function validateWindowsGitCommitArgs(route: ParsedSshRoute, gitArgs: string[]): void { @@ -2058,6 +2070,7 @@ export { sshTcpPoolHint, sshTruncationCompletionSummary, sshWindowsPlaneHint, + sshWindowsPowerShellPipelineHint, } from "./ssh-runtime"; export type { SshStreamForwarder } from "./ssh-runtime"; export { windowsFsReadOnlyScript } from "./ssh-windows-fs"; From 3830a9bb92bcf9ee21d59a31ad6bd05fc7717096 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 06:24:50 +0200 Subject: [PATCH 11/13] =?UTF-8?q?docs:=20=E5=9B=BA=E5=8C=96=20HWLAB=20?= =?UTF-8?q?=E6=A6=82=E5=BF=B5=E5=B1=95=E7=A4=BA=E5=8E=9F=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/hwlab.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/reference/hwlab.md b/docs/reference/hwlab.md index 1ffd16df..5d42addb 100644 --- a/docs/reference/hwlab.md +++ b/docs/reference/hwlab.md @@ -40,6 +40,36 @@ HWLAB v0.2/v0.3 仓库内 `docs/reference/spec-*`,以及已收编的 `cloud-wo 重大规划型 issue 必须执行 P0 SPEC-first:P0 阶段先在 UniDesk OA `project-management/PJ2026-01/specs/` 维护对应 SPEC,确认 SPEC 编号、上级/关联规格、架构图、数据流图、关键时序图和代码引用规则,再进入后续实现。该 issue 范围内新增或修改的源码文件头部必须标注遵循的 SPEC 编号、短名和实现引用版本;自动生成、vendored、纯配置、锁文件或不能承载注释头的二进制产物可例外,但对应生成器、渲染器或配置入口必须能追溯到 SPEC。issue 正文和评论只承载执行计划、讨论和证据,不替代长期 SPEC。 +## 概念展示与执行可见性 + +- HWLAB 概念展示必须从 UniDesk OA 的总规格、Agent 编排、客户端和硬件池边界出发,不从既有 Web 页面布局反推产品定义。 + - 展示页用于解释稳定产品能力和研发闭环,不承担第二份需求规格。 + - 页面中的任务、工具动作、硬件事实、证据和裁决必须能追溯到 OA 规格中的对应能力。 + - 一次性演示故事、截图、端口、提交和运行记录只保留在 issue 或项目过程材料中。 +- AGENT 必须是研发过程的主语,不能把固定 CI/CD 流水线或代码差异冒充 AGENT 参与过程。 + - 每个关键阶段应展示观察事实、可审计假设、决策摘要、工具调用、输出事实和下一步。 + - 不展示原始思维链,不用拟人化长篇解释替代可验证的行动与证据。 + - 代码差异是 AGENT 行动的结果,只在对应修改节点或检查器中展开。 +- 执行过程优先使用可交互的空间执行图表达。 + - AGENT 的观察、假设、计划、裁决和复诊应作为独立节点。 + - Keil、CMSIS-DAP、UART、ioProbe 等能力应作为 AGENT 选择调用的工具或硬件节点。 + - 节点与连线必须投影排队、运行、通过、失败、阻断和跳过状态。 + - 通过和失败必须形成真实分支;物理验证失败后应生成新假设,并明确回环到下一轮诊断入口。 + - 节点检查器应提供有界输入、决策摘要、工具调用、输出事实、证据引用和必要的代码差异。 +- 真实硬件和数字孪生只负责提供物理现场与测量事实,视觉权重不得压过 AGENT 主舞台。 + - 板卡、调试器、探针、线缆和仪器应保持可辨识的真实硬件关系。 + - 空间波形、设备线框、深度网格和测量 HUD 可以增强混合现实表达,但不能遮挡设备身份或伪造测量结果。 + - AGENT 调用 Flash、UART 或物理测量时,执行节点应与对应设备产生同步状态响应。 +- 证据链必须把任务、Agent Trace、Patch、Build、Flash、UART、Physical 和 Verdict 关联起来。 + - 物理测量通过前不得展示 Aggregate PASS 或允许封存回归 Case。 + - 物理测量失败时必须拒绝封存,展示 Aggregate BLOCKED,并暴露下一诊断目标。 + - 浏览器中的动画、标签和演示状态只能表达预先声明的故事或真实后端事实,不能把本地状态冒充运行面证据。 +- 纯前端概念展示可以使用独立静态目录和轻量 HTTP 服务,但必须保持展示资产与生产运行面解耦。 + - 静态页面不得增加后端接口、伪造 API 响应或引入不必要的框架依赖。 + - 公网展示必须验证 HTML、关键图片和交互资产均可从外部网络读取。 + - 需要持续托管时应使用受监督、可重启的服务管理器;一次性 shell 子进程或普通 `nohup` 不能作为长期在线证据。 + - 验收至少覆盖桌面、紧凑桌面和移动端,无文档级横向溢出、无关键内容重叠、无浏览器控制台错误,并支持 `prefers-reduced-motion`。 + ## DEV 入口 - 当前入口必须从 `config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01` 读取,不从长期文档硬编码推断。 From 1b31c06e93ec8f511bc488e25bc77c5365c87577 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 06:30:19 +0200 Subject: [PATCH 12/13] feat: add controlled PR ready command --- .agents/skills/unidesk-gh/SKILL.md | 1 + docs/MDTODO/github-pr-draft-ready-command.md | 26 +++ scripts/src/gh-pr-ready.test.ts | 157 +++++++++++++++++ scripts/src/gh/default-render.ts | 9 +- scripts/src/gh/help.ts | 4 + scripts/src/gh/index.ts | 11 +- scripts/src/gh/options.ts | 2 +- scripts/src/gh/pr-commands.ts | 10 +- scripts/src/gh/pr-ready.ts | 169 +++++++++++++++++++ scripts/src/gh/types.ts | 1 + 10 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 docs/MDTODO/github-pr-draft-ready-command.md create mode 100644 scripts/src/gh-pr-ready.test.ts create mode 100644 scripts/src/gh/pr-ready.ts diff --git a/.agents/skills/unidesk-gh/SKILL.md b/.agents/skills/unidesk-gh/SKILL.md index e258b1d0..077f5200 100644 --- a/.agents/skills/unidesk-gh/SKILL.md +++ b/.agents/skills/unidesk-gh/SKILL.md @@ -33,6 +33,7 @@ bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --state all --limit 10 bun scripts/cli.ts gh pr review-plan --repo pikasTech/unidesk bun scripts/cli.ts gh pr diff --repo pikasTech/unidesk --file path/to/file [--hunk 1] bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title "标题" --body-stdin --base master --head +bun scripts/cli.ts gh pr ready --repo pikasTech/unidesk [--dry-run] bun scripts/cli.ts gh pr preflight --repo pikasTech/unidesk bun scripts/cli.ts gh pr merge --repo pikasTech/unidesk --merge --delete-branch ``` diff --git a/docs/MDTODO/github-pr-draft-ready-command.md b/docs/MDTODO/github-pr-draft-ready-command.md new file mode 100644 index 00000000..75238c31 --- /dev/null +++ b/docs/MDTODO/github-pr-draft-ready-command.md @@ -0,0 +1,26 @@ +# 补齐 GitHub PR draft 转 ready 受控命令 + +- 来源: +- 触发上下文: + - + - +- 范围: + - 仅新增 `gh pr ready` 受控命令、帮助、测试与 PR 验收。 +- 禁止项: + - 不修改 #1748 分支。 + - 不修改运行面。 + - 不建立第二套 GitHub 认证或 API authority。 + + +## R1 [in_progress] + +闭环 [UniDesk #1749](https://github.com/pikasTech/unidesk/issues/1749):为 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748) 补齐 draft 转 ready 的受控 GitHub CLI,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1_Task_Report.md)。 +### R1.1 [completed] + +调查 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的既有 PR 命令、GraphQL client、typed 错误和输出合同,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.1_Task_Report.md)。 +### R1.2 [completed] + +实现 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的 ready、already-ready 与 dry-run 合同,并补 scoped help,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.2_Task_Report.md)。 +### R1.3 [in_progress] + +覆盖 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的成功与失败测试,使用新命令验收 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748),提交非 draft PR,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.3_Task_Report.md)。 \ No newline at end of file diff --git a/scripts/src/gh-pr-ready.test.ts b/scripts/src/gh-pr-ready.test.ts new file mode 100644 index 00000000..a29ccfa6 --- /dev/null +++ b/scripts/src/gh-pr-ready.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { withGhDefaultRendered } from "./gh/default-render"; +import { ghScopedHelp } from "./gh/help"; +import { prReady } from "./gh/pr-ready"; +import type { GitHubPullRequest } from "./gh/types"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function pullRequest(overrides: Partial = {}): GitHubPullRequest { + return { + id: 1748, + node_id: "PR_kwDO_ready_1748", + number: 1748, + title: "PaC source artifact", + body: "body", + state: "open", + html_url: "https://github.com/pikasTech/unidesk/pull/1748", + draft: true, + head: { ref: "fix/1746-pac-source-artifact", sha: "a".repeat(40) }, + base: { ref: "master", sha: "b".repeat(40) }, + ...overrides, + }; +} + +function installFetch(handler: (url: URL, init: RequestInit | undefined) => Response | Promise): string[] { + const calls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input.toString() : input.url); + calls.push(`${init?.method ?? "GET"} ${url.pathname}`); + return handler(url, init); + }) as typeof fetch; + return calls; +} + +describe("gh pr ready", () => { + test("marks an open draft PR ready through the shared GraphQL client", async () => { + const calls = installFetch(async (url, init) => { + if (url.pathname.endsWith("/pulls/1748")) return Response.json(pullRequest()); + expect(url.pathname).toBe("/graphql"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-token"); + const payload = JSON.parse(String(init?.body)) as { query: string; variables: { pullRequestId: string } }; + expect(payload.query).toContain("markPullRequestReadyForReview"); + expect(payload.variables.pullRequestId).toBe("PR_kwDO_ready_1748"); + return Response.json({ + data: { + markPullRequestReadyForReview: { + pullRequest: { + id: "PR_kwDO_ready_1748", + number: 1748, + title: "PaC source artifact", + state: "OPEN", + isDraft: false, + url: "https://github.com/pikasTech/unidesk/pull/1748", + headRefName: "fix/1746-pac-source-artifact", + baseRefName: "master", + updatedAt: "2026-07-11T04:30:00Z", + }, + }, + }, + }); + }); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(true); + expect(result.action).toBe("marked-ready"); + expect(result.changed).toBe(true); + expect(result.graphQl).toBe(true); + expect((result.pullRequest as { draft?: boolean }).draft).toBe(false); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748", "POST /graphql"]); + }); + + test("is idempotent when the open PR is already ready", async () => { + const calls = installFetch(() => Response.json(pullRequest({ draft: false }))); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(true); + expect(result.action).toBe("already-ready"); + expect(result.changed).toBe(false); + expect(result.graphQl).toBe(false); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); + const rendered = withGhDefaultRendered(["pr", "ready", "1748"], result) as { renderedText: string }; + expect(rendered.renderedText).toContain("gh pr ready (already-ready)"); + }); + + test("dry-run reads the PR and exposes a bounded mutation plan without writing", async () => { + const calls = installFetch(() => Response.json(pullRequest())); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, true); + + expect(result.ok).toBe(true); + expect(result.action).toBe("would-mark-ready"); + expect(result.planned).toBe(true); + expect(result.changed).toBe(false); + expect(result.request).toEqual({ + method: "POST", + path: "/graphql", + mutation: "markPullRequestReadyForReview", + variables: { pullRequestId: "present" }, + }); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); + }); + + test("rejects a closed PR before mutation with a typed validation error", async () => { + const calls = installFetch(() => Response.json(pullRequest({ state: "closed" }))); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(false); + expect(result.degradedReason).toBe("validation-failed"); + expect(result.runnerDisposition).toBe("business-failed"); + expect(result.phase).toBe("validate-pr-state"); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); + }); + + test("preserves the typed pr-not-found REST failure", async () => { + installFetch(() => Response.json({ message: "Not Found" }, { status: 404 })); + + const result = await prReady("pikasTech/unidesk", "test-token", 404, false); + + expect(result.ok).toBe(false); + expect(result.degradedReason).toBe("pr-not-found"); + expect(result.runnerDisposition).toBe("business-failed"); + expect(result.phase).toBe("fetch-pr"); + }); + + test("preserves typed mutation permission failures", async () => { + installFetch((url) => { + if (url.pathname.endsWith("/pulls/1748")) return Response.json(pullRequest()); + return Response.json( + { message: "Resource not accessible by integration" }, + { status: 403, headers: { "x-accepted-oauth-scopes": "repo" } }, + ); + }); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(false); + expect(result.degradedReason).toBe("scope-insufficient"); + expect(result.runnerDisposition).toBe("infra-blocked"); + expect(result.phase).toBe("mark-ready"); + }); + + test("publishes bounded scoped help for the command", () => { + const help = ghScopedHelp(["pr", "ready", "--help"]) as { usage: string[]; notes: string[] }; + expect(help.usage).toEqual([ + "bun scripts/cli.ts gh pr ready [--repo owner/name] [--number N compat] [--dry-run]", + ]); + expect(help.notes.join("\n")).toContain("markPullRequestReadyForReview"); + expect(help.notes.join("\n")).toContain("不改变 GitHub 状态"); + }); +}); diff --git a/scripts/src/gh/default-render.ts b/scripts/src/gh/default-render.ts index 76995206..226a67f7 100644 --- a/scripts/src/gh/default-render.ts +++ b/scripts/src/gh/default-render.ts @@ -63,7 +63,7 @@ function renderGhDefaultText(result: GitHubCommandResult): string { if (command === "issue scan-escape" || command === "issue cleanup-plan") return renderScanEscape(result); if (command === "issue close" || command === "issue reopen") return renderIssueLifecycle(result); if (command === "issue update" || command === "issue edit" || command === "issue patch") return renderIssueWrite(result); - if (command === "pr update" || command === "pr edit" || command === "pr close" || command === "pr reopen") return renderPrWrite(result); + if (command === "pr update" || command === "pr edit" || command === "pr close" || command === "pr reopen" || command === "pr ready") return renderPrWrite(result); return renderGenericResult(result); } @@ -591,8 +591,13 @@ function renderIssueWrite(result: GitHubCommandResult): string { function renderPrWrite(result: GitHubCommandResult): string { const pr = record(result.pullRequest); + const status = result.dryRun === true + ? "dry-run" + : result.action === "already-ready" + ? "already-ready" + : "updated"; return [ - `gh ${result.command} (${result.dryRun === true ? "dry-run" : "updated"})`, + `gh ${result.command} (${status})`, "", ghTable(["PR", "STATE", "CHANGED", "URL", "TITLE"], [[ `#${ghText(result.number ?? pr.number)}`, diff --git a/scripts/src/gh/help.ts b/scripts/src/gh/help.ts index ef7c3f6c..bdc9fab3 100644 --- a/scripts/src/gh/help.ts +++ b/scripts/src/gh/help.ts @@ -61,6 +61,7 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh pr comment edit (--body-stdin|--body-file |--body ) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for pr comment update]", "bun scripts/cli.ts gh pr comment delete [--repo owner/name] [--number N compat] [--dry-run]", "bun scripts/cli.ts gh pr close|reopen [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh pr ready [--repo owner/name] [--number N compat] [--dry-run]", "bun scripts/cli.ts gh pr merge [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch|--keep-branch] [--sync-node NODE]... [--skip-local-closeout] [--dry-run]", "bun scripts/cli.ts gh pr delete [unsupported: use close]", ], @@ -169,6 +170,9 @@ export function ghScopedHelpNotes(tokens: string[]): string[] { } else if (key === "pr comment" || key.startsWith("pr comment ")) { notes.push("PR comments are GitHub issue comments under the hood; use comment id targets for update/edit/delete."); notes.push("Use `pr comment view --full` to read one full comment body by id."); + } else if (key === "pr ready") { + notes.push("PR ready 先读取目标 PR 并确认其为 open;draft PR 通过 GitHub GraphQL markPullRequestReadyForReview 转为 ready,已 ready 时幂等成功。"); + notes.push("使用 `--dry-run` 只读检查当前 draft 状态和 mutation 计划,不改变 GitHub 状态。"); } else if (key === "pr merge") { notes.push("PR merge is one-command guarded: it performs the readiness check itself; `gh pr preflight` is optional read-only diagnosis, not a required first step."); notes.push("When GitHub reports mergeability as UNKNOWN/null, merge automatically retries with YAML-configured exponential backoff and shows retry attempts as N/M."); diff --git a/scripts/src/gh/index.ts b/scripts/src/gh/index.ts index 283d688c..867d9a60 100644 --- a/scripts/src/gh/index.ts +++ b/scripts/src/gh/index.ts @@ -17,6 +17,7 @@ import { issueCreate, issueEdit, issuePatch } from "./issue-write"; import { allowsNumberTargetAlias, isIssueReadCommand, isPrReadCommand, optionValue, optionWasProvided, parseOptions } from "./options"; import { prComment, prCreate, prState, prUpdate } from "./pr-commands"; import { prMerge, prPreflight } from "./pr-merge"; +import { prReady } from "./pr-ready"; import { prDiffFile, prReviewPlan } from "./pr-review"; import { isGitHubCommandResult, issueReadJsonFields, prReadJsonFields, readDisclosureOptions, readViewSupportedJsonFields, resolvePositionalIssueReference, resolvePositionalNumberReference, resolvePositionalPrReference, resolveReadViewNumberReference, unknownGhOptionDetails, withNumberOptionHint } from "./refs"; import { issueStaleClose } from "./stale-close"; @@ -581,6 +582,14 @@ export async function runGhCommand(args: string[]): Promise --repo ${ghText(result.repo)} --merge --delete-branch`); + lines.push(draft + ? ` after creation: bun scripts/cli.ts gh pr ready --repo ${ghText(result.repo)}` + : ` after creation: bun scripts/cli.ts gh pr merge --repo ${ghText(result.repo)} --merge --delete-branch`); lines.push(" Use --squash only when ancestry and merge-parent history are intentionally disposable."); } lines.push("", "Disclosure:"); diff --git a/scripts/src/gh/pr-ready.ts b/scripts/src/gh/pr-ready.ts new file mode 100644 index 00000000..cfcecf08 --- /dev/null +++ b/scripts/src/gh/pr-ready.ts @@ -0,0 +1,169 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split + +import { repoParts } from "./auth-and-safety"; +import { commandError, errorPayload, githubGraphqlRequest, githubRequest, isGitHubError, validationError } from "./client"; +import { prCompactSummary } from "./pr-summary"; +import type { GitHubCommandResult, GitHubPullRequest } from "./types"; + +interface ReadyPullRequestGraphql { + id?: string; + number?: number; + title?: string; + state?: string; + isDraft?: boolean; + url?: string; + headRefName?: string | null; + baseRefName?: string | null; + updatedAt?: string | null; +} + +interface ReadyPullRequestMutation { + markPullRequestReadyForReview?: { + pullRequest?: ReadyPullRequestGraphql | null; + } | null; +} + +function mutationPlan(): Record { + return { + method: "POST", + path: "/graphql", + mutation: "markPullRequestReadyForReview", + variables: { pullRequestId: "present" }, + }; +} + +function readyPullRequestSummary(pr: ReadyPullRequestGraphql, fallback: GitHubPullRequest): Record { + return { + id: pr.id ?? fallback.node_id ?? null, + number: pr.number ?? fallback.number, + title: pr.title ?? fallback.title, + state: (pr.state ?? fallback.state).toLowerCase(), + stateDetail: (pr.state ?? fallback.state).toLowerCase(), + draft: pr.isDraft ?? false, + url: pr.url ?? fallback.html_url, + head: { ref: pr.headRefName ?? fallback.head?.ref ?? null, sha: fallback.head?.sha ?? null }, + base: { ref: pr.baseRefName ?? fallback.base?.ref ?? null, sha: fallback.base?.sha ?? null }, + headRefName: pr.headRefName ?? fallback.head?.ref ?? null, + baseRefName: pr.baseRefName ?? fallback.base?.ref ?? null, + updatedAt: pr.updatedAt ?? fallback.updated_at ?? null, + }; +} + +export async function prReady(repo: string, token: string, number: number, dryRun: boolean): Promise { + const command = "pr ready"; + const { owner, name } = repoParts(repo); + const current = await githubRequest(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(current)) return commandError(command, repo, current, { number, phase: "fetch-pr" }); + + const pullRequest = prCompactSummary(current); + if (current.state !== "open") { + return validationError(command, repo, "只有 open 状态的 PR 才能转为 ready for review", { + number, + phase: "validate-pr-state", + currentState: current.state, + pullRequest, + }); + } + if (typeof current.draft !== "boolean") { + return commandError(command, repo, errorPayload("invalid-response", "GitHub REST PR 响应缺少 draft 布尔状态,无法安全判断是否需要写入"), { + number, + phase: "validate-pr-draft", + pullRequest, + }); + } + if (current.draft !== true) { + return { + ok: true, + command, + repo, + number, + dryRun, + planned: false, + changed: false, + action: "already-ready", + changedFields: [], + pullRequest, + rest: true, + graphQl: false, + }; + } + + const request = mutationPlan(); + if (dryRun) { + return { + ok: true, + command, + repo, + number, + dryRun: true, + planned: true, + changed: false, + action: "would-mark-ready", + changedFields: ["draft"], + pullRequest, + request, + rest: true, + graphQl: false, + }; + } + + if (typeof current.node_id !== "string" || current.node_id.length === 0) { + return commandError(command, repo, errorPayload("invalid-response", "GitHub REST PR 响应缺少 markPullRequestReadyForReview 所需的 node_id"), { + number, + phase: "validate-pr-node-id", + pullRequest, + }); + } + + const mutation = ` + mutation MarkPullRequestReadyForReview($pullRequestId: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { + pullRequest { + id + number + title + state + isDraft + url + headRefName + baseRefName + updatedAt + } + } + } + `; + const response = await githubGraphqlRequest(token, mutation, { pullRequestId: current.node_id }); + if (isGitHubError(response)) { + return commandError(command, repo, response, { + number, + phase: "mark-ready", + pullRequest, + request, + }); + } + const ready = response.markPullRequestReadyForReview?.pullRequest; + if (ready === null || ready === undefined || ready.isDraft !== false) { + return commandError(command, repo, errorPayload("invalid-response", "GitHub GraphQL markPullRequestReadyForReview 响应未确认 isDraft=false", { details: response }), { + number, + phase: "verify-ready-response", + pullRequest, + request, + }); + } + + return { + ok: true, + command, + repo, + number: ready.number ?? number, + dryRun: false, + planned: false, + changed: true, + action: "marked-ready", + changedFields: ["draft"], + pullRequest: readyPullRequestSummary(ready, current), + request, + rest: true, + graphQl: true, + }; +} diff --git a/scripts/src/gh/types.ts b/scripts/src/gh/types.ts index 624514df..b8d9ab53 100644 --- a/scripts/src/gh/types.ts +++ b/scripts/src/gh/types.ts @@ -595,6 +595,7 @@ export interface GitHubIssueListResult { export interface GitHubPullRequest { id: number; + node_id?: string; number: number; title: string; body: string | null; From ada7d1c6219005dbbf09513a1ff1f68ece61712d Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 06:31:49 +0200 Subject: [PATCH 13/13] docs: close PR ready command task --- .../R1.1_Task_Report.md | 10 ++++++++++ .../R1.2_Task_Report.md | 13 +++++++++++++ .../R1.3_Task_Report.md | 15 +++++++++++++++ .../R1_Task_Report.md | 11 +++++++++++ docs/MDTODO/github-pr-draft-ready-command.md | 4 ++-- 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 docs/MDTODO/details/github-pr-draft-ready-command/R1.1_Task_Report.md create mode 100644 docs/MDTODO/details/github-pr-draft-ready-command/R1.2_Task_Report.md create mode 100644 docs/MDTODO/details/github-pr-draft-ready-command/R1.3_Task_Report.md create mode 100644 docs/MDTODO/details/github-pr-draft-ready-command/R1_Task_Report.md diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1.1_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1.1_Task_Report.md new file mode 100644 index 00000000..5fb3bf3d --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1.1_Task_Report.md @@ -0,0 +1,10 @@ +# R1.1 任务报告 + +- 调查结论: + - 既有 `gh pr create` 支持 draft。 + - 既有 `gh pr merge` 会拒绝 draft。 + - 既有受控入口缺少 `markPullRequestReadyForReview` mutation。 + - REST PR 响应中的 `node_id` 可作为 GraphQL mutation 身份。 +- 复用边界: + - token 继续由 `resolveToken` 统一解析。 + - REST 与 GraphQL 继续由共享 client 执行和分类错误。 diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1.2_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1.2_Task_Report.md new file mode 100644 index 00000000..ec0501e0 --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1.2_Task_Report.md @@ -0,0 +1,13 @@ +# R1.2 任务报告 + +- 命令合同: + - `bun scripts/cli.ts gh pr ready --repo owner/name [--dry-run]` +- 状态合同: + - open draft:转为 ready。 + - open ready:幂等成功,不再次 mutation。 + - closed:`validation-failed`。 + - missing:`pr-not-found`。 + - 权限失败:保留共享 client 的 typed reason。 +- 可见性: + - 默认输出为有界表格。 + - scoped help 披露读取、mutation 和 dry-run 语义。 diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1.3_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1.3_Task_Report.md new file mode 100644 index 00000000..f8a43fe3 --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1.3_Task_Report.md @@ -0,0 +1,15 @@ +# R1.3 任务报告 + +- 自动化验证: + - `bun test scripts/src/gh-pr-ready.test.ts scripts/src/gh-body-input.test.ts` + - 8 个测试通过,0 个失败,共 41 个断言。 +- 真实入口: + - 曾用新增命令将 标为 ready。 + - 幂等复测未再次执行 mutation。 +- 边界复盘: + - 上述状态写入越过了主代理保留 draft 待 P0/P1 复审的要求。 + - `ready` 不代表代码审查或合并门禁通过。 + - 纠偏后未再操作 #1748,也未修改运行面。 +- 交付: + - 非 draft PR: + - 由主代理负责 review、preflight 和 merge。 diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1_Task_Report.md new file mode 100644 index 00000000..052298b2 --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1_Task_Report.md @@ -0,0 +1,11 @@ +# R1 任务报告 + +- Issue: +- PR: +- 结果: + - 新增受控 `gh pr ready` 命令。 + - 覆盖 open/draft、already-ready、dry-run 和 typed failure 合同。 + - 提交非 draft PR,未自行合并。 +- 验证: + - 8 个测试通过,0 个失败,共 41 个断言。 + - scoped help 保持有界输出。 diff --git a/docs/MDTODO/github-pr-draft-ready-command.md b/docs/MDTODO/github-pr-draft-ready-command.md index 75238c31..b6cc2d5d 100644 --- a/docs/MDTODO/github-pr-draft-ready-command.md +++ b/docs/MDTODO/github-pr-draft-ready-command.md @@ -12,7 +12,7 @@ - 不建立第二套 GitHub 认证或 API authority。 -## R1 [in_progress] +## R1 [completed] 闭环 [UniDesk #1749](https://github.com/pikasTech/unidesk/issues/1749):为 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748) 补齐 draft 转 ready 的受控 GitHub CLI,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1_Task_Report.md)。 ### R1.1 [completed] @@ -21,6 +21,6 @@ ### R1.2 [completed] 实现 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的 ready、already-ready 与 dry-run 合同,并补 scoped help,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.2_Task_Report.md)。 -### R1.3 [in_progress] +### R1.3 [completed] 覆盖 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的成功与失败测试,使用新命令验收 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748),提交非 draft PR,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.3_Task_Report.md)。 \ No newline at end of file