feat: add remote apply-patch passthrough and pipeline timeline polish
This commit is contained in:
@@ -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
|
||||
|
||||
+21
-1
@@ -14,10 +14,11 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
|
||||
- `server logs` 返回 `logs/` 文件日志和 Docker 容器日志的尾部,默认限制输出大小,避免日志爆炸。
|
||||
- `server rebuild <backend-core|frontend|provider-gateway|todo-note>` 创建异步 job,先构建目标服务镜像,构建成功后只按 Compose project/service label 移除该服务旧容器,再用 `--no-deps` 启动目标服务;该命令用于替代手工删除容器的兜底流程,其中 `todo-note` 只重建主 server 承载的 Todo Note 后端,不会重建或删除 database 命名卷。
|
||||
- `ssh <providerId> [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;无后续参数时进入远端登录 shell,有后续参数时按 ssh 远端命令体验执行并返回远端 exit code。
|
||||
- `ssh <providerId> 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=<ms>` 临时调大,但最小有效值固定为 15000ms,避免把真实离线误判为长时间阻塞。
|
||||
|
||||
`ssh <providerId>` 会在远端会话启动时注入 `/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 <providerId>` 执行多行脚本时,脚本内容必须从本地 stdin 直接喂给远端解释器,例如 `bun scripts/cli.ts ssh D601 'python3 -' < script.py` 或 `printf ... | (bun scripts/cli.ts ssh D601 'bash -s')` 这种单层 stdin 传输。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <<EOF ...'` 形态;多层 shell 解析容易把 stdin 绑定到错误进程,结果会打开远端交互解释器并留下悬挂的 broker/SSH 会话。长脚本需要复用时,优先通过 stdin 写入目标节点的临时脚本,再在同一个远端命令中显式执行并清理。
|
||||
|
||||
## Remote Main Server Passthrough
|
||||
|
||||
`--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://<ip>:<frontendPort>/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。
|
||||
|
||||
+5
-4
@@ -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 <backend-core|frontend|provider-gateway|todo-note>", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." },
|
||||
{ command: "ssh <providerId> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge." },
|
||||
{ command: "ssh <providerId> [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 <providerId> 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 <id>", description: "Show one microservice config, repository reference, backend mapping, and runtime status." },
|
||||
{ command: "microservice health <id>", 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 <taskId|latest>", 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<void> {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
+202
-6
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user