Compare commits
2 Commits
5a164843ef
...
ab8e07201f
| Author | SHA1 | Date | |
|---|---|---|---|
| ab8e07201f | |||
| ad367e450d |
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"部署目標定義檔(leo 2026-07-22 立)。一個目標=一組『帳號+profile+apiBase+專案名』。",
|
||||||
|
"",
|
||||||
|
"為什麼要這個檔:5a16484 把 UI 搬 CF Pages 後,這些值從 worker 環境變數變成 build 期參數。",
|
||||||
|
"誰部署誰要記得帶 → 帶漏了就退回預設,而預設值對兩邊都不對。今天實際踩到的:",
|
||||||
|
" · demo 站漏 CONSOLE_PROFILE=rag → 顯示個人版 7 頁駕駛艙(leo 看到『Mira 介面』的真因)",
|
||||||
|
" · 兩站都漏 ARCRUN_API_BASE → apiBase 空字串 → 前端打自己回 405 → 登不進去",
|
||||||
|
" · 兩個帳號有同名 arcrun-console-ui 專案,wrangler 又登入在 uncle6",
|
||||||
|
" → 不指定帳號直接 deploy 會部到 demo 站上(差點蓋掉)",
|
||||||
|
"",
|
||||||
|
"版本差異(leo 2026-07-22 定調):頁面都存在,由 profile 決定顯示哪些。",
|
||||||
|
" personal(full) 個人版:7 頁全開,落地駕駛艙",
|
||||||
|
" enterprise(rag) 企業版:只留 搜尋/工作流/設定/card,落地搜尋頁",
|
||||||
|
" 未來擴充:個人版新用戶上限 1、知識庫權限不可用 → 加在對應目標的欄位裡,別再散進部署指令。",
|
||||||
|
"",
|
||||||
|
"用法:npm run deploy:personal / npm run deploy:enterprise"
|
||||||
|
],
|
||||||
|
|
||||||
|
"personal": {
|
||||||
|
"description": "leo 私人實例(原 Mira)。入口 mira.uncle6.me → leo21c worker。",
|
||||||
|
"accountId": "51a01bfa2665bd7bc3fd080dc40cf3e1",
|
||||||
|
"projectName": "arcrun-console-ui",
|
||||||
|
"profile": "full",
|
||||||
|
"brand": "Arcrun",
|
||||||
|
"apiBase": "https://arcrun-cypher-executor.leo21c.workers.dev",
|
||||||
|
"limits": {
|
||||||
|
"maxUsers": 1,
|
||||||
|
"libraryPermissions": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"enterprise": {
|
||||||
|
"description": "企業版 demo 站。rag-demo.arcrun.dev → uncle6 帳號 cypher。",
|
||||||
|
"accountId": "58309bb90fd93ad6d0fe0aae99170e9d",
|
||||||
|
"projectName": "arcrun-console-ui",
|
||||||
|
"profile": "rag",
|
||||||
|
"brand": "Arcrun",
|
||||||
|
"apiBase": "https://cypher.arcrun.dev",
|
||||||
|
"limits": {
|
||||||
|
"maxUsers": null,
|
||||||
|
"libraryPermissions": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
"description": "Arcrun Console / Portal 靜態前端(Cloudflare Pages)——從 cypher-executor 搬出的 UI 層",
|
"description": "Arcrun Console / Portal 靜態前端(Cloudflare Pages)——從 cypher-executor 搬出的 UI 層",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node scripts/build.mjs",
|
"build": "node scripts/build.mjs",
|
||||||
"deploy": "npm run build && wrangler pages deploy public --project-name arcrun-console-ui",
|
"deploy": "node scripts/deploy.mjs",
|
||||||
|
"deploy:personal": "node scripts/deploy.mjs personal",
|
||||||
|
"deploy:enterprise": "node scripts/deploy.mjs enterprise",
|
||||||
"preview": "npm run build && npx serve public"
|
"preview": "npm run build && npx serve public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
||||||
window.ARCRUN_CONFIG = { apiBase: "" };
|
window.ARCRUN_CONFIG = { apiBase: "https://cypher.arcrun.dev" };
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
.theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; }
|
.theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; }
|
||||||
</style>
|
</style>
|
||||||
<script src="/config.js"></script>
|
<script src="/config.js"></script>
|
||||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";</script>
|
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -220,7 +220,7 @@
|
|||||||
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
|
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
|
||||||
</style>
|
</style>
|
||||||
<script src="/config.js"></script>
|
<script src="/config.js"></script>
|
||||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";</script>
|
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
|
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">這是一個人的智慧總部。<br>若你不是擁有者,這裡沒有你要找的東西。</div>
|
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">這是一個人的智慧總部。<br>若你不是擁有者,這裡沒有你要找的東西。</div>
|
||||||
<a href="/console/dashboard" style="font-size:13.5px;color:rgba(var(--amber-rgb),.7)">免登入看駕駛艙 ›</a>
|
|
||||||
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
|
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,11 +266,10 @@
|
|||||||
<div id="shell">
|
<div id="shell">
|
||||||
<div id="sidenav">
|
<div id="sidenav">
|
||||||
<div class="logo">Arcrun</div>
|
<div class="logo">Arcrun</div>
|
||||||
<div class="nav" data-nav="cockpit">駕駛艙</div>
|
|
||||||
<div class="nav" data-nav="search">總庫搜尋</div>
|
<div class="nav" data-nav="search">總庫搜尋</div>
|
||||||
<div class="nav" data-nav="workflows">工作流</div>
|
<div class="nav" data-nav="workflows">工作流</div>
|
||||||
<div class="nav" data-nav="creds">憑證管理</div>
|
|
||||||
<div class="nav" data-nav="inbox">分流台</div>
|
|
||||||
<div class="nav" data-nav="settings">設定</div>
|
<div class="nav" data-nav="settings">設定</div>
|
||||||
<div class="nav themelabel" data-themetoggle style="margin-top:8px;font-size:14.5px">☾ 切深色</div>
|
<div class="nav themelabel" data-themetoggle style="margin-top:8px;font-size:14.5px">☾ 切深色</div>
|
||||||
<div class="foot" id="side-foot"></div>
|
<div class="foot" id="side-foot"></div>
|
||||||
@@ -442,13 +441,7 @@
|
|||||||
<div id="st-pw-status" style="font-size:14px;min-height:1.2em"></div>
|
<div id="st-pw-status" style="font-size:14px;min-height:1.2em"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel" style="display:flex;align-items:center;gap:12px;cursor:pointer" data-nav="creds">
|
|
||||||
<div>
|
|
||||||
<div style="font-size:17px;font-weight:600">憑證管理</div>
|
|
||||||
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)">只保存目錄與加密值,不可回看</div>
|
|
||||||
</div>
|
|
||||||
<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);font-size:18px">›</span>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div style="font-size:17px;font-weight:600;margin-bottom:12px">系統資訊</div>
|
<div style="font-size:17px;font-weight:600;margin-bottom:12px">系統資訊</div>
|
||||||
<div id="st-info"><div class="muted">載入中…</div></div>
|
<div id="st-info"><div class="muted">載入中…</div></div>
|
||||||
@@ -462,10 +455,10 @@
|
|||||||
|
|
||||||
<!-- 手機底部導覽 -->
|
<!-- 手機底部導覽 -->
|
||||||
<div id="tabbar">
|
<div id="tabbar">
|
||||||
<div class="tab" data-nav="cockpit">駕駛艙</div>
|
|
||||||
<div class="tab" data-nav="search">搜尋</div>
|
<div class="tab" data-nav="search">搜尋</div>
|
||||||
<div class="tab" data-nav="workflows">工作流</div>
|
<div class="tab" data-nav="workflows">工作流</div>
|
||||||
<div class="tab" data-nav="inbox">分流台</div>
|
|
||||||
<div class="tab" data-nav="settings">設定</div>
|
<div class="tab" data-nav="settings">設定</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -600,8 +593,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
// ── 路由 ──
|
// ── 路由 ──
|
||||||
// VIEWS / HOME 由 server 依 CONSOLE_PROFILE 注入(full=全套 7 頁落地駕駛艙;rag=4 頁落地搜尋)。
|
// VIEWS / HOME 由 server 依 CONSOLE_PROFILE 注入(full=全套 7 頁落地駕駛艙;rag=4 頁落地搜尋)。
|
||||||
// 不在 VIEWS 裡的 hash(含被 profile 裁掉的頁)一律導回 HOME——手打 #/cockpit 也進不去。
|
// 不在 VIEWS 裡的 hash(含被 profile 裁掉的頁)一律導回 HOME——手打 #/cockpit 也進不去。
|
||||||
var VIEWS = ["cockpit","search","card","workflows","creds","inbox","settings"];
|
var VIEWS = ["search","card","workflows","settings"];
|
||||||
var HOME = "cockpit";
|
var HOME = "search";
|
||||||
function currentRoute() {
|
function currentRoute() {
|
||||||
var h = location.hash.replace(/^#\/?/, '');
|
var h = location.hash.replace(/^#\/?/, '');
|
||||||
var seg = h.split('/');
|
var seg = h.split('/');
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Arcrun RAG</title>
|
||||||
|
<!--
|
||||||
|
根目錄直接導向搜尋 Portal。
|
||||||
|
|
||||||
|
為什麼不做「選擇介面」的導覽頁(2026-07-21 leo 實際撞到):
|
||||||
|
這個網域(rag-demo.arcrun.dev)是給**客戶測試**用的入口,
|
||||||
|
客戶測試指南寫的就是「一個網址、一組帳密」——多一層選擇=多一個困惑點,
|
||||||
|
而且會讓客戶看到 Admin Console 這個維運介面(不該對客戶露出)。
|
||||||
|
|
||||||
|
維運者要進 console 直接打 /console/ 即可。
|
||||||
|
-->
|
||||||
|
<meta http-equiv="refresh" content="0; url=/portal/">
|
||||||
|
<link rel="canonical" href="/portal/">
|
||||||
|
<script>location.replace('/portal/');</script>
|
||||||
|
<style>
|
||||||
|
body{margin:0;min-height:100vh;display:grid;place-items:center;
|
||||||
|
background:#faf8f5;color:#6b635a;
|
||||||
|
font:15px/1.7 system-ui,-apple-system,"Noto Sans TC",sans-serif}
|
||||||
|
@media(prefers-color-scheme:dark){body{background:#1a1816;color:#9a9186}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>正在前往搜尋頁… <a href="/portal/">沒有自動跳轉請點這裡</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
|
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
|
||||||
</style>
|
</style>
|
||||||
<script src="/config.js"></script>
|
<script src="/config.js"></script>
|
||||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";</script>
|
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
* 故注入 window.ARCRUN_API_BASE,並把 fetch 的相對路徑改成 API_BASE + path。
|
* 故注入 window.ARCRUN_API_BASE,並把 fetch 的相對路徑改成 API_BASE + path。
|
||||||
* 見下方 rewriteFetchPaths()。
|
* 見下方 rewriteFetchPaths()。
|
||||||
*/
|
*/
|
||||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -30,13 +30,28 @@ const OUT = join(ROOT, 'public');
|
|||||||
// ── 建置期組態(原本是 Worker 的 env var,現在是建置參數)────────────────
|
// ── 建置期組態(原本是 Worker 的 env var,現在是建置參數)────────────────
|
||||||
// Pages 是靜態站,沒有 per-request env;品牌/profile 這類「一個部署一個值」的
|
// Pages 是靜態站,沒有 per-request env;品牌/profile 這類「一個部署一個值」的
|
||||||
// 設定改在建置時決定(要換值=重跑 build 再部署,符合靜態站模型)。
|
// 設定改在建置時決定(要換值=重跑 build 再部署,符合靜態站模型)。
|
||||||
|
// 具名部署目標(deploy.targets.json):一個目標=帳號+profile+apiBase 綁在一起。
|
||||||
|
// 帶 DEPLOY_TARGET=personal|enterprise 就套用該組值;個別環境變數仍可覆蓋(除錯用)。
|
||||||
|
// 立此檔的原因見 deploy.targets.json 的 _readme——散在部署指令裡的參數帶漏過三次。
|
||||||
|
const TARGET_NAME = process.env.DEPLOY_TARGET || '';
|
||||||
|
let TARGET = {};
|
||||||
|
if (TARGET_NAME) {
|
||||||
|
const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
|
||||||
|
TARGET = targets[TARGET_NAME];
|
||||||
|
if (!TARGET) {
|
||||||
|
const names = Object.keys(targets).filter((k) => !k.startsWith('_'));
|
||||||
|
throw new Error(`未知的 DEPLOY_TARGET:"${TARGET_NAME}"。可用:${names.join(' / ')}`);
|
||||||
|
}
|
||||||
|
console.log(`部署目標:${TARGET_NAME} — ${TARGET.description}`);
|
||||||
|
}
|
||||||
|
|
||||||
const CFG = {
|
const CFG = {
|
||||||
brand: process.env.CONSOLE_BRAND || 'Arcrun',
|
brand: process.env.CONSOLE_BRAND || TARGET.brand || 'Arcrun',
|
||||||
profile: process.env.CONSOLE_PROFILE || 'full',
|
profile: process.env.CONSOLE_PROFILE || TARGET.profile || 'full',
|
||||||
registryBase: process.env.REGISTRY_BASE || 'https://registry.arcrun.dev',
|
registryBase: process.env.REGISTRY_BASE || 'https://registry.arcrun.dev',
|
||||||
sourceWebBase: process.env.PORTAL_SOURCE_WEB_BASE || '',
|
sourceWebBase: process.env.PORTAL_SOURCE_WEB_BASE || '',
|
||||||
// API base 走 runtime 注入(見 public/config.js),這裡只放預設值
|
// API base 走 runtime 注入(見 public/config.js),這裡只放預設值
|
||||||
apiBase: process.env.ARCRUN_API_BASE || '',
|
apiBase: process.env.ARCRUN_API_BASE || TARGET.apiBase || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,8 +61,24 @@ const CFG = {
|
|||||||
* (如 console.ts 的 rag/views/home 由 profile 推導)。只搬模板=把那段推導邏輯
|
* (如 console.ts 的 rag/views/home 由 profile 推導)。只搬模板=把那段推導邏輯
|
||||||
* 複製一份到本檔=雙份真相會漂移。連 body 一起求值 → 推導邏輯永遠只有一份。
|
* 複製一份到本檔=雙份真相會漂移。連 body 一起求值 → 推導邏輯永遠只有一份。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* renderer 原始檔的位置:本專案 `console-ui/src/` 優先,找不到才回退 cypher-executor。
|
||||||
|
*
|
||||||
|
* 為什麼要這層(2026-07-22 修):`5a16484` 把 UI 搬出 cypher-executor 時,
|
||||||
|
* **刪了 console.ts / portal-ui.ts 卻只搬走 build 產物(HTML),原始檔沒跟著搬**
|
||||||
|
* → build.mjs 讀不到來源,`npm run build` 從那天起就 ENOENT 死掉,
|
||||||
|
* 線上 HTML 是刪檔前烤好的、之後再也無法重建(profile 改了也不會生效)。
|
||||||
|
* 現已從 git 撈回放進 console-ui/src/——UI 原始碼跟著 UI 專案走,才是那一刀的原意。
|
||||||
|
* console-dashboard.ts 仍在 cypher-executor(它同時含 API),故保留回退路徑。
|
||||||
|
*/
|
||||||
|
function resolveSource(file) {
|
||||||
|
const local = join(ROOT, 'src', file.replace(/^routes\//, ''));
|
||||||
|
if (existsSync(local)) return local;
|
||||||
|
return join(SRC, file);
|
||||||
|
}
|
||||||
|
|
||||||
function extractRendererBody(file, fnName) {
|
function extractRendererBody(file, fnName) {
|
||||||
const code = readFileSync(join(SRC, file), 'utf8');
|
const code = readFileSync(resolveSource(file), 'utf8');
|
||||||
const start = code.indexOf(`function ${fnName}(`);
|
const start = code.indexOf(`function ${fnName}(`);
|
||||||
if (start < 0) throw new Error(`找不到 ${fnName} in ${file}`);
|
if (start < 0) throw new Error(`找不到 ${fnName} in ${file}`);
|
||||||
const braceStart = code.indexOf('{', code.indexOf(')', start));
|
const braceStart = code.indexOf('{', code.indexOf(')', start));
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* deploy.mjs — 依具名目標部署 console-ui 到 Cloudflare Pages
|
||||||
|
*
|
||||||
|
* 用法:npm run deploy:personal / npm run deploy:enterprise
|
||||||
|
*
|
||||||
|
* 為什麼不直接用 `wrangler pages deploy`(2026-07-22 leo 立,實際踩到才補):
|
||||||
|
* **兩個帳號都有名為 arcrun-console-ui 的 Pages 專案**
|
||||||
|
* · leo21c → arcrun-console-ui.pages.dev(個人版 console)
|
||||||
|
* · uncle6 → 綁 rag-demo.arcrun.dev(企業版 demo 站)
|
||||||
|
* wrangler 若 OAuth 登入在 uncle6,`--project-name arcrun-console-ui` 會部到 demo 站上。
|
||||||
|
* 本腳本強制帶目標的 accountId,並在部署前印出目標,避免部錯帳號。
|
||||||
|
*
|
||||||
|
* 同時把 profile/apiBase 綁進目標(deploy.targets.json),不再靠部署者記得帶環境變數——
|
||||||
|
* 帶漏過三次:demo 站漏 profile=rag 顯示成個人版、兩站漏 apiBase 導致登入 405。
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
|
||||||
|
const names = Object.keys(targets).filter((k) => !k.startsWith('_'));
|
||||||
|
|
||||||
|
const name = process.argv[2];
|
||||||
|
if (!name || !targets[name]) {
|
||||||
|
console.error(`用法:npm run deploy:<target>\n可用目標:${names.join(' / ')}`);
|
||||||
|
if (name) console.error(`(收到未知目標:"${name}")`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const t = targets[name];
|
||||||
|
|
||||||
|
console.log(`\n部署目標:${name}`);
|
||||||
|
console.log(` 說明 :${t.description}`);
|
||||||
|
console.log(` 帳號 :${t.accountId}`);
|
||||||
|
console.log(` 專案 :${t.projectName}`);
|
||||||
|
console.log(` profile :${t.profile}`);
|
||||||
|
console.log(` apiBase :${t.apiBase}\n`);
|
||||||
|
|
||||||
|
const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId };
|
||||||
|
|
||||||
|
const build = spawnSync('node', [join(ROOT, 'scripts', 'build.mjs')], { stdio: 'inherit', env });
|
||||||
|
if (build.status !== 0) process.exit(build.status ?? 1);
|
||||||
|
|
||||||
|
// --commit-dirty:本地部署常有未提交變更,不因此中斷
|
||||||
|
const deploy = spawnSync(
|
||||||
|
'npx',
|
||||||
|
['wrangler', 'pages', 'deploy', 'public', '--project-name', t.projectName, '--commit-dirty=true'],
|
||||||
|
{ stdio: 'inherit', cwd: ROOT, env },
|
||||||
|
);
|
||||||
|
process.exit(deploy.status ?? 1);
|
||||||
@@ -0,0 +1,822 @@
|
|||||||
|
/**
|
||||||
|
* arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
|
||||||
|
* 2026-07-07 fix/console-dashboard-live-data:stale 資料整修,總管交辦)
|
||||||
|
*
|
||||||
|
* 端點皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
|
||||||
|
* - GET /console/dashboard-data:聚合 JSON。
|
||||||
|
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),每 60 秒自動刷新。
|
||||||
|
* - GET /console/kb-scale-data:精耕層規模(wiki 卡/三元組/已嵌入;2026-07-07 leo 裁
|
||||||
|
* 「遺產庫不用顯示」後 console 頭部統計改讀這裡)。
|
||||||
|
* - GET /console/settings-data:設定頁誠實系統值(MCP token TTL 佔位)。
|
||||||
|
*
|
||||||
|
* ── 2026-07-07 二修(fix/console-truth-audit):「今日完成/今日路線」接 sprint 任務板 ──
|
||||||
|
* leo 拍板(「今天做了這麼多事……其實就是我們到底完成了多少事」):dash_task 同 dash_wait
|
||||||
|
* 病(沒活管線),真相源=sprint 檔「## 任務板」勾選。比照「等你的事」#36 模式:同一輪
|
||||||
|
* Gitea fetch(90s 快取共用)解析任務板,「今日完成」只認「完成(今天台北日)」標記,
|
||||||
|
* dash_task 降 fallback;板檔今天沒 commit → 頁面誠實標「今日任務板未更新(最後 N 小時前)」。
|
||||||
|
*
|
||||||
|
* ── 2026-07-07 整修:每個區塊都讀「live 一手資料」,讀不到就誠實標示,不擺 stale 殘骸 ──
|
||||||
|
*
|
||||||
|
* 資料源診斷(leo 抱怨「等你的事錯了好幾天」的根因):
|
||||||
|
* - dash_wait(等你的事舊資料源)最後寫入 2026-07-04,**沒有活的維護管線**——等leo清單#11
|
||||||
|
* 已於 07-05 銷案(誤判),dashboard 卻繼續掛著它。真相源其實是 InkStoneCo sprint 檔的
|
||||||
|
* 「## 等 leo 清單」表格(progress-guard routine 每日核實維護)。
|
||||||
|
* - dash_task 的 scope:"today" 沒有日期——07-04 的「今日路線」到 07-07 還被當今天的。
|
||||||
|
* - dash_beat 是唯一有活管線的 dash_*(progress-guard/cloud-worker/watchdog 每日寫入)。
|
||||||
|
*
|
||||||
|
* 整修後的資料源:
|
||||||
|
* 等你的事 → 首選 Gitea sprint 檔等leo清單(需 GITEA_BASE_URL var + GITEA_TOKEN secret;
|
||||||
|
* 進程內 fetch Gitea API,非 GitHub、無 D20 疑慮);讀不到 → fallback dash_wait
|
||||||
|
* 但必標 age + stale 警示;連 dash_wait 都沒有 → 誠實顯示「管線未接」。
|
||||||
|
* 今日路線 → dash_task,但以台北日曆日判 is_today;非今日寫入=降級顯示「最後路線(N 天前)」,
|
||||||
|
* 不假裝是今天的。今日無寫入時明講管線缺口(sprint 任務板→dashboard 無自動投影)。
|
||||||
|
* 系統狀況 → live 健康信號:KBDB /health、/embed/backfill/status(enabled:false 誠實顯示)、
|
||||||
|
* kbdb-graph-plugin /triplets/stats、workflow 總數(KBDB entry_type=workflow)。
|
||||||
|
* 總庫規模 → KBDB entries 總數/wiki_card 數/triplets 數,全部 live API 一手拉。
|
||||||
|
*
|
||||||
|
* 燈號判定(寫死在端點,頁面只渲染):
|
||||||
|
* red = 「今日寫入」的任務有 blocked,或最新心跳距今 > 240 分(台北 09:00-22:00 窗內判定),
|
||||||
|
* 或 KBDB /health 打不通。stale 殘任務**不再**觸發燈號(07-04 的 blocked 不該讓 07-07 亮紅)。
|
||||||
|
* yellow = 無 red 條件,但今日任務有非標準 status(late/behind 等落後標記)。
|
||||||
|
* green = 其餘。
|
||||||
|
*
|
||||||
|
* 薄殼定位:聚合端點(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯;判定純函式抽在
|
||||||
|
* lib/console-dashboard-model.ts(可單測)。讀 KBDB 走 HTTP(kbdbBase 慣例),不新增 binding。
|
||||||
|
*/
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { Bindings } from '../types';
|
||||||
|
import { kbdbBase, graphBase } from './kbdb-proxy';
|
||||||
|
import { validateConsoleSession } from './console-auth';
|
||||||
|
import {
|
||||||
|
type KbdbEntry,
|
||||||
|
type WaitingItem,
|
||||||
|
type WaitingModel,
|
||||||
|
type CachedWaitingEnvelope,
|
||||||
|
type SprintBoardTask,
|
||||||
|
type SprintSnapshot,
|
||||||
|
GITEA_WAITING_CACHE_TTL_SECONDS,
|
||||||
|
parseCreatedAtMs,
|
||||||
|
parseJsonContent,
|
||||||
|
agoMinutes,
|
||||||
|
buildRouteModel,
|
||||||
|
buildSprintRouteModel,
|
||||||
|
buildWaitingFallback,
|
||||||
|
parseSprintTaskBoard,
|
||||||
|
parseSprintWaitingTable,
|
||||||
|
pickLatestSprintFiles,
|
||||||
|
reviveWaitingAges,
|
||||||
|
sortWaitingItems,
|
||||||
|
taipeiDayKey,
|
||||||
|
} from '../lib/console-dashboard-model';
|
||||||
|
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
|
||||||
|
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
|
||||||
|
|
||||||
|
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
|
const STALE_MINUTES = 240;
|
||||||
|
const JUDGE_START_HOUR = 9; // 台北時間,含
|
||||||
|
const JUDGE_END_HOUR = 22; // 台北時間,不含
|
||||||
|
const STANDARD_TASK_STATUS = new Set(['done', 'doing', 'todo', 'blocked']);
|
||||||
|
|
||||||
|
async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise<KbdbEntry[]> {
|
||||||
|
const { base, headers } = kbdbBase(env);
|
||||||
|
const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = (await res.json()) as { entries?: KbdbEntry[] };
|
||||||
|
return data.entries ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 泛用 GET JSON(失敗回 null,caller 誠實顯示「讀不到」,不編數字)。 */
|
||||||
|
async function fetchJson<T>(url: string, headers?: Record<string, string>): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, headers ? { headers } : undefined);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
|
||||||
|
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
|
||||||
|
const { base, headers } = kbdbBase(env);
|
||||||
|
const params = new URLSearchParams({ ...filters, limit: '1' });
|
||||||
|
const data = await fetchJson<{ total?: unknown }>(`${base}/entries?${params.toString()}`, headers);
|
||||||
|
return data && typeof data.total === 'number' ? data.total : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sprint 檔活資料源(同一輪 fetch 兩個產物,leo 2026-07-07 拍板加「今日完成」):
|
||||||
|
* - 「等你的事」=「## 等 leo 清單」表格(progress-guard 每日維護)。
|
||||||
|
* - 「今日完成/今日路線」=「## 任務板」checkbox(今天勾的才算今日完成)。
|
||||||
|
* 需 GITEA_BASE_URL(var)+ GITEA_TOKEN(secret,建議唯讀 scope)。請求序:
|
||||||
|
* 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項/未收項,例:07b 開了、
|
||||||
|
* 🔴 mira 憑證外洩與 [🔄] T-cockpit 仍掛 07a)→ 各抓 raw、兩個 parser 吃同一份文字 →
|
||||||
|
* 最新檔的最後 commit 時間當「維護於」。等leo清單全解析失敗回 null → caller fallback
|
||||||
|
* dash_wait / dash_task(標 age),不硬湊。
|
||||||
|
*/
|
||||||
|
async function fetchGiteaSprint(env: Bindings, nowMs: number): Promise<SprintSnapshot | null> {
|
||||||
|
const base = (env.GITEA_BASE_URL ?? '').replace(/\/$/, '');
|
||||||
|
const token = env.GITEA_TOKEN;
|
||||||
|
if (!base || !token) return null;
|
||||||
|
const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
|
||||||
|
const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
|
||||||
|
const headers = { Authorization: `token ${token}` };
|
||||||
|
try {
|
||||||
|
const files = await fetchJson<{ name: string }[]>(`${base}/api/v1/repos/${repo}/contents/${encodeURI(dir)}`, headers);
|
||||||
|
if (!files) return null;
|
||||||
|
const sprints = pickLatestSprintFiles(files.map((f) => f.name));
|
||||||
|
if (!sprints.length) return null;
|
||||||
|
const parsed = await Promise.all(
|
||||||
|
sprints.map(async (name) => {
|
||||||
|
const rawRes = await fetch(`${base}/api/v1/repos/${repo}/raw/${encodeURI(`${dir}/${name}`)}`, { headers });
|
||||||
|
if (!rawRes.ok) return null;
|
||||||
|
const text = await rawRes.text();
|
||||||
|
return { waiting: parseSprintWaitingTable(text, name), board: parseSprintTaskBoard(text, name) };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const readFiles = sprints.filter((_, i) => parsed[i]?.waiting != null);
|
||||||
|
const merged = parsed.map((p) => p?.waiting).filter((p): p is WaitingItem[] => p != null).flat();
|
||||||
|
if (!readFiles.length) return null; // 等leo清單全部解析失敗=誠實 fallback
|
||||||
|
// 任務板:新→舊合併(現役 sprint 的板先列);兩檔都沒有可解析的板 → null(fallback dash_task)
|
||||||
|
const boardMerged = parsed.map((p) => p?.board).filter((b): b is SprintBoardTask[] => b != null).flat();
|
||||||
|
// 清單上次維護時間 = 現役 sprint 檔最後 commit(progress-guard 每日 commit,>48h 沒動才算 stale)
|
||||||
|
let ago = -1;
|
||||||
|
const commits = await fetchJson<{ commit?: { committer?: { date?: string } } }[]>(
|
||||||
|
`${base}/api/v1/repos/${repo}/commits?path=${encodeURIComponent(`${dir}/${readFiles[0]}`)}&limit=1&stat=false&verification=false&files=false`,
|
||||||
|
headers,
|
||||||
|
);
|
||||||
|
const date = commits?.[0]?.commit?.committer?.date;
|
||||||
|
if (date) {
|
||||||
|
const ms = Date.parse(date);
|
||||||
|
if (!Number.isNaN(ms)) ago = agoMinutes(nowMs, ms);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
waiting: {
|
||||||
|
items: sortWaitingItems(merged),
|
||||||
|
source: 'gitea_sprint',
|
||||||
|
updated_ago_minutes: ago,
|
||||||
|
stale: ago >= 0 && ago > 48 * 60,
|
||||||
|
sprint_files: readFiles,
|
||||||
|
},
|
||||||
|
board: boardMerged.length ? boardMerged : null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GiteaSprintFetcher = (env: Bindings, nowMs: number) => Promise<SprintSnapshot | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetchGiteaSprint 的快取層(總管 #36 審查要求):CF Cache API(caches.default)、
|
||||||
|
* TTL 90s(GITEA_WAITING_CACHE_TTL_SECONDS)。前端 60 秒刷新下,Gitea 從
|
||||||
|
* 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」;快取是查詢面的讀優化,不是輪詢。
|
||||||
|
*
|
||||||
|
* - key:合成 URL(Cache API 要求合法 URL;host 用不會真的被打的保留名),帶
|
||||||
|
* base/repo/dir 參數——設定變了自然 miss,不會吐到別的 Gitea 的殘資料。
|
||||||
|
* - hit 回放時用 reviveWaitingAges 把「維護於 N 分鐘前」隨牆鐘補算(存的是 fetch
|
||||||
|
* 當下的 ago,直接回放會讓時間停走)。任務板存原始 completed_days,「今天完成幾件」
|
||||||
|
* 由請求當下算——跨台北午夜的快取不會把昨天的完成冒領成今天。
|
||||||
|
* - **失敗不快取**:negative cache 會把一時網路抖動放大成 90 秒盲區,caller 該
|
||||||
|
* 當場 fallback dash_wait / dash_task。
|
||||||
|
* - cache.put 走 waitUntil(不阻塞回應);fetcher 參數可注入=單測不用真打網路。
|
||||||
|
* - 回傳多帶 cache:'hit'|'miss',吐進 waiting_meta 當快取生效的客觀證據(curl 兩次
|
||||||
|
* 第二次該是 hit)。
|
||||||
|
*/
|
||||||
|
export async function cachedGiteaSprint(
|
||||||
|
env: Bindings,
|
||||||
|
nowMs: number,
|
||||||
|
waitUntil: (p: Promise<unknown>) => void,
|
||||||
|
fetcher: GiteaSprintFetcher = fetchGiteaSprint,
|
||||||
|
): Promise<(SprintSnapshot & { cache: 'hit' | 'miss' }) | null> {
|
||||||
|
if (!env.GITEA_BASE_URL || !env.GITEA_TOKEN) return null;
|
||||||
|
const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
|
||||||
|
const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
|
||||||
|
const cacheKey = new Request(
|
||||||
|
`https://console-dashboard.arcrun.internal/gitea-waiting?${new URLSearchParams({ base: env.GITEA_BASE_URL, repo, dir }).toString()}`,
|
||||||
|
);
|
||||||
|
const cache = caches.default;
|
||||||
|
try {
|
||||||
|
const hit = await cache.match(cacheKey);
|
||||||
|
if (hit) {
|
||||||
|
const envelope = (await hit.json()) as CachedWaitingEnvelope;
|
||||||
|
return {
|
||||||
|
waiting: reviveWaitingAges(envelope.snapshot.waiting, envelope.fetched_at_ms, nowMs),
|
||||||
|
board: envelope.snapshot.board,
|
||||||
|
cache: 'hit',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* cache 故障不致命,走 miss 路徑 */
|
||||||
|
}
|
||||||
|
const fresh = await fetcher(env, nowMs);
|
||||||
|
if (!fresh) return null; // 失敗不快取,caller 誠實 fallback
|
||||||
|
const envelope: CachedWaitingEnvelope = { snapshot: fresh, fetched_at_ms: nowMs };
|
||||||
|
try {
|
||||||
|
waitUntil(
|
||||||
|
cache.put(
|
||||||
|
cacheKey,
|
||||||
|
new Response(JSON.stringify(envelope), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': `public, max-age=${GITEA_WAITING_CACHE_TTL_SECONDS}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* put 失敗只是少了快取,不影響本次回應 */
|
||||||
|
}
|
||||||
|
return { ...fresh, cache: 'miss' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
|
||||||
|
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||||
|
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||||
|
const now = Date.now();
|
||||||
|
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||||
|
const graphUrl = graphBase(c.env);
|
||||||
|
|
||||||
|
const [
|
||||||
|
beatEntries,
|
||||||
|
taskEntries,
|
||||||
|
waitEntries,
|
||||||
|
inboxEntries,
|
||||||
|
giteaSprint,
|
||||||
|
kbdbHealth,
|
||||||
|
embedStatus,
|
||||||
|
graphStats,
|
||||||
|
entriesTotal,
|
||||||
|
wikiCardTotal,
|
||||||
|
workflowTotal,
|
||||||
|
] = await Promise.all([
|
||||||
|
fetchEntries(c.env, tenant, 'dash_beat', 100),
|
||||||
|
fetchEntries(c.env, tenant, 'dash_task', 200),
|
||||||
|
fetchEntries(c.env, tenant, 'dash_wait', 100),
|
||||||
|
fetchEntries(c.env, tenant, 'inbox', 200),
|
||||||
|
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
|
||||||
|
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
|
||||||
|
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
|
||||||
|
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
|
||||||
|
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732)
|
||||||
|
fetchEntryTotal(c.env, { owner_id: tenant }),
|
||||||
|
fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
|
||||||
|
fetchEntryTotal(c.env, { entry_type: 'workflow', owner_id: tenant }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)。唯一有活管線的 dash_*。
|
||||||
|
const beats: { actor: string; event: string; note: string; at: string | number; ago_minutes: number }[] = [];
|
||||||
|
const seenActors = new Set<string>();
|
||||||
|
for (const e of beatEntries) {
|
||||||
|
const j = parseJsonContent(e);
|
||||||
|
const actor = typeof j?.actor === 'string' ? j.actor : null;
|
||||||
|
if (!actor || seenActors.has(actor)) continue;
|
||||||
|
seenActors.add(actor);
|
||||||
|
const ms = parseCreatedAtMs(e.created_at);
|
||||||
|
beats.push({
|
||||||
|
actor,
|
||||||
|
event: typeof j?.event === 'string' ? (j.event as string) : '',
|
||||||
|
note: typeof j?.note === 'string' ? (j.note as string) : '',
|
||||||
|
at: e.created_at,
|
||||||
|
ago_minutes: agoMinutes(now, ms),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null;
|
||||||
|
|
||||||
|
// 等你的事:Gitea sprint 等leo清單優先(走 90s 快取);讀不到 fallback dash_wait(帶 age + stale)
|
||||||
|
let waiting: WaitingModel;
|
||||||
|
let waitingCache: 'hit' | 'miss' | null = null;
|
||||||
|
if (giteaSprint) {
|
||||||
|
waiting = giteaSprint.waiting;
|
||||||
|
waitingCache = giteaSprint.cache;
|
||||||
|
} else {
|
||||||
|
waiting = buildWaitingFallback(waitEntries, now);
|
||||||
|
if (waiting.source === 'kbdb_dash_wait' && !(c.env.GITEA_BASE_URL && c.env.GITEA_TOKEN)) {
|
||||||
|
waiting.note = 'Gitea sprint 清單未接(缺 GITEA_TOKEN secret)——以下是 dash_wait 殘資料';
|
||||||
|
} else if (waiting.source === 'kbdb_dash_wait') {
|
||||||
|
waiting.note = 'Gitea sprint 清單讀取失敗——以下是 dash_wait 殘資料';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 今日完成/今日路線:sprint 任務板優先(leo 2026-07-07 拍板——「到底完成了多少事」的
|
||||||
|
// 真相源=progress-guard/cloud-worker 每日勾選的板,dash_task 沒活管線降 fallback)。
|
||||||
|
// 板的「今日完成」只認「完成(今天台北日)」標記;板檔今天沒 commit 過 → 誠實標示。
|
||||||
|
const sprintRoute = giteaSprint?.board ? buildSprintRouteModel(giteaSprint.board, now) : null;
|
||||||
|
const route = buildRouteModel(taskEntries, now); // fallback + 燈號仍吃 dash_task 今日寫入
|
||||||
|
const boardAgo = giteaSprint ? giteaSprint.waiting.updated_ago_minutes : -1;
|
||||||
|
const boardUpdatedToday = boardAgo >= 0 && taipeiDayKey(now - boardAgo * 60000) === taipeiDayKey(now);
|
||||||
|
|
||||||
|
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
|
||||||
|
const inboxNew = inboxEntries.reduce((n, e) => {
|
||||||
|
const j = parseJsonContent(e);
|
||||||
|
return j && j.status !== 'done' ? n + 1 : n;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// 燈號:只吃「今日寫入」的任務 + 心跳 + KBDB 健康(stale 殘任務不再觸發燈號)
|
||||||
|
const todayWrites = route.tasks.filter((t) => t.is_today_write);
|
||||||
|
const hasBlocked = todayWrites.some((t) => t.status === 'blocked');
|
||||||
|
const hasLagMark = todayWrites.some((t) => !STANDARD_TASK_STATUS.has(t.status));
|
||||||
|
const taipeiHour = new Date(now + 8 * 3600 * 1000).getUTCHours();
|
||||||
|
const inJudgeWindow = taipeiHour >= JUDGE_START_HOUR && taipeiHour < JUDGE_END_HOUR;
|
||||||
|
const beatStale = lastBeat === null || lastBeat.ago_minutes > STALE_MINUTES;
|
||||||
|
const kbdbOk = kbdbHealth?.ok === true;
|
||||||
|
const light: 'green' | 'yellow' | 'red' =
|
||||||
|
hasBlocked || (inJudgeWindow && beatStale) || !kbdbOk ? 'red' : hasLagMark ? 'yellow' : 'green';
|
||||||
|
const lightReason = !kbdbOk
|
||||||
|
? 'KBDB 基本盤 /health 打不通'
|
||||||
|
: hasBlocked
|
||||||
|
? '今日任務有 blocked'
|
||||||
|
: inJudgeWindow && beatStale
|
||||||
|
? `心跳超過 ${STALE_MINUTES} 分鐘`
|
||||||
|
: hasLagMark
|
||||||
|
? '今日任務有落後標記'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
light,
|
||||||
|
light_reason: lightReason,
|
||||||
|
last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null,
|
||||||
|
beats,
|
||||||
|
// 路線:sprint 任務板優先(tasks 欄位形狀與 dash_task 版相容——title/status/scope);
|
||||||
|
// 板上開著的項 is_today_write=false(燈號沿 #36 原則只吃 dash_task 今日寫入+心跳+KBDB,
|
||||||
|
// 板上掛了幾天的 [!] 不會天天亮紅燈——那是「等裁決」不是「今天卡住」)
|
||||||
|
tasks: sprintRoute
|
||||||
|
? sprintRoute.tasks.map((t, i) => ({
|
||||||
|
title: t.title,
|
||||||
|
status: t.status,
|
||||||
|
order: i,
|
||||||
|
scope: 'today' as const,
|
||||||
|
age_minutes: boardAgo,
|
||||||
|
is_today_write: t.status === 'done', // done 項必然是「今天完成」的(模型已濾)
|
||||||
|
sprint: t.sprint ?? null,
|
||||||
|
}))
|
||||||
|
: route.tasks.map((t) => ({
|
||||||
|
title: t.title,
|
||||||
|
status: t.status,
|
||||||
|
order: t.order,
|
||||||
|
scope: t.scope,
|
||||||
|
age_minutes: t.age_minutes,
|
||||||
|
is_today_write: t.is_today_write,
|
||||||
|
sprint: null,
|
||||||
|
})),
|
||||||
|
route_meta: sprintRoute
|
||||||
|
? {
|
||||||
|
source: 'gitea_sprint_board',
|
||||||
|
// is_today=板檔今天(台北)有 commit 過;false → 頁面誠實標「今日任務板未更新」
|
||||||
|
is_today: boardUpdatedToday,
|
||||||
|
updated_ago_minutes: boardAgo,
|
||||||
|
sprint_files: waiting.sprint_files ?? null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source: 'kbdb_dash_task',
|
||||||
|
is_today: route.is_today,
|
||||||
|
updated_ago_minutes: route.updated_ago_minutes,
|
||||||
|
sprint_files: null,
|
||||||
|
},
|
||||||
|
today_done: sprintRoute ? sprintRoute.today_done : route.today_done,
|
||||||
|
today_total: sprintRoute ? sprintRoute.today_total : route.today_total,
|
||||||
|
done_today_titles: sprintRoute ? sprintRoute.done_today_titles : null,
|
||||||
|
waiting: waiting.items,
|
||||||
|
waiting_meta: {
|
||||||
|
source: waiting.source,
|
||||||
|
updated_ago_minutes: waiting.updated_ago_minutes,
|
||||||
|
stale: waiting.stale,
|
||||||
|
sprint_files: waiting.sprint_files ?? null,
|
||||||
|
note: waiting.note ?? null,
|
||||||
|
// Gitea 快取層狀態(hit/miss;fallback 路徑為 null)——快取生效的客觀證據
|
||||||
|
cache: waitingCache,
|
||||||
|
},
|
||||||
|
inbox_new: inboxNew,
|
||||||
|
system: {
|
||||||
|
kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
|
||||||
|
embed: embedStatus
|
||||||
|
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
|
||||||
|
: null,
|
||||||
|
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
|
||||||
|
workflow_total: workflowTotal,
|
||||||
|
},
|
||||||
|
kb: {
|
||||||
|
entries_total: entriesTotal,
|
||||||
|
wiki_card_total: wikiCardTotal,
|
||||||
|
triplets_total: graphStats?.total ?? null,
|
||||||
|
},
|
||||||
|
generated_at: new Date(now).toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /console/kb-scale-data — 總庫「精耕層」規模(leo 2026-07-07 裁:45.8 萬 14-E 搬遷
|
||||||
|
// blocks 已 deprecated 之後要刪,頭部統計**不再拿遺產數字撐場面**,只顯示真的新的)。
|
||||||
|
// 免登入(純聚合計數、無內容原文,同 dashboard-data 標準)。3 個 subrequest,全是
|
||||||
|
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
|
||||||
|
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
|
||||||
|
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
||||||
|
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||||
|
const { base, headers } = kbdbBase(c.env);
|
||||||
|
const graphUrl = graphBase(c.env);
|
||||||
|
const now = Date.now();
|
||||||
|
const [wikiCards, graphStats, embedStatus] = await Promise.all([
|
||||||
|
// limit=1 順手拿最新一筆 created_at(list 為 created_at DESC)=「最近寫入時間」
|
||||||
|
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
|
||||||
|
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
|
||||||
|
headers,
|
||||||
|
),
|
||||||
|
fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
|
||||||
|
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
|
||||||
|
]);
|
||||||
|
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
|
||||||
|
// 讀不到的欄位誠實回 null(頁面顯示「讀不到」),不編數字
|
||||||
|
return c.json({
|
||||||
|
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
|
||||||
|
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
|
||||||
|
triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
|
||||||
|
embedded: embedStatus?.embedded ?? null,
|
||||||
|
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
|
||||||
|
generated_at: new Date(now).toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /console/settings-data — 設定頁的誠實系統值(目前只有 MCP token TTL 佔位區塊用)。
|
||||||
|
// TTL 真相住在 mcp worker 部署端 env `MCP_TOKEN_TTL`(mcp/src/types.ts,預設 2592000=30 天);
|
||||||
|
// cypher 讀的是自己這份同名 var(deploy 時兩處要一致,#32 形態 config 同步教訓)——
|
||||||
|
// source 欄位如實標 env/default,頁面不假裝這是能遠端改的設定。
|
||||||
|
consoleDashboardRouter.get('/console/settings-data', (c) => {
|
||||||
|
const raw = c.env.MCP_TOKEN_TTL;
|
||||||
|
const parsed = raw ? parseInt(raw, 10) : NaN;
|
||||||
|
const fromEnv = Number.isFinite(parsed) && parsed > 0;
|
||||||
|
return c.json({
|
||||||
|
mcp_token_ttl_seconds: fromEnv ? parsed : 2592000,
|
||||||
|
mcp_token_ttl_source: fromEnv ? 'env' : 'default',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /console/triage-data — 分流台資料(Mira Console 頁 7,Arcrun#9 收件夾改裝;原
|
||||||
|
// /console/inbox-data 的後繼——唯一消費者是 console 頁本身,一起改裝,不留死端點)。
|
||||||
|
// **需 console session**(Bearer):dashboard-data 只吐計數可免登入;這裡吐待辦/訊息原文屬機敏,鎖登入。
|
||||||
|
// 資料源二合一(kb-ingest SDD R7):entry_type=todo(Logseq 萃取,Arcrun#8 ingest 線)+
|
||||||
|
// entry_type=inbox(Telegram)。契約解析/三欄分流/計數=純函式 lib/console-triage-model.ts。
|
||||||
|
consoleDashboardRouter.get('/console/triage-data', async (c) => {
|
||||||
|
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
|
||||||
|
if (!ok) return c.json({ error: '需要登入(console session)' }, 401);
|
||||||
|
|
||||||
|
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||||
|
const [todoEntries, inboxEntries] = await Promise.all([
|
||||||
|
fetchEntries(c.env, tenant, 'todo', 500),
|
||||||
|
fetchEntries(c.env, tenant, 'inbox', 200),
|
||||||
|
]);
|
||||||
|
const model = buildTriageModel(todoEntries, inboxEntries);
|
||||||
|
return c.json({ ...model, generated_at: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /console/triage-check — 分流台勾掉/還原(leo 2026-07-08 拍板;body: {entry_id, action?})。
|
||||||
|
// 為什麼開這個小端點而不讓瀏覽器直打 KBDB:瀏覽器沒有 KBDB_INTERNAL_TOKEN(token 只能在
|
||||||
|
// server 側,同 kbdb-graph proxy 理由),且 console session ≠ X-Arcrun-API-Key。沿用
|
||||||
|
// triage-data 同款 session 驗證,server 端做 KBDB PATCH(kbdbBase 慣例)。
|
||||||
|
//
|
||||||
|
// PATCH content 需**整串回寫**(KBDB updateEntry 是欄位級覆蓋,content 給什麼存什麼)——
|
||||||
|
// 先 GET 原 entry、只動 status/checked_* 欄再回寫,防蓋掉 text/marker/owner_tier 等別的欄位。
|
||||||
|
// 改寫邏輯=lib/console-triage-model.ts applyTriageCheck(純函式,vitest 驗證)。
|
||||||
|
//
|
||||||
|
// ── 雙向銷案語意(死循環防呆,與 applyTriageCheck 註解同一套規約,萃取端會配合)──
|
||||||
|
// console 勾掉=終局(checked_via:"console"):即使 Logseq 原文還是 TODO,萃取端也絕不
|
||||||
|
// 復活它;Logseq 改 DONE 的由萃取端 PATCH status:done(checked_via:"logseq")。
|
||||||
|
// console 只需忠實顯示非 done 項;還原=status 回 new + 移除 checked_via/checked_at。
|
||||||
|
consoleDashboardRouter.post('/console/triage-check', async (c) => {
|
||||||
|
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
|
||||||
|
if (!ok) return c.json({ error: '需要登入(console session)' }, 401);
|
||||||
|
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const entryId = typeof body?.entry_id === 'string' ? body.entry_id.trim() : '';
|
||||||
|
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
|
||||||
|
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
|
||||||
|
|
||||||
|
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||||
|
const { base, headers } = kbdbBase(c.env);
|
||||||
|
|
||||||
|
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
|
||||||
|
// 1. owner_id 必須=console 固定租戶(session 只代表 leo 這個租戶,不能改到別人的資料);
|
||||||
|
// 2. entry_type 限分流台的兩個來源 todo/inbox(這端點不是泛用 entry 改寫器)。
|
||||||
|
const got = await fetchJson<{ entry?: { owner_id?: string; entry_type?: string; content?: string | null } }>(
|
||||||
|
`${base}/entries/${encodeURIComponent(entryId)}`,
|
||||||
|
headers,
|
||||||
|
);
|
||||||
|
const entry = got?.entry;
|
||||||
|
if (!entry) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404);
|
||||||
|
if (entry.owner_id !== tenant) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404); // 不洩漏他租戶存在性
|
||||||
|
if (entry.entry_type !== 'todo' && entry.entry_type !== 'inbox') {
|
||||||
|
return c.json({ error: '只有分流台項目(todo/inbox)能在這裡勾掉' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newContent = applyTriageCheck(entry.content, action, new Date().toISOString());
|
||||||
|
const res = await fetch(`${base}/entries/${encodeURIComponent(entryId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ content: newContent }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return c.json({ error: `KBDB 回寫失敗(HTTP ${res.status})` }, 502);
|
||||||
|
return c.json({ success: true, entry_id: entryId, action, status: action === 'restore' ? 'new' : 'done' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderDashboardHtml(brand: string): string {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${brand} 駕駛艙</title>
|
||||||
|
<script>
|
||||||
|
// 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),與 /console 共用同一 localStorage key
|
||||||
|
document.documentElement.setAttribute('data-theme', (function () {
|
||||||
|
try { return localStorage.getItem('arcrun_console_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }
|
||||||
|
})());
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
/* Mira Console 定稿視覺(紙感「2a」,Mira Style Guide 2026-07-04):
|
||||||
|
紙紋底 repeating-linear-gradient、明體標題級聯、琥珀強調、呼吸狀態球嵌單字。
|
||||||
|
2026-07-04 二輪:CSS custom properties 兩份色板——預設淺色(宣紙米白+墨字),深色=原定稿暖黑不動。 */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
:root {
|
||||||
|
--paper-a: #f4eddc; --paper-b: #f1e9d6;
|
||||||
|
--ink: #2f2a20; --ink-rgb: 30,24,14;
|
||||||
|
--amber: #8a5f1e; --amber-rgb: 138,95,30;
|
||||||
|
--ok: #1d7a48; --ok-rgb: 29,122,72;
|
||||||
|
--err: #b03a26; --err-rgb: 176,58,38;
|
||||||
|
--track: rgba(30,24,14,.12);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--paper-a: #191410; --paper-b: #1b1611;
|
||||||
|
--ink: #ede4d3; --ink-rgb: 237,228,211;
|
||||||
|
--amber: #e8b45a; --amber-rgb: 232,180,90;
|
||||||
|
--ok: #7fe0a8; --ok-rgb: 63,190,120;
|
||||||
|
--err: #e58575; --err-rgb: 217,95,76;
|
||||||
|
--track: rgba(255,255,255,.08);
|
||||||
|
}
|
||||||
|
html, body { margin: 0; background: repeating-linear-gradient(0deg,var(--paper-a) 0px,var(--paper-a) 3px,var(--paper-b) 3px,var(--paper-b) 4px); color: var(--ink);
|
||||||
|
font-family: -apple-system, "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
|
||||||
|
.serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }
|
||||||
|
main { max-width: 560px; margin: 0 auto; padding: 0 20px 40px; }
|
||||||
|
.pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(var(--amber-rgb),.4); display: flex; justify-content: space-between; align-items: baseline; }
|
||||||
|
.pagehead .title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; }
|
||||||
|
.pagehead .title small { font-size: 14px; letter-spacing: .3em; color: rgba(var(--ink-rgb),.5); }
|
||||||
|
.pagehead .date { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 14px; color: rgba(var(--ink-rgb),.55); }
|
||||||
|
.orb-row { display: flex; align-items: center; gap: 20px; padding: 26px 2px 20px; }
|
||||||
|
.orb { width: 84px; height: 84px; border-radius: 50%; flex: none; display: grid; place-items: center; }
|
||||||
|
.orb span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 30px; font-weight: 600; color: rgba(10,20,14,.85); text-shadow: 0 1px 0 rgba(255,255,255,.25); }
|
||||||
|
.orb-title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; font-weight: 600; }
|
||||||
|
.orb-sub { margin-top: 5px; font-size: 15px; color: rgba(var(--ink-rgb),.6); line-height: 1.55; }
|
||||||
|
@keyframes breatheGreen { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--ok-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--ok-rgb),.55); } }
|
||||||
|
@keyframes breatheAmber { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--amber-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--amber-rgb),.6); } }
|
||||||
|
@keyframes breatheRed { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--err-rgb),.4); } 50% { box-shadow: 0 0 44px 16px rgba(var(--err-rgb),.65); } }
|
||||||
|
.bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.brick { padding: 16px; border-radius: 12px; }
|
||||||
|
.brick.amber { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.22); }
|
||||||
|
.brick.plain { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.14); }
|
||||||
|
.brick .lbl { font-size: 13.5px; color: rgba(var(--ink-rgb),.55); margin-bottom: 6px; }
|
||||||
|
.brick .num { font-family: ui-monospace, Menlo, monospace; font-size: 26px; color: var(--amber); }
|
||||||
|
.brick .num small { font-size: 15px; color: rgba(var(--ink-rgb),.5); }
|
||||||
|
.bar { margin-top: 10px; height: 6px; border-radius: 3px; background: var(--track); }
|
||||||
|
.bar > i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg,#b98330,#e8b45a); transition: width .6s; }
|
||||||
|
.wait-box { margin-top: 14px; padding: 20px; border-radius: 12px; border: 1px dashed rgba(var(--ok-rgb),.3); background: rgba(var(--ok-rgb),.05); }
|
||||||
|
.wait-box.has { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.05); }
|
||||||
|
.wait-head { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); margin-bottom: 10px; text-align: center; }
|
||||||
|
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); letter-spacing: .08em; text-align: center; }
|
||||||
|
.wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.3); font-size: 16px; line-height: 1.5; }
|
||||||
|
.wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }
|
||||||
|
.wait-meta { margin-top: 10px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); line-height: 1.7; }
|
||||||
|
.wait-meta .warn { color: var(--err); }
|
||||||
|
.subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }
|
||||||
|
.subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }
|
||||||
|
.subhead .m { font-size: 13px; color: rgba(var(--ink-rgb),.4); }
|
||||||
|
ul.route { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
ul.route li { display: flex; align-items: flex-start; gap: 12px; padding: 13px 16px; border-radius: 11px; background: rgba(var(--ink-rgb),.045); border: 1px solid transparent; font-size: 16px; line-height: 1.4; }
|
||||||
|
ul.route li.doing { background: rgba(var(--amber-rgb),.09); border-color: rgba(var(--amber-rgb),.3); }
|
||||||
|
ul.route li .ic { flex: none; font-size: 15px; margin-top: 2px; }
|
||||||
|
ul.route li.done { color: rgba(var(--ink-rgb),.65); }
|
||||||
|
ul.route li.done .ic { color: var(--ok); }
|
||||||
|
ul.route li.doing .ic { color: var(--amber); }
|
||||||
|
ul.route li.todo { color: rgba(var(--ink-rgb),.6); }
|
||||||
|
ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }
|
||||||
|
ul.route li.blocked .ic { color: var(--err); }
|
||||||
|
ul.route.faded li { opacity: .55; }
|
||||||
|
.sys { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.sys .row { display: flex; justify-content: space-between; align-items: baseline; padding: 10px 14px; border-radius: 10px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); font-size: 14.5px; }
|
||||||
|
.sys .row .k { color: rgba(var(--ink-rgb),.6); }
|
||||||
|
.sys .row .v { font-family: ui-monospace, Menlo, monospace; font-size: 14px; }
|
||||||
|
.sys .ok { color: var(--ok); }
|
||||||
|
.sys .bad { color: var(--err); }
|
||||||
|
.sys .off { color: rgba(var(--ink-rgb),.5); }
|
||||||
|
.muted { color: rgba(var(--ink-rgb),.45); font-size: 14px; }
|
||||||
|
.err { color: var(--err); font-size: 14px; }
|
||||||
|
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.8; }
|
||||||
|
.enter { display: block; text-align: center; font-size: 13.5px; color: rgba(var(--amber-rgb),.75); text-decoration: none; margin-top: 6px; }
|
||||||
|
.theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="pagehead">
|
||||||
|
<div class="title serif">${brand}<small> 駕駛艙</small></div>
|
||||||
|
<div style="display:flex;align-items:baseline">
|
||||||
|
<div class="date serif" id="date-str"></div>
|
||||||
|
<button class="theme-btn" id="theme-btn" title="切換深/淺色">☾</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="orb-row">
|
||||||
|
<div class="orb" id="orb" style="background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)"><span id="orb-char">…</span></div>
|
||||||
|
<div>
|
||||||
|
<div class="orb-title" id="orb-title">載入中</div>
|
||||||
|
<div class="orb-sub" id="orb-sub"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bricks">
|
||||||
|
<div class="brick amber">
|
||||||
|
<div class="lbl">今日完成</div>
|
||||||
|
<div class="num"><span id="done-n">–</span><small> / <span id="total-n">–</span> 件</small></div>
|
||||||
|
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
|
||||||
|
</div>
|
||||||
|
<div class="brick plain">
|
||||||
|
<div class="lbl">收件匣未處理</div>
|
||||||
|
<div class="num"><span id="inbox-n">–</span><small> 條</small></div>
|
||||||
|
<div class="lbl" style="margin:10px 0 0">來自 Telegram</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wait-box" id="wait-box">
|
||||||
|
<div class="wait-head">等你的事</div>
|
||||||
|
<div id="wait-body" class="wait-none">載入中…</div>
|
||||||
|
<div class="wait-meta" id="wait-meta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="subhead"><span class="t">今日路線</span><span class="m" id="route-m"></span></div>
|
||||||
|
<ul class="route" id="today-list"><li class="todo"><span class="ic">○</span>載入中…</li></ul>
|
||||||
|
<div class="subhead" id="week-head" style="display:none"><span class="t">本週</span></div>
|
||||||
|
<ul class="route" id="week-list"></ul>
|
||||||
|
<div class="subhead"><span class="t">系統狀況</span><span class="m">live 健康信號</span></div>
|
||||||
|
<div class="sys" id="sys-list"><div class="row"><span class="k">載入中…</span></div></div>
|
||||||
|
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
|
||||||
|
<a class="enter" href="/console">進入完整控制台 ›</a>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// 台北時間 helper(lib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)
|
||||||
|
${TAIPEI_CLIENT_JS}
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const LIGHT = {
|
||||||
|
green: { ch: '安', title: '系統運轉中', grad: 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)', anim: 'breatheGreen' },
|
||||||
|
yellow: { ch: '趕', title: '落後趕工中', grad: 'radial-gradient(circle at 36% 30%,#f2d194,#e8b45a 55%,#8a5f1e 100%)', anim: 'breatheAmber' },
|
||||||
|
red: { ch: '滯', title: '卡住或斷訊', grad: 'radial-gradient(circle at 36% 30%,#f0a094,#d95f4c 55%,#7e2c20 100%)', anim: 'breatheRed' }
|
||||||
|
};
|
||||||
|
const ICONS = { done: '✓', doing: '◐', todo: '○', blocked: '●' };
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
}
|
||||||
|
function taskLine(t) {
|
||||||
|
const cls = ICONS[t.status] ? t.status : 'blocked';
|
||||||
|
const ic = ICONS[t.status] || '●';
|
||||||
|
return '<li class="' + cls + '"><span class="ic">' + ic + '</span><span>' + esc(t.title) + '</span></li>';
|
||||||
|
}
|
||||||
|
function humanAge(m) {
|
||||||
|
if (m == null || m < 0) return '時間不明';
|
||||||
|
if (m < 60) return m + ' 分鐘前';
|
||||||
|
if (m < 2880) return Math.round(m / 60) + ' 小時前';
|
||||||
|
return Math.round(m / 1440) + ' 天前';
|
||||||
|
}
|
||||||
|
const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];
|
||||||
|
function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '十' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '十' + (n % 10 ? CNUM[n % 10] : '')); }
|
||||||
|
// 頁首日期=台北日(原本用瀏覽器本地時區,換裝置會漂)
|
||||||
|
const nowTpe = taipeiMonthDay(Date.now());
|
||||||
|
$('date-str').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
|
||||||
|
// 深/淺切換(與 /console 共用 arcrun_console_theme;預設淺色)
|
||||||
|
function syncThemeBtn() { $('theme-btn').textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾'; }
|
||||||
|
$('theme-btn').addEventListener('click', () => {
|
||||||
|
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
try { localStorage.setItem('arcrun_console_theme', next); } catch (e) { /* 私密模式忽略 */ }
|
||||||
|
syncThemeBtn();
|
||||||
|
});
|
||||||
|
syncThemeBtn();
|
||||||
|
// fetch 失敗(斷網)的裸訊息 → 友善誠實文案;60 秒定時器常駐,網路恢復自動刷回
|
||||||
|
function friendlyErr(e) {
|
||||||
|
const m = e && e.message ? String(e.message) : String(e);
|
||||||
|
return /failed to fetch|load failed|networkerror|network request failed/i.test(m) ? '連線中斷' : m;
|
||||||
|
}
|
||||||
|
function sysRow(k, v, cls) {
|
||||||
|
return '<div class="row"><span class="k">' + esc(k) + '</span><span class="v ' + cls + '">' + esc(v) + '</span></div>';
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/console/dashboard-data');
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
const d = await res.json();
|
||||||
|
const cfg = LIGHT[d.light] || LIGHT.green;
|
||||||
|
const orb = $('orb');
|
||||||
|
orb.style.background = cfg.grad;
|
||||||
|
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
|
||||||
|
$('orb-char').textContent = cfg.ch;
|
||||||
|
$('orb-title').textContent = cfg.title;
|
||||||
|
$('orb-sub').textContent = (d.last_beat
|
||||||
|
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
|
||||||
|
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '(' + d.light_reason + ')' : '');
|
||||||
|
const done = d.today_done || 0, total = d.today_total || 0;
|
||||||
|
$('done-n').textContent = done; $('total-n').textContent = total;
|
||||||
|
$('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
|
||||||
|
$('inbox-n').textContent = d.inbox_new || 0;
|
||||||
|
// ── 等你的事:來源 + 維護時間攤開講,stale 一定警示 ──
|
||||||
|
const wb = $('wait-box'), body = $('wait-body'), wmeta = $('wait-meta');
|
||||||
|
const wm = d.waiting_meta || {};
|
||||||
|
if (d.waiting && d.waiting.length) {
|
||||||
|
wb.classList.add('has');
|
||||||
|
body.className = '';
|
||||||
|
body.innerHTML = d.waiting.map((w) =>
|
||||||
|
'<div class="wait-item"><span class="dm">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
|
||||||
|
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>').join('');
|
||||||
|
} else {
|
||||||
|
wb.classList.remove('has');
|
||||||
|
body.className = 'wait-none';
|
||||||
|
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
|
||||||
|
}
|
||||||
|
let metaTxt = '';
|
||||||
|
if (wm.source === 'gitea_sprint') {
|
||||||
|
metaTxt = '來源:sprint 等leo清單(' + esc((wm.sprint_files || []).join('、')) + ')・清單維護於 ' + humanAge(wm.updated_ago_minutes);
|
||||||
|
if (wm.stale) metaTxt += '<br><span class="warn">⚠ 清單超過 2 天沒維護,可能過時</span>';
|
||||||
|
} else if (wm.source === 'kbdb_dash_wait') {
|
||||||
|
metaTxt = '<span class="warn">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + humanAge(wm.updated_ago_minutes) + ',可能過時</span>';
|
||||||
|
} else {
|
||||||
|
metaTxt = '<span class="warn">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
|
||||||
|
}
|
||||||
|
wmeta.innerHTML = metaTxt;
|
||||||
|
// ── 今日路線:sprint 任務板優先(來源攤開講);dash_task fallback 沿舊誠實降級 ──
|
||||||
|
const rm = d.route_meta || {};
|
||||||
|
const today = (d.tasks || []).filter((t) => t.scope === 'today');
|
||||||
|
const week = (d.tasks || []).filter((t) => t.scope === 'week');
|
||||||
|
if (rm.source === 'gitea_sprint_board') {
|
||||||
|
$('route-m').textContent = '來源 sprint 任務板・更新於 ' + humanAge(rm.updated_ago_minutes);
|
||||||
|
$('today-list').className = 'route';
|
||||||
|
const staleHead = rm.is_today ? '' :
|
||||||
|
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + humanAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
|
||||||
|
$('today-list').innerHTML = staleHead + (today.length
|
||||||
|
? today.map(taskLine).join('')
|
||||||
|
: '<li class="todo"><span class="ic">○</span><span class="muted">任務板上沒有可解析的事項</span></li>');
|
||||||
|
} else if (rm.is_today) {
|
||||||
|
$('route-m').textContent = '更新於 ' + humanAge(rm.updated_ago_minutes);
|
||||||
|
$('today-list').className = 'route';
|
||||||
|
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
|
||||||
|
} else if (today.length) {
|
||||||
|
$('route-m').textContent = '最後路線・' + humanAge(rm.updated_ago_minutes) + '寫入';
|
||||||
|
$('today-list').className = 'route faded';
|
||||||
|
$('today-list').innerHTML =
|
||||||
|
'<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + humanAge(rm.updated_ago_minutes) +
|
||||||
|
'的殘留路線(sprint 任務板→dashboard 投影管線未接,等leo清單#15 裁決中)</span></li>' + today.map(taskLine).join('');
|
||||||
|
} else {
|
||||||
|
$('route-m').textContent = '';
|
||||||
|
$('today-list').className = 'route';
|
||||||
|
$('today-list').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">無資料——dash_task 管線未接</span></li>';
|
||||||
|
}
|
||||||
|
$('week-head').style.display = week.length ? '' : 'none';
|
||||||
|
$('week-list').innerHTML = week.map(taskLine).join('');
|
||||||
|
// ── 系統狀況 + 總庫規模(全 live,讀不到就標讀不到)──
|
||||||
|
const sys = d.system || {}, kb = d.kb || {};
|
||||||
|
const rows = [];
|
||||||
|
rows.push(sysRow('KBDB 基本盤', sys.kbdb_ok ? '● 正常' : '● 打不通', sys.kbdb_ok ? 'ok' : 'bad'));
|
||||||
|
if (sys.embed) {
|
||||||
|
rows.push(sys.embed.enabled
|
||||||
|
? sysRow('語意嵌入', '● 啟用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'ok')
|
||||||
|
: sysRow('語意嵌入', '○ 停用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'bad'));
|
||||||
|
} else {
|
||||||
|
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
|
||||||
|
}
|
||||||
|
rows.push(sys.graph && sys.graph.ok
|
||||||
|
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
|
||||||
|
: sysRow('知識圖譜', '● 打不通', 'bad'));
|
||||||
|
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
|
||||||
|
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
|
||||||
|
// 三元組/已嵌入 已各有一列)
|
||||||
|
rows.push(sysRow('精耕層 wiki 卡', kb.wiki_card_total == null ? '讀不到' : kb.wiki_card_total + ' 張', kb.wiki_card_total == null ? 'off' : ''));
|
||||||
|
$('sys-list').innerHTML = rows.join('');
|
||||||
|
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(taipeiTimeStr(Date.parse(d.generated_at))) + '(台北)<br>此頁不含機敏內容,免登入';
|
||||||
|
} catch (e) {
|
||||||
|
$('orb-char').textContent = '?';
|
||||||
|
$('orb-title').textContent = '讀不到狀態';
|
||||||
|
$('orb-sub').innerHTML = '<span class="err">' + esc(friendlyErr(e)) + '・每 60 秒自動重試</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
setInterval(load, 60000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字)
|
||||||
|
// 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG")
|
||||||
|
// CONSOLE_PROFILE=rag(console-profile-trim):駕駛艙不屬企業版頁面 → 302 回 /console。
|
||||||
|
// 選 302 不選 404:舊書籤/外鏈直接落回產品頁,不給死路(只裁 UI 頁面,資料端點行為不動)。
|
||||||
|
consoleDashboardRouter.get('/console/dashboard', (c) => {
|
||||||
|
if ((c.env.CONSOLE_PROFILE || 'full') === 'rag') return c.redirect('/console', 302);
|
||||||
|
return c.html(renderDashboardHtml(c.env.CONSOLE_BRAND || 'Arcrun'));
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,127 @@
|
|||||||
|
|
||||||
## 待裁決
|
## 待裁決
|
||||||
|
|
||||||
### P-2026-07-21:registry 寫入端+搜尋端修復(leo 親自交辦,最高優先)
|
### P-2026-07-24:CP2-F 第二~四刀——執行引擎獨立成精簡 worker(cypher 瘦身收官)
|
||||||
|
|
||||||
|
> 提案人:總管交辦之 arcrun 偵察 subagent(唯讀分析,未動 code、未部署)。
|
||||||
|
> 觸發:頂層 CRITICAL-PATH.md CP2-F(w=9,P3)——「免費起步」承諾不成立;
|
||||||
|
> leo 定調:workflow 執行引擎獨立成精簡 worker(只做調度),console/portal/credentials/webhooks 拆出去。
|
||||||
|
> **依 D35:現行 active SDD=portal-auth,與本題不同卷,故只寫 proposal 停下等 confirm,不動 code。**
|
||||||
|
|
||||||
|
#### 0. 先更正頂層 CP2-F 的過時記載(本次實測核實)
|
||||||
|
|
||||||
|
| 項目 | CP2-F 記載 | 2026-07-24 實測 | 出入原因 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 原始碼行數 | 15,446 行 | **11,050 行**(`find src -name '*.ts' \| xargs wc -l`) | 第一刀已砍 |
|
||||||
|
| bundle | 748KB | **528.1KB**(540,742 bytes,`wrangler deploy --dry-run --outdir` 實跑) | 同上 |
|
||||||
|
| 「ingest 必爆」 | 隱含現在式 | **已穩定**(第一刀驗收:連灌 10 張全過、同卡 6 連跑全過) | 5a16484 已修 |
|
||||||
|
| /health 5-7ms、9ms/節點 | 現在式 | **拆分前舊數,第一刀後未重測**——本提案每刀驗法含重測 | 未更新 |
|
||||||
|
|
||||||
|
第一刀=commit `5a16484`(07-21):console.ts(1623行)+portal-ui.ts(1390行) 搬去 `console-ui/` CF Pages
|
||||||
|
(arcrun-console-ui.pages.dev),748→528KB(-29%)。07-22 `ad367e4` 補 console-ui build 修復+
|
||||||
|
`deploy.targets.json` 具名部署目標(personal/enterprise 兩 profile,帳號+apiBase+專案名收一檔)。
|
||||||
|
→ **CP2-F 條目該由總管在頂層更新為「第一刀 ✅、二~四刀待裁」**,狀態從 ❌ 斷改 ◐ 半通。
|
||||||
|
|
||||||
|
#### 1. Bundle 實測解剖(528.1KB,sourcemap byte 級歸因,非推測)
|
||||||
|
|
||||||
|
方法:`npx wrangler deploy --dry-run --outdir=<scratch>` 產 index.js+index.js.map →
|
||||||
|
自寫 VLQ 解碼器把每個 byte 歸因到原始檔(mapped 507.5KB+bundler glue 20.5KB)。
|
||||||
|
|
||||||
|
| 模組 | KB | 佔比 | 主要檔案(行數) | 去向提議 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **dep: zod** | 130.6 | 24.7% | 只被 `lib/schemas.ts`+`lib/prompt-recipe-schema.ts` import | 刀④逐出引擎 |
|
||||||
|
| **dep: hono** | 76.9 | 14.6% | 框架 | 兩邊都要,留 |
|
||||||
|
| **執行引擎核心** | 74.3 | 14.1% | graph-executor(702)、component-loader(370)、wasi-shim(674)、execute/executions/resume/cypher routes | **留(引擎本體)** |
|
||||||
|
| Credentials/Auth | 48.0 | 9.1% | auth.ts(467)、credentials.ts(322)、auth-dispatcher(301)、auth-recipe-seeds(748行/21.9KB) | CRUD+seeds 搬;dispatcher 讀路徑留 |
|
||||||
|
| Portal(RAG 多人) | 42.3 | 8.0% | portal.ts(783)、portal-data.ts(457)、portal-auth、portal-seeds | 刀②搬 |
|
||||||
|
| Console API | 31.8 | 6.0% | console-dashboard(526)、console-auth(165)、兩 model(616) | 刀②搬 |
|
||||||
|
| dep: unenv/polyfill | 29.1 | 5.5% | nodejs_compat 代價 | 留(兩邊皆有) |
|
||||||
|
| Webhooks CRUD | 25.6 | 4.8% | webhooks-named(521)含觸發+管理混一檔 | **拆檔**:trigger/query 留、CRUD 搬 |
|
||||||
|
| Recipes CRUD/seeds | 24.1 | 4.6% | recipes.ts(563)、api-recipe-seeds、init-seed | CRUD/seeds 搬;`resolveRecipe` 讀路徑留 |
|
||||||
|
| Docs/OpenAPI | 12.5 | 2.4% | openapi.ts(306) | 刀③搬 |
|
||||||
|
| KBDB proxy | 8.4 | 1.6% | kbdb-proxy.ts(272) | 刀②搬 |
|
||||||
|
| scheduled/cron | 3.7 | 0.7% | scheduled.ts、cron-* | 留(觸發面) |
|
||||||
|
|
||||||
|
**三個關鍵事實**:
|
||||||
|
- zod 一家=24.7%,卻只服務「入口驗證」兩條鏈(execute→schemas、recipe-loader→prompt-recipe-schema)。
|
||||||
|
- 引擎真身(核心+hono+unenv)只要 ~180KB;其餘 ~350KB 全是管理/前端 API 面。
|
||||||
|
- 耦合點:`graph-executor.ts:6` import `resolveRecipe` from `routes/recipes.ts:406`——
|
||||||
|
**讀路徑長在 CRUD 路由檔裡**,拆分前要先抽到 lib(刀②-0 前置小步)。
|
||||||
|
|
||||||
|
#### 2. 拆分方案(Option A:引擎留原地,管理面搬出——推薦)
|
||||||
|
|
||||||
|
**為什麼引擎留在 cypher-executor 原 worker 不搬家**:webhook 觸發 URL
|
||||||
|
(`/webhooks/:token/trigger`、`/webhooks/named/:ns/:name/trigger`、`/q/:ns/:name`)
|
||||||
|
被外部 caller(Telegram webhook、Routine、CLI、MCP)焊死;引擎留原地=**外部零改動**。
|
||||||
|
反向(引擎搬新 worker)要全網改 URL,風險大得多,不採。
|
||||||
|
|
||||||
|
**佈局**:既有 25 顆(22 零件+cypher+kbdb+mcp)→ **26 顆**:新增 1 顆 `arcrun-api`(管理/控制面 TS worker)。
|
||||||
|
(arcrun-api 是框架自身的 API 面、非業務零件,與零件須 WASM 的鐵律不衝突——cypher 本身同理。)
|
||||||
|
|
||||||
|
| 端點群 | 去向 |
|
||||||
|
|---|---|
|
||||||
|
| `/execute`、`/cypher/*`、`/validate`(刀④前)、`/resume`、`/executions/*`、`/workflows/:name/executions`、webhook **trigger/query**、`/health`、scheduled cron | **cypher-executor(引擎)留** |
|
||||||
|
| `/portal/*`、`/portal/data/*`、`/console/*`、`/kbdb/*`(proxy)、`/credentials/*`、`/auth/*` CRUD、`/recipes/*` CRUD、`/webhooks` CRUD+named 管理(backfill/migrate/delete/list)、`/init-seed`、`/docs`+OpenAPI | **arcrun-api(新)搬** |
|
||||||
|
|
||||||
|
**Service binding 過渡(13 顆 SVC_\* 懶載現況)**:零風險——13 個 binding 服務的是
|
||||||
|
`component-loader.ts:73-85` LOGIC_BINDING_MAP 的邏輯零件調度,**全屬引擎**;引擎不搬家=
|
||||||
|
wrangler.toml `[[services]]` 原封不動。arcrun-api 完全不需要 SVC binding。
|
||||||
|
未配置時 fallback 公網 workers.dev 的既有機制(component-loader.ts:249-272)也不動。
|
||||||
|
self-hosted 的 deploy.ts 注入機制(D1 database_id 等)需對 arcrun-api 的 toml 複用同款注入——既有機制,非新開發。
|
||||||
|
|
||||||
|
**與 07-22 第一刀銜接**:console-ui Pages 的 `ARCRUN_API_BASE`(build 期參數)刀②時改指
|
||||||
|
arcrun-api;`deploy.targets.json` 兩 profile 各加一欄,部署命令不變。
|
||||||
|
|
||||||
|
**刀法(分三刀,每刀獨立可回退)**:
|
||||||
|
- **刀②(先抽 resolveRecipe 到 lib)**:Portal+Console API+kbdb-proxy 搬 arcrun-api。
|
||||||
|
預估 -82.5KB → 引擎 ~446KB。console-ui apiBase 同步切。
|
||||||
|
- **刀③**:credentials/auth CRUD+webhooks 管理面(先把 webhooks-named.ts 拆成 trigger/管理兩檔)+
|
||||||
|
recipes CRUD+init-seed(含兩包 seeds 30KB)+docs/openapi 搬過去。預估 -90KB → 引擎 ~355KB。
|
||||||
|
- **刀④(zod 逐出引擎)**:`/validate` 搬 arcrun-api(zod 隨行);`/execute` 入口驗證改手寫窄驗證
|
||||||
|
(graph shape 檢查 ~50 行)或換 valibot(~10KB);prompt-recipe-schema 同款處理。
|
||||||
|
預估 -130KB → **引擎 ~200-225KB(對 528KB 減 ~60%)**。
|
||||||
|
|
||||||
|
**目標與誠實邊界**:/health <2ms 主要靠 bundle 縮(冷啟 parse+常駐面縮)可期;
|
||||||
|
但**單節點 10ms 內不保證光靠瘦身達標**——9ms/節點若主要來自 per-node KV put
|
||||||
|
(graph-executor.ts:352 每節點 `kvSetNodeOutput`)+JSON 序列化,則需**刀④.5(備案)**:
|
||||||
|
node output 改「僅斷點/暫停時寫 KV、其餘記憶體傳遞」。是否需要動,以刀②後的 cpuTime 實測決定,不預先動。
|
||||||
|
|
||||||
|
#### 3. 每刀驗法(免費層 cpuTime 實測法)
|
||||||
|
|
||||||
|
每刀收工四件、缺一不算完成(守 CP 使用規則 6/7):
|
||||||
|
1. `wrangler deploy --dry-run` bundle size 對照表(貼實跑輸出,驗預估)。
|
||||||
|
2. vitest 全綠+tsc 0(現基線:cypher 169/170,唯一失敗=executor「不存在的零件」pre-existing)。
|
||||||
|
3. **免費層 cpuTime 實測**:部署後 `npx wrangler tail arcrun-cypher-executor --format=json` 抓
|
||||||
|
`cpuTime`,三個探針各打 10 次取 P50:/health、單節點 workflow(http_request×1)、
|
||||||
|
km_wiki_ingest(6 節點)。記進 CP2-F 條目當證據。
|
||||||
|
4. 端到端頭尾驗:console-ui 兩 profile 登入+搜尋(打 arcrun-api)、portal 登入、
|
||||||
|
`acr push`+`acr run` 走引擎、**webhook trigger 原 URL 打通**(外部 caller 不改的承諾)。
|
||||||
|
|
||||||
|
**回退**:每刀=一個 PR;arcrun-api 是加法(新 worker),引擎側只刪路由掛載——
|
||||||
|
回退=revert PR+重部引擎(arcrun-api 留著不礙事)。KV/D1 零 schema 變更,無資料遷移,無不可逆步驟。
|
||||||
|
|
||||||
|
#### 4. 影響分析(D35 第 3 條)
|
||||||
|
|
||||||
|
- **portal-auth(現行 active)**:P1-P4 已完成待部署排練。刀②把 `/portal/*` 路由搬 arcrun-api=
|
||||||
|
**只搬家不改行為**,其測試(portal 三套 56/56)隨檔案搬移後應原樣全綠;
|
||||||
|
但其「部署清單」要加一行(portal 部署目標從 cypher 變 arcrun-api)。**不作廢任何任務**。
|
||||||
|
- **第一刀遺留(5a16484「未做(第二刀)」清單)**:console-dashboard/portal/portal-data=本提案刀②,正式立案銷帳。
|
||||||
|
- **library-map(draft)**:M5 console 首頁區塊打 `/kbdb/map`——kbdb-proxy 刀②搬家後 console-ui apiBase 同步切,不改功能。
|
||||||
|
- **P-2026-07-21 registry proposal 的 B1**(「在 cypher-executor 開 GET /search」):若兩案都 confirm,
|
||||||
|
`/search` 統一面該開在 **arcrun-api** 而非引擎(搜尋是管理面不是執行面)——兩案不衝突,落點修一字。
|
||||||
|
- **不受影響**:22 顆零件、kbdb worker、mcp、CLI(`acr` 打的執行端點全留原 URL)、
|
||||||
|
webhook 外部 caller、13 個 service binding、artifact-sharing(confirmed)各 Phase。
|
||||||
|
- **新增維護面(誠實記)**:多一顆 worker 要部署(官方+self-hosted 各一次);
|
||||||
|
deploy 腳本/文件要加 arcrun-api;兩 worker 共用 KV/D1 binding(同一批 id,讀寫面分離)。
|
||||||
|
|
||||||
|
#### 5. 待 leo 拍板點
|
||||||
|
|
||||||
|
1. **開工排序**:CP 排 P3(P1=CP3-A、P1↑=A4 OAuth 在前)。本提案只求 confirm 方案,開工時點照 CP 排序走——除非 leo 要提前。
|
||||||
|
2. **刀④驗證庫選擇**:手寫窄驗證(零依賴、-130KB 全拿)vs valibot(保留 schema 風格、-120KB)——品味題。
|
||||||
|
3. **arcrun-api 命名**:`arcrun-api`?或併入既有規劃中的其他 worker?(跨 repo 佈局=總管/leo 層決策)
|
||||||
|
4. **頂層 CP2-F 條目更正**(§0 表)由總管執行——本 repo 只能在此記載,不碰頂層文件。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
> 提案人:總管交辦之 arcrun subagent。
|
> 提案人:總管交辦之 arcrun subagent。
|
||||||
> 觸發:leo 2026-07-21 —「一旦推進一個零件,就自動進 registry;recipe、workflow、app 都應該可以 registry,
|
> 觸發:leo 2026-07-21 —「一旦推進一個零件,就自動進 registry;recipe、workflow、app 都應該可以 registry,
|
||||||
|
|||||||
Reference in New Issue
Block a user