diff --git a/TEST.md b/TEST.md index d8f93ec0..169215a8 100644 --- a/TEST.md +++ b/TEST.md @@ -34,7 +34,7 @@ ## T8 Playwright 公网前端 E2E -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 `config.json` 的 `network.publicHost` 是主 server 公网地址,运行 `bun scripts/cli.ts e2e run`,要求 JSON 中 `network:only-frontend-provider-ports`、`network:core-public-blocked`、`network:database-public-blocked`、`network:findjob-public-blocked`、`network:met-nonlinear-public-blocked`、`network:todo-note-public-blocked`、`core:internal-overview`、`core:pgdata-usage`、`provider:self-node-online`、`provider:gateway-version-label`、`provider:system-status`、`provider:docker-status`、`provider:upgrade-plan`、`provider-ingress:public-health`、`microservice:catalog-findjob`、`microservice:catalog-pipeline`、`microservice:catalog-met-nonlinear`、`microservice:catalog-todo-note`、`microservice:todo-note-health`、`microservice:todo-note-migrated-data`、`microservice:todo-note-write-path`、`microservice:findjob-health`、`microservice:findjob-summary`、`microservice:findjob-jobs-preview`、`microservice:pipeline-status`、`microservice:pipeline-health`、`microservice:pipeline-snapshot`、`microservice:met-nonlinear-status`、`microservice:met-nonlinear-health`、`microservice:met-nonlinear-queue`、`microservice:met-nonlinear-projects`、`microservice:met-nonlinear-image`、`database:named-volume-write`、`database:todo-note-pg-storage`、`frontend:login-provider-visible`、`frontend:public-provider-info-visible`、`frontend:sidebar-collapse`、`frontend:overview-pgdata-visible`、`frontend:no-naked-json-before-click`、`frontend:system-monitor-visible`、`frontend:upgrade-plan-dispatch`、`frontend:docker-status-visible`、`frontend:gateway-version-records-visible`、`frontend:gateway-duration-subsecond-visible`、`frontend:provider-operation-availability-visible`、`frontend:microservice-catalog-visible`、`frontend:todo-note-integrated-visible`、`frontend:findjob-integrated-visible`、`frontend:pipeline-integrated-visible`、`frontend:pipeline-react-flow-visible`、`frontend:met-nonlinear-integrated-visible`、`frontend:met-nonlinear-project-tree-detail`、`frontend:met-nonlinear-queue-detail-speed` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider`、`D601`、`FindJob`、`Pipeline`、`MET Nonlinear`、`SSH 透传`、`远程更新` 和结构化控件。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 `config.json` 的 `network.publicHost` 是主 server 公网地址,运行 `bun scripts/cli.ts e2e run`,要求 JSON 中 `network:only-frontend-provider-ports`、`network:core-public-blocked`、`network:database-public-blocked`、`network:findjob-public-blocked`、`network:met-nonlinear-public-blocked`、`network:todo-note-public-blocked`、`core:internal-overview`、`core:pgdata-usage`、`provider:self-node-online`、`provider:gateway-version-label`、`provider:system-status`、`provider:docker-status`、`provider:upgrade-plan`、`provider-ingress:public-health`、`microservice:catalog-findjob`、`microservice:catalog-pipeline`、`microservice:catalog-met-nonlinear`、`microservice:catalog-todo-note`、`microservice:todo-note-health`、`microservice:todo-note-migrated-data`、`microservice:todo-note-write-path`、`microservice:findjob-health`、`microservice:findjob-summary`、`microservice:findjob-jobs-preview`、`microservice:pipeline-status`、`microservice:pipeline-health`、`microservice:pipeline-snapshot`、`microservice:met-nonlinear-status`、`microservice:met-nonlinear-health`、`microservice:met-nonlinear-queue`、`microservice:met-nonlinear-projects`、`microservice:met-nonlinear-image`、`database:named-volume-write`、`database:todo-note-pg-storage`、`frontend:login-provider-visible`、`frontend:public-provider-info-visible`、`frontend:sidebar-collapse`、`frontend:overview-pgdata-visible`、`frontend:no-naked-json-before-click`、`frontend:system-monitor-visible`、`frontend:upgrade-plan-dispatch`、`frontend:docker-status-visible`、`frontend:gateway-version-records-visible`、`frontend:gateway-duration-subsecond-visible`、`frontend:provider-operation-availability-visible`、`frontend:microservice-catalog-visible`、`frontend:todo-note-integrated-visible`、`frontend:findjob-integrated-visible`、`frontend:pipeline-integrated-visible`、`frontend:pipeline-react-flow-visible`、`frontend:pipeline-step-timeline-visible`、`frontend:met-nonlinear-integrated-visible`、`frontend:met-nonlinear-project-tree-detail`、`frontend:met-nonlinear-queue-detail-speed` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider`、`D601`、`FindJob`、`Pipeline`、`MET Nonlinear`、`SSH 透传`、`远程更新` 和结构化控件。 ## T9 Database 命名卷持久化 @@ -86,7 +86,7 @@ ## T21 D601 Pipeline Microservice -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `pipeline` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/pipeline`、commit id、`127.0.0.1:18082` 后端映射、`allowedMethods` 包含 `GET/HEAD/POST` 和 `pipeline-v2-webui` 容器摘要;运行 `bun scripts/cli.ts microservice health pipeline` 和 `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 Pipeline 后端,snapshot 返回 `ok=true`、组件 registry、Pipeline run 预览,并且 run/procedure 摘要包含甘特图需要的 `startedAt`、`finishedAt` 或 `durationMs`;运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health pipeline`,确认非主 server 也能通过公网 frontend remote CLI 验证同一链路且不需要 `--main-server-key`。随后运行 `bun scripts/cli.ts e2e run`,确认 microservice 和 frontend Pipeline 检查全部 passed;再登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / 服务目录` 和 `微服务 / Pipeline`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、Pipeline 组件矩阵、React Flow 控制图框图、epoch 列表、epoch 甘特图和运行材料索引,点击控制图中的 node 后会打开 node 精细控制面板,能通过“抓取过程”读取 node 执行过程,并显示 append prompt、guide 和 redo/restart 操作入口;甘特图必须按纵向时间轴绘制 node 工作竖条,滚动到某个时间窗口时自动隐藏该窗口内无工作的 node 列;默认没有裸 JSON、JSONL 或逐行日志,只有点击 `查看原始JSON` 才显示原始数据。Pipeline 业务代码开发和调试必须用 `bun scripts/cli.ts ssh D601 ...` 进入 D601 的 `/home/ubuntu/pipeline`,不得把 pipeline 全量代码复制进 UniDesk 仓库,也不得占用主 server 部署调试服务。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `pipeline` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/pipeline`、commit id、`127.0.0.1:18082` 后端映射、`allowedMethods` 包含 `GET/HEAD/POST` 和 `pipeline-v2-webui` 容器摘要;运行 `bun scripts/cli.ts microservice health pipeline` 和 `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 Pipeline 后端,snapshot 返回 `ok=true`、组件 registry、Pipeline run 预览,并且 run/procedure 摘要包含甘特图需要的 `startedAt`、`finishedAt` 或 `durationMs`;运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health pipeline`,确认非主 server 也能通过公网 frontend remote CLI 验证同一链路且不需要 `--main-server-key`。随后运行 `bun scripts/cli.ts e2e run`,确认 microservice 和 frontend Pipeline 检查全部 passed;再登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / 服务目录` 和 `微服务 / Pipeline`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、Pipeline 组件矩阵、React Flow 控制图框图、epoch 列表、epoch 甘特图和运行材料索引,点击控制图中的 node 后会打开 node 精细控制面板,能通过“抓取过程”读取 node 执行过程,并显示 append prompt、guide 和 redo/restart 操作入口;甘特图必须按纵向时间轴绘制 node 工作竖条,滚动到某个时间窗口时自动隐藏该窗口内无工作的 node 列;点击甘特图执行线后必须显示 `OpenCode Step Timeline`,按角色、模型、tokens、正文摘要、思考块和工具调用分区展示 step,风格参考 `agent-sessions` 的 `MessageList.tsx`;默认没有裸 JSON、JSONL 或逐行日志,只有点击 `查看原始JSON` 才显示原始数据。Pipeline 业务代码开发和调试必须用 `bun scripts/cli.ts ssh D601 ...` 进入 D601 的 `/home/ubuntu/pipeline`,不得把 pipeline 全量代码复制进 UniDesk 仓库,也不得占用主 server 部署调试服务。 ## T22 Main Server Todo Note Microservice diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 08729e80..4b2d5efb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -14,10 +14,11 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `server logs` 返回 `logs/` 文件日志和 Docker 容器日志的尾部,默认限制输出大小,避免日志爆炸。 - `server rebuild ` 创建异步 job,先构建目标服务镜像,构建成功后只按 Compose project/service label 移除该服务旧容器,再用 `--no-deps` 启动目标服务;该命令用于替代手工删除容器的兜底流程,其中 `todo-note` 只重建主 server 承载的 Todo Note 后端,不会重建或删除 database 命名卷。 - `ssh [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;无后续参数时进入远端登录 shell,有后续参数时按 ssh 远端命令体验执行并返回远端 exit code。 +- `ssh apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点。 - `microservice list/status/health/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 中的 microservice;`health` 和 `proxy` 会走真实 backend-core -> provider-gateway -> 节点本机后端链路,`proxy` 对超大 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。 - `job list` 与 `job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。 - `debug health`、`debug dispatch` 与 `debug task` 走真实内部 core、WebSocket、数据库、provider、系统指标、Docker 状态和 Host SSH 维护桥流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。 -- `e2e run` 使用 publicHost 派生的公开 frontend/provider ingress URL,并通过 Docker 内网验证 core API、PostgreSQL、provider self-connection、系统指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 前端页面,是交付前的自动化 E2E 门禁;CLI 默认输出 check 状态摘要,完整诊断写入 `resultPath`。 +- `e2e run [--only pattern[,pattern...]] [--skip pattern[,pattern...]]` 使用 publicHost 派生的公开 frontend/provider ingress URL,并通过 Docker 内网验证 core API、PostgreSQL、provider self-connection、系统指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 前端页面,是交付前的自动化 E2E 门禁;CLI 默认输出 check 状态摘要,完整诊断写入 `resultPath`,日常迭代应优先用 `--only` / `--skip` 跑最小必要集合。 ## Async Job State @@ -43,6 +44,25 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 core 只允许声明了 `host.ssh` capability 的 provider 使用 `ssh` 透传或 `host.ssh` dispatch;旧 provider 不支持该能力时必须快速失败并输出错误,不能把未知命令误判成 `echo` 成功。 +本地 broker 默认等待 provider SSH 会话打开 60000ms,以便在目标节点同时有较多 microservice.http 任务时仍能建立维护会话;需要诊断慢连接时可用 `UNIDESK_SSH_OPEN_TIMEOUT_MS=` 临时调大,但最小有效值固定为 15000ms,避免把真实离线误判为长时间阻塞。 + +`ssh ` 会在远端会话启动时注入 `/tmp/unidesk-ssh-tools/apply_patch`,并把该目录加入远端 `PATH`。该工具接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;目标节点需要具备 `python3` 和 `base64`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库,交互式 shell 和远端命令都可以直接调用 `apply_patch`。 + +如果只是远端打小补丁,不需要再手写 `ssh D601 'apply_patch' < patch.diff` 这种命令拼接;正式入口是 `bun scripts/cli.ts ssh D601 apply-patch < patch.diff`。`apply-patch` 与 `patch` 等价,附加参数会原样透传给远端 `apply_patch`,例如 `bun scripts/cli.ts ssh D601 apply-patch --help`。标准单命令用法如下,不需要先创建本地 patch 临时文件: + +```bash +bun scripts/cli.ts ssh D601 apply-patch <<'PATCH' +*** Begin Patch +*** Update File: /home/ubuntu/pipeline/scripts/src/nodeControl.ts +@@ +-const value = "old"; ++const value = "new"; +*** End Patch +PATCH +``` + +通过 `ssh ` 执行多行脚本时,脚本内容必须从本地 stdin 直接喂给远端解释器,例如 `bun scripts/cli.ts ssh D601 'python3 -' < script.py` 或 `printf ... | (bun scripts/cli.ts ssh D601 'bash -s')` 这种单层 stdin 传输。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <:/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 564ae0f0..21a71211 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -1,7 +1,7 @@ import { readConfig } from "./src/config"; import { debugDispatch, debugHealth, debugTask, isDebugDispatchCommand, type DebugDispatchCommand } from "./src/debug"; import { isRebuildableService, rebuildService, stackLogs, stackStatus, startStack, stopStack } from "./src/docker"; -import { runE2E } from "./src/e2e"; +import { parseE2ERunOptions, runE2E } from "./src/e2e"; import { emitError, emitJson } from "./src/output"; import { jobWithTail, listJobs, readJob, runJob } from "./src/jobs"; import { runChecks } from "./src/check"; @@ -27,7 +27,8 @@ function help(): unknown { { command: "server status", description: "Show fixed ports, containers, service health, and public URLs." }, { command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." }, { command: "server rebuild ", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." }, - { command: "ssh [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge." }, + { command: "ssh [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge with built-in apply_patch in PATH." }, + { command: "ssh apply-patch [tool args...] < patch.diff", description: "Invoke the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." }, { command: "microservice list", description: "List UniDesk-managed microservices and their provider/runtime mapping." }, { command: "microservice status ", description: "Show one microservice config, repository reference, backend mapping, and runtime status." }, { command: "microservice health ", description: "Probe one microservice through backend-core -> provider-gateway HTTP proxy." }, @@ -37,7 +38,7 @@ function help(): unknown { { command: "debug health", description: "Probe internal core, nodes, system/Docker status, frontend, provider ingress, and public boundary." }, { command: "debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|microservice.http|echo] [--wait-ms N]", description: "Submit a real internal-core dispatch request for CLI debugging." }, { command: "debug task ", description: "Read a dispatched task record from internal core for CLI debugging." }, - { command: "e2e run", description: "Run public frontend/provider, internal core/database, and Playwright login E2E checks." }, + { command: "e2e run [--only pattern[,pattern...]] [--skip pattern[,pattern...]]", description: "Run selected public/internal/Playwright E2E checks; use --only for focused iteration and rerun without filters for final regression." }, ], }; } @@ -192,7 +193,7 @@ async function main(): Promise { } if (top === "e2e" && sub === "run") { - const result = await runE2E(config); + const result = await runE2E(config, parseE2ERunOptions(args.slice(2))); const ok = (result as { ok?: unknown }).ok === true; emitJson(commandName, result, ok); if (!ok) process.exitCode = 1; diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 72add537..6eb8567d 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -5,11 +5,159 @@ export interface ParsedSshArgs { remoteCommand: string | null; } +const remoteApplyPatchSource = String.raw`#!/usr/bin/env python3 +import sys +from pathlib import Path + + +def die(message): + print(f"apply_patch: {message}", file=sys.stderr) + sys.exit(1) + + +def write_file(path, text): + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text) + + +def move_file(source, destination): + source_path = Path(source) + destination_path = Path(destination) + if not source_path.exists(): + die(f"file not found: {source}") + if destination_path.exists(): + die(f"target file already exists: {destination}") + destination_path.parent.mkdir(parents=True, exist_ok=True) + source_path.rename(destination_path) + + +def read_file(path): + try: + return Path(path).read_text() + except FileNotFoundError: + die(f"file not found: {path}") + + +def apply_update(path, body): + old = read_file(path) + output = [] + search_from = 0 + index = 0 + while index < len(body): + if body[index].startswith("*** End of File"): + index += 1 + continue + if not body[index].startswith("@@"): + die(f"expected hunk header in {path}") + index += 1 + sequence = [] + while index < len(body) and not body[index].startswith("@@"): + line = body[index] + index += 1 + if line.startswith("*** End of File"): + continue + if line.startswith(" "): + sequence.append((" ", line[1:])) + elif line.startswith("-"): + sequence.append(("-", line[1:])) + elif line.startswith("+"): + sequence.append(("+", line[1:])) + elif line.rstrip("\n") == r"\ No newline at end of file": + continue + else: + die(f"bad hunk line in {path}: {line[:80].rstrip()}") + search = "".join(text for kind, text in sequence if kind in (" ", "-")) + replacement = "".join(text for kind, text in sequence if kind in (" ", "+")) + offset = old.find(search, search_from) + if offset < 0: + offset = old.find(search) + if offset < 0: + die(f"hunk context not found in {path}") + output.append(old[search_from:offset]) + output.append(replacement) + search_from = offset + len(search) + output.append(old[search_from:]) + write_file(path, "".join(output)) + + +def main(): + if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"): + print("apply_patch: read *** Begin Patch format from stdin; supports add/update/delete/move") + return + lines = sys.stdin.read().splitlines(keepends=True) + if not lines or lines[0].strip() != "*** Begin Patch": + die("patch must start with *** Begin Patch") + index = 1 + while index < len(lines): + line = lines[index].rstrip("\n") + if line == "*** End Patch": + print("Done!") + return + if line.startswith("*** Add File: "): + path = line[len("*** Add File: "):].strip() + index += 1 + content = [] + while index < len(lines) and not lines[index].startswith("*** "): + if not lines[index].startswith("+"): + die(f"add file lines must start with + for {path}") + content.append(lines[index][1:]) + index += 1 + if Path(path).exists(): + die(f"file already exists: {path}") + write_file(path, "".join(content)) + continue + if line.startswith("*** Delete File: "): + path = line[len("*** Delete File: "):].strip() + try: + Path(path).unlink() + except FileNotFoundError: + die(f"file not found: {path}") + index += 1 + continue + if line.startswith("*** Update File: "): + path = line[len("*** Update File: "):].strip() + index += 1 + move_to = None + if index < len(lines) and lines[index].startswith("*** Move to: "): + move_to = lines[index][len("*** Move to: "):].strip() + index += 1 + body = [] + while index < len(lines) and not ( + lines[index].startswith("*** Add File: ") + or lines[index].startswith("*** Update File: ") + or lines[index].startswith("*** Delete File: ") + or lines[index].startswith("*** End Patch") + ): + if lines[index].startswith("*** Move to: "): + die("move marker must appear before update hunks") + body.append(lines[index]) + index += 1 + if body: + apply_update(path, body) + elif move_to is None and not Path(path).exists(): + die(f"file not found: {path}") + if move_to is not None: + move_file(path, move_to) + continue + die(f"unexpected patch line: {line}") + die("missing *** End Patch") + + +if __name__ == "__main__": + main() +`; + const sshOptionsWithValue = new Set([ "-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w", ]); export function parseSshArgs(args: string[]): ParsedSshArgs { + const subcommand = args[0] ?? ""; + if (subcommand === "apply-patch" || subcommand === "patch") { + const toolArgs = ["apply_patch", ...args.slice(1)]; + return { remoteCommand: toolArgs.map(shellQuote).join(" ") }; + } const remote: string[] = []; let remoteStarted = false; for (let index = 0; index < args.length; index += 1) { @@ -32,6 +180,27 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { return { remoteCommand: remote.length === 0 ? null : remote.join(" ") }; } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function remoteToolBootstrapCommand(): string { + const encoded = Buffer.from(remoteApplyPatchSource, "utf8").toString("base64"); + return [ + "UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools", + 'mkdir -p "$UNIDESK_SSH_TOOL_DIR"', + `printf %s ${shellQuote(encoded)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/apply_patch"`, + 'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"', + 'export PATH="$UNIDESK_SSH_TOOL_DIR:$PATH"', + ].join("; "); +} + +function wrapRemoteCommand(command: string | null): string { + const bootstrap = remoteToolBootstrapCommand(); + if (command === null) return `${bootstrap}; exec "\${SHELL:-/bin/bash}" -l`; + return `${bootstrap}; stty -echo 2>/dev/null || true; ${command}`; +} + function brokerSource(): string { return String.raw` const open = JSON.parse(process.argv[2] || process.argv[1] || "{}"); @@ -40,8 +209,10 @@ const url = "ws://127.0.0.1:8080/ws/ssh?token=" + encodeURIComponent(token); const ws = new WebSocket(url); let exitCode = 255; let canSend = false; +let sessionReady = false; let opened = false; const pending = []; +const pendingInput = []; const openTimer = setTimeout(() => { if (opened) return; process.stderr.write("unidesk ssh bridge timed out waiting for provider session\n"); @@ -64,6 +235,22 @@ function flush() { } } +function sendInput(value) { + const text = JSON.stringify(value); + if (!sessionReady || ws.readyState !== WebSocket.OPEN) { + pendingInput.push(text); + return; + } + ws.send(text); +} + +function flushInput() { + if (!sessionReady || ws.readyState !== WebSocket.OPEN) return; + while (pendingInput.length > 0) { + ws.send(pendingInput.shift()); + } +} + function decodeData(data) { return typeof data === "string" ? data : Buffer.from(data).toString("utf8"); } @@ -92,10 +279,15 @@ ws.addEventListener("message", (event) => { } if (message.type === "ssh.opened") { opened = true; + sessionReady = true; clearTimeout(openTimer); + if (open.stdinEotOnEnd === true) setTimeout(flushInput, 200); + else flushInput(); + return; + } + if (message.type === "ssh.dispatched") { return; } - if (message.type === "ssh.dispatched") return; if (message.type === "ssh.error") { clearTimeout(openTimer); process.stderr.write(String(message.message || "ssh bridge error") + "\n"); @@ -120,12 +312,13 @@ ws.addEventListener("error", () => { }); process.stdin.on("data", (chunk) => { - send({ type: "ssh.input", data: Buffer.from(chunk).toString("base64"), encoding: "base64" }); - flush(); + sendInput({ type: "ssh.input", data: Buffer.from(chunk).toString("base64"), encoding: "base64" }); }); process.stdin.on("end", () => { - send({ type: "ssh.eof" }); - flush(); + if (open.stdinEotOnEnd === true) { + sendInput({ type: "ssh.input", data: Buffer.from([4]).toString("base64"), encoding: "base64" }); + } + sendInput({ type: "ssh.eof" }); }); `; } @@ -141,9 +334,12 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st if (!providerId) throw new Error("ssh requires provider id, for example: bun scripts/cli.ts ssh D518"); const parsed = parseSshArgs(args); const size = terminalSize(); + const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)); const payload = { providerId, - command: parsed.remoteCommand, + command: wrapRemoteCommand(parsed.remoteCommand), + stdinEotOnEnd: parsed.remoteCommand !== null, + openTimeoutMs, cols: size.cols, rows: size.rows, }; diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index 25e66563..d156ed51 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -412,8 +412,9 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } } .status-badge.online, .status-badge.succeeded, .status-badge.public { color: var(--ok); border-color: rgba(113, 191, 120, 0.45); } .status-badge.offline, .status-badge.failed, .status-badge.canceled { color: var(--danger); border-color: rgba(207, 106, 84, 0.45); } -.status-badge.running, .status-badge.dispatched, .status-badge.accepted, .status-badge.internal { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); } +.status-badge.running, .status-badge.dispatched, .status-badge.accepted, .status-badge.internal, .status-badge.delivered, .status-badge.applied { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); } .status-badge.queued, .status-badge.staged, .status-badge.warn { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); } +.status-badge.ignored { color: var(--muted); border-color: rgba(129, 147, 159, 0.38); } .status-badge.private, .status-badge.p1, .status-badge.prioritized, .status-badge.verified { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); } .status-badge.stale, .status-badge.invalid, .status-badge.abandoned { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); } @@ -1617,6 +1618,52 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } color: var(--muted); background: rgba(255,255,255,0.03); } +.pipeline-gantt-scale { + display: grid; + grid-template-columns: minmax(130px, 0.52fr) minmax(150px, 1fr); + grid-template-areas: "label slider" "legend legend"; + align-items: center; + gap: 3px 8px; + min-width: min(340px, 72vw); + padding: 6px 9px; + border: 1px solid rgba(215, 161, 58, 0.22); + background: + linear-gradient(90deg, rgba(215, 161, 58, 0.08), rgba(78, 183, 168, 0.045)), + rgba(255,255,255,0.026); + color: var(--muted); +} +.pipeline-gantt-scale > span:first-child { + grid-area: label; + display: grid; + gap: 1px; + min-width: 0; +} +.pipeline-gantt-scale b { + color: var(--accent); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.pipeline-gantt-scale em { + color: var(--text); + font-style: normal; + font-size: 11px; + white-space: nowrap; +} +.pipeline-gantt-scale input[type="range"] { + grid-area: slider; + width: 100%; + accent-color: var(--accent); +} +.pipeline-gantt-scale small { + grid-area: legend; + display: flex; + justify-content: space-between; + color: var(--faint); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} .pipeline-gantt-wrap { display: grid; gap: 8px; @@ -1653,6 +1700,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .pipeline-gantt-board { display: grid; align-items: start; + position: relative; } .pipeline-gantt-head { position: sticky; @@ -1733,6 +1781,36 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } linear-gradient(180deg, rgba(255,255,255,0.018), transparent), repeating-linear-gradient(0deg, rgba(255,255,255,0.032) 0, rgba(255,255,255,0.032) 1px, transparent 1px, transparent 96px); } +.pipeline-gantt-arrow-layer { + position: absolute; + z-index: 4; + pointer-events: none; + overflow: visible; +} +.pipeline-gantt-arrow { + fill: none; + stroke: #8aa0ad; + stroke-width: 1.6; + stroke-dasharray: 6 5; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.82; +} +.pipeline-gantt-arrow.monitor, +.pipeline-gantt-arrow.guide { + stroke: var(--accent-2); +} +.pipeline-gantt-arrow.webui { + stroke: #69aee8; +} +.pipeline-gantt-arrow.cli { + stroke: #d7a13a; +} +.pipeline-gantt-arrow.control-command-ignored, +.pipeline-gantt-arrow.ignored { + stroke: var(--muted); + opacity: 0.52; +} .pipeline-gantt-empty-col { display: grid; place-items: start center; @@ -1788,6 +1866,82 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } border-color: rgba(255, 240, 198, 0.98); box-shadow: 0 0 0 2px rgba(8, 17, 24, 0.88), 0 0 22px rgba(246, 197, 91, 0.55); } +.pipeline-gantt-marker { + position: absolute; + left: 50%; + width: 11px; + height: 11px; + padding: 0; + border: 1px solid rgba(255,255,255,0.18); + border-radius: 999px; + background: #a7bac5; + box-shadow: 0 0 0 2px rgba(8, 17, 24, 0.88); + transform: translate(-50%, -50%); + z-index: 6; +} +.pipeline-gantt-marker.prompt { + width: 9px; + height: 9px; + border-color: rgba(78, 183, 168, 0.58); + background: rgba(78, 183, 168, 0.98); +} +.pipeline-gantt-marker.prompt.initial { + border-color: rgba(215, 161, 58, 0.62); + background: rgba(215, 161, 58, 0.98); +} +.pipeline-gantt-marker.prompt.monitor { + border-color: rgba(105, 174, 232, 0.78); + background: rgba(105, 174, 232, 0.98); +} +.pipeline-gantt-marker.prompt.queued { + background: #0b1319; + border-color: rgba(105, 174, 232, 0.7); +} +.pipeline-gantt-marker.control-source { + width: 12px; + height: 12px; + border-radius: 3px; + transform: translate(-50%, -50%) rotate(45deg); + background: rgba(138, 160, 173, 0.92); +} +.pipeline-gantt-marker.control-source.monitor { + background: rgba(78, 183, 168, 0.98); + border-color: rgba(78, 183, 168, 0.8); +} +.pipeline-gantt-marker.control-source.webui { + background: rgba(105, 174, 232, 0.98); + border-color: rgba(105, 174, 232, 0.82); +} +.pipeline-gantt-marker.control-source.cli { + background: rgba(215, 161, 58, 0.98); + border-color: rgba(215, 161, 58, 0.82); +} +.pipeline-gantt-marker.control-target { + width: 12px; + height: 12px; + border-width: 2px; + background: #081118; +} +.pipeline-gantt-marker.control-target.guide { + border-color: rgba(78, 183, 168, 0.98); +} +.pipeline-gantt-marker.control-target.modify { + border-color: rgba(224, 185, 90, 0.98); +} +.pipeline-gantt-marker.control-target.approve { + border-color: rgba(78, 183, 168, 0.98); + background: rgba(78, 183, 168, 0.22); +} +.pipeline-gantt-marker.control-target.restart { + border-color: rgba(215, 161, 58, 0.98); +} +.pipeline-gantt-marker.control-target.ignored { + border-color: rgba(129, 147, 159, 0.9); + background: rgba(8, 17, 24, 0.62); +} +.pipeline-gantt-marker.selected { + box-shadow: 0 0 0 2px rgba(8, 17, 24, 0.92), 0 0 0 4px rgba(246, 197, 91, 0.26), 0 0 18px rgba(246, 197, 91, 0.36); +} .pipeline-gantt-detail-panel { position: sticky; top: 8px; @@ -1823,6 +1977,47 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } flex-wrap: wrap; gap: 6px; } +.pipeline-event-card { + display: grid; + gap: 8px; + padding: 9px; + border: 1px solid rgba(105, 174, 232, 0.22); + background: + linear-gradient(135deg, rgba(105, 174, 232, 0.08), transparent 42%), + rgba(255,255,255,0.025); +} +.pipeline-event-card-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: start; +} +.pipeline-event-blocks { + display: grid; + gap: 7px; +} +.pipeline-event-text-block { + display: grid; + gap: 4px; + padding: 8px; + border: 1px solid rgba(215, 161, 58, 0.2); + background: + linear-gradient(90deg, rgba(215, 161, 58, 0.08), transparent 52%), + rgba(0,0,0,0.14); +} +.pipeline-event-text-block b { + color: var(--accent); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.pipeline-event-text-block p { + margin: 0; + color: var(--ink); + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.45; +} .pipeline-kv-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1847,6 +2042,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } display: flex; flex-wrap: wrap; gap: 5px; + min-width: 0; + max-width: 100%; } .pipeline-chip-row span { padding: 3px 6px; @@ -1854,6 +2051,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } background: rgba(78, 183, 168, 0.055); color: var(--muted); font-size: 10px; + max-width: 100%; + overflow-wrap: anywhere; } .pipeline-attempt-card { display: grid; @@ -1862,27 +2061,472 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } border: 1px solid var(--line-soft); background: rgba(255,255,255,0.026); } +.pipeline-attempt-card.matched { + border-color: rgba(215, 161, 58, 0.46); + background: + linear-gradient(135deg, rgba(215, 161, 58, 0.08), transparent 48%), + rgba(255,255,255,0.03); +} +.pipeline-attempt-head > div { + display: grid; + gap: 2px; +} .pipeline-attempt-head span { color: var(--muted); font-size: 11px; } +.pipeline-attempt-badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 5px; +} +.pipeline-attempt-badges span { + padding: 3px 6px; + border: 1px solid rgba(78, 183, 168, 0.18); + background: rgba(78, 183, 168, 0.055); + color: #b8d7d3; + font-size: 10px; + text-transform: uppercase; +} +.pipeline-attempt-badges .danger { + border-color: rgba(207, 106, 84, 0.36); + background: rgba(207, 106, 84, 0.12); + color: #f0b7a8; +} +.pipeline-opencode-timeline { + display: grid; + gap: 9px; + max-height: min(76vh, 780px); + min-width: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 9px; + border: 1px solid rgba(105, 174, 232, 0.2); + background: + linear-gradient(180deg, rgba(105, 174, 232, 0.06), transparent 180px), + rgba(3, 8, 12, 0.34); +} +.pipeline-opencode-timeline-head { + position: sticky; + top: -9px; + z-index: 2; + display: flex; + justify-content: space-between; + align-items: start; + gap: 12px; + padding: 8px 6px 9px; + border-bottom: 1px solid rgba(105, 174, 232, 0.16); + background: rgba(7, 14, 20, 0.94); + backdrop-filter: blur(10px); + min-width: 0; +} +.pipeline-opencode-timeline-head b { + color: var(--text); + letter-spacing: 0.04em; +} +.pipeline-opencode-timeline-head span { + display: block; + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} +.pipeline-opencode-session-head { + display: grid; + justify-items: start; + gap: 6px; + min-width: 0; + max-width: 100%; +} .pipeline-opencode-flow { + display: grid; + gap: 9px; + position: relative; + width: 100%; + box-sizing: border-box; + padding-left: 12px; + min-width: 0; + overflow-x: clip; +} +.pipeline-opencode-flow::before { + content: ""; + position: absolute; + left: 3px; + top: 6px; + bottom: 8px; + width: 1px; + background: linear-gradient(180deg, rgba(78, 183, 168, 0.56), rgba(215, 161, 58, 0.34), rgba(105, 174, 232, 0.18)); +} +.pipeline-opencode-step { + position: relative; + overflow: hidden; + min-width: 0; + width: 100%; + box-sizing: border-box; + border: 1px solid rgba(255,255,255,0.085); + background: + linear-gradient(120deg, rgba(78, 183, 168, 0.06), transparent 42%), + rgba(0,0,0,0.18); + box-shadow: 0 10px 24px rgba(0,0,0,0.16); +} +.pipeline-opencode-step::before { + content: ""; + position: absolute; + left: -12px; + top: 18px; + width: 9px; + height: 9px; + border: 1px solid rgba(78, 183, 168, 0.7); + background: #0a1517; + box-shadow: 0 0 0 3px rgba(78, 183, 168, 0.12); +} +.pipeline-opencode-step.user { + background: + linear-gradient(120deg, rgba(105, 174, 232, 0.09), transparent 42%), + rgba(0,0,0,0.18); +} +.pipeline-opencode-step.failed { + border-color: rgba(207, 106, 84, 0.38); + box-shadow: inset 2px 0 0 rgba(207, 106, 84, 0.7), 0 10px 24px rgba(0,0,0,0.16); +} +.pipeline-opencode-step.running { + border-color: rgba(215, 161, 58, 0.38); + box-shadow: inset 2px 0 0 rgba(215, 161, 58, 0.72), 0 10px 24px rgba(0,0,0,0.16); +} +.pipeline-opencode-step.matched { + border-color: rgba(215, 161, 58, 0.48); + box-shadow: inset 3px 0 0 rgba(215, 161, 58, 0.78), 0 0 0 1px rgba(215, 161, 58, 0.12), 0 12px 28px rgba(0,0,0,0.18); +} +.pipeline-opencode-step > summary { + display: grid; + gap: 6px; + align-items: start; + padding: 8px; + cursor: pointer; + list-style: none; + min-width: 0; +} +.pipeline-opencode-step > summary::-webkit-details-marker { + display: none; +} +.pipeline-opencode-step[open] > summary { + border-bottom: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.018); +} +.pipeline-opencode-step-body { + display: grid; + gap: 8px; + padding: 9px; +} +.pipeline-step-role, +.pipeline-tool-badge, +.pipeline-part-kind { + display: inline-flex; + width: fit-content; + align-items: center; + justify-content: center; + padding: 2px 6px; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.045); + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.11em; + text-transform: uppercase; +} +.pipeline-step-role.user { + border-color: rgba(105, 174, 232, 0.38); + background: rgba(105, 174, 232, 0.12); + color: #c7e5ff; +} +.pipeline-step-role.assistant { + border-color: rgba(78, 183, 168, 0.34); + background: rgba(78, 183, 168, 0.11); + color: #bfe7dd; +} +.pipeline-step-role.system { + border-color: rgba(215, 161, 58, 0.35); + background: rgba(215, 161, 58, 0.11); + color: #f0d499; +} +.pipeline-step-time-card { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 3px 8px; + min-width: 0; + padding: 0 0 5px; + border-bottom: 1px solid rgba(255,255,255,0.08); + background: none; +} +.pipeline-step-time-card strong { + color: #d7e5e6; + font-size: 11px; + letter-spacing: 0.03em; + min-width: 0; +} +.pipeline-step-time-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 4px; + margin-left: auto; + min-width: 0; +} +.pipeline-step-time-card span { + color: var(--muted); + font-size: 9px; + min-width: 0; +} +.pipeline-step-message-card { + display: grid; + gap: 8px; + min-width: 0; + padding: 7px 8px; + border: 1px solid rgba(255,255,255,0.08); + background: + linear-gradient(90deg, rgba(78, 183, 168, 0.05), transparent 62%), + rgba(4, 9, 13, 0.54); +} +.pipeline-step-message-card.compact { + gap: 4px; + padding: 5px 6px; +} +.pipeline-step-message-card.user, +.pipeline-step-message-card.system { + background: + linear-gradient(90deg, rgba(105, 174, 232, 0.07), transparent 62%), + rgba(4, 9, 13, 0.56); +} +.pipeline-step-message-card-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-width: 0; +} +.pipeline-step-message-rows { + display: grid; + gap: 7px; + min-width: 0; +} +.pipeline-step-message-row.compact { + display: grid; + gap: 3px; + min-width: 0; +} +.pipeline-step-message-row.compact p { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 7px; +} +.pipeline-step-message-row.compact p { + margin: 0; + color: #d4e3e4; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.32; + min-width: 0; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.pipeline-step-message-row.compact p b { + flex: 0 0 auto; +} +.pipeline-step-message-row.compact p span { + min-width: 0; + flex: 1 1 220px; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.pipeline-step-message-row.compact.reasoning p { + color: #ead8a9; +} +.pipeline-step-message-row.expanded .pipeline-step-text-stack { + gap: 6px; +} +.pipeline-step-message-row.expanded .pipeline-step-text-block { + background: rgba(255,255,255,0.02); +} +.pipeline-step-tool-summary { + display: grid; + gap: 4px; + min-width: 0; + padding: 6px 7px; + border: 1px solid rgba(255,255,255,0.08); + background: + linear-gradient(90deg, rgba(105, 174, 232, 0.06), transparent 60%), + rgba(5, 10, 14, 0.52); +} +.pipeline-step-tool-summary.empty { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; +} +.pipeline-step-tool-summary-list { + display: grid; + gap: 5px; + min-width: 0; +} +.pipeline-step-tool-summary-item { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 7px; + min-width: 0; +} +.pipeline-step-tool-summary-item span { + color: var(--accent-2); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + flex: 0 0 auto; +} +.pipeline-step-tool-summary-item p, +.pipeline-step-tool-summary > p { + margin: 0; + color: #d4e3e4; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.32; + min-width: 0; + flex: 1 1 220px; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} +.pipeline-step-tool-summary small { + color: var(--muted); + font-size: 10px; +} +.pipeline-step-factbar { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.pipeline-step-token-line { + color: var(--muted); + font-size: 10px; +} +.pipeline-step-text-stack { display: grid; gap: 7px; } -.pipeline-opencode-step, +.pipeline-step-text-block { + display: grid; + gap: 5px; + padding: 8px; + border: 1px solid rgba(255,255,255,0.07); + background: + linear-gradient(90deg, rgba(78, 183, 168, 0.055), transparent 56%), + rgba(4, 9, 13, 0.5); +} +.pipeline-step-text-block.user, +.pipeline-step-text-block.user-text { + border-color: rgba(105, 174, 232, 0.18); + background: + linear-gradient(90deg, rgba(105, 174, 232, 0.08), transparent 56%), + rgba(4, 9, 13, 0.52); +} +.pipeline-step-text-block.reasoning { + border-color: rgba(215, 161, 58, 0.16); + background: + linear-gradient(90deg, rgba(215, 161, 58, 0.07), transparent 56%), + rgba(4, 9, 13, 0.44); +} +.pipeline-step-text-block.failed { + border-color: rgba(207, 106, 84, 0.28); + background: rgba(207, 106, 84, 0.08); +} +.pipeline-step-text-block b, +.pipeline-tool-call-title b, +.pipeline-opencode-part-body b { + color: var(--accent); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.pipeline-step-text-block p { + margin: 0; + color: #bfd3d4; + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.45; +} +.pipeline-step-text-overflow { + color: rgba(191, 211, 212, 0.72); + font-size: 11px; + letter-spacing: 0.02em; +} +.pipeline-structured-payload { + display: grid; + gap: 6px; + padding: 8px; + border: 1px solid rgba(105, 174, 232, 0.18); + background: + linear-gradient(90deg, rgba(105, 174, 232, 0.08), transparent 58%), + rgba(8, 17, 24, 0.48); +} +.pipeline-structured-payload-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} +.pipeline-structured-payload-head b { + color: var(--accent); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.pipeline-structured-payload-head span { + color: var(--muted); + font-size: 10px; + text-transform: uppercase; +} +.pipeline-tool-call-strip { + display: grid; + gap: 6px; + padding: 8px; + border: 1px solid rgba(78, 183, 168, 0.12); + background: rgba(78, 183, 168, 0.035); +} +.pipeline-tool-call-title { + display: flex; + justify-content: space-between; + gap: 8px; +} +.pipeline-tool-call-title span { + color: var(--muted); + font-size: 11px; +} .pipeline-opencode-part { border: 1px solid rgba(255,255,255,0.08); background: rgba(0,0,0,0.15); } -.pipeline-opencode-step > summary, +.pipeline-opencode-part.tool.failed { + border-color: rgba(207, 106, 84, 0.34); +} +.pipeline-opencode-part.tool.succeeded { + border-color: rgba(78, 183, 168, 0.2); +} .pipeline-opencode-part > summary { - display: grid; - gap: 5px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; padding: 8px; cursor: pointer; } -.pipeline-opencode-step > summary::-webkit-details-marker, .pipeline-opencode-part > summary::-webkit-details-marker { display: none; } @@ -1892,10 +2536,17 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } letter-spacing: 0.13em; text-transform: uppercase; } -.pipeline-opencode-step summary small { - color: var(--muted); +.pipeline-step-match-badge { + display: inline-flex; + width: fit-content; + padding: 2px 6px; + border: 1px solid rgba(215, 161, 58, 0.46); + color: #f2da9f; + background: rgba(215, 161, 58, 0.12); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; } -.pipeline-opencode-step-body, .pipeline-opencode-part-body { display: grid; gap: 8px; @@ -1932,6 +2583,29 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } display: grid; gap: 6px; } +.pipeline-opencode-part-list.reasoning-list { + gap: 5px; +} +@media (max-width: 1120px) { + .pipeline-step-time-meta { + margin-left: 0; + justify-content: flex-start; + } +} +@media (max-width: 760px) { + .pipeline-opencode-timeline-head { + flex-direction: column; + align-items: stretch; + } + .pipeline-opencode-session-head { + justify-items: start; + min-width: 0; + } + .pipeline-step-tool-summary-item p, + .pipeline-step-tool-summary > p { + flex-basis: 100%; + } +} @keyframes ganttPulse { 0%, 100% { box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 12px rgba(215, 161, 58, 0.28); } 50% { box-shadow: 0 0 0 1px rgba(215, 161, 58, 0.52), 0 0 20px rgba(215, 161, 58, 0.48); }