diff --git a/CHANGELOG.md b/CHANGELOG.md index e311d39..46da3d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## Unreleased +- Console P4: workspace detail now projects a read-only operator twin after + the existing inventory and before the family / events / channel panes. + Plan swimlanes come from existing Objectives plus `objective_wave` events; + milestone badges stay `incomplete | complete | repair_required`; task + phases reuse the existing state machine; 谁在跑 shows executor, latest + `dispatch` facts, and whether a `board` event landed. The latest ledger + line is a single redacted human-visible row. Missing wave / dispatch / + board / ledger fail closed. Swimlanes do not invent Objectives. 谁在跑 + does not claim a board landed unless a matching `board` event exists for + that task. Live ingest does not replay an events prefix over the GET + snapshot: `projected_seq` from that GET is the live floor, a + truncated overlay sets `overlay_complete` false, and a workspace + GET that cannot load overlay config returns an empty twin with + `overlay_complete` false so a prefix page cannot invent 谁在跑 or + 会审已落下. 阶段 / 谁在跑 / 会审 stay with the projection. + Hash stays `#w/` and + `#w//family|events|channel`. No new mutation API. +- Console UI: restyle the local page as a one-level family patchbay. The + family graph is the signature instrument (parent in camera focus; edges + light on merge/sync). Workspace detail is a room: bay first, twin as + the now-reading, inventory and proofs recede. Overview stays a strip, + not a dump. System fonts only. No third-party script, font, chart, or + player. + ## 0.7.8 - 2026-08-20 - Tighten the internal `dyro-board` Record protocol: declare a diff --git a/docs/designs/console-v2-live-family-signals.md b/docs/designs/console-v2-live-family-signals.md index efd0b24..c33a375 100644 --- a/docs/designs/console-v2-live-family-signals.md +++ b/docs/designs/console-v2-live-family-signals.md @@ -423,6 +423,17 @@ P3 已落地:overlay 清单与同源图像(bearer `fetch` 后赋 `blob:` URL - 视频只出卡片。 - 不扫描 harness home,不把 sidecar 输出目录当成家族产物。 +### P4 · 操作者双生 + +P4 仍在同一 `0.7.x` 列车、同一工作区详情页,不新增一级导航。详情在既有清单之后投影只读操作者双生: + +- **计划**:只为快照里已有的 Objective 建泳道;`objective_wave` 只填充这些泳道,不发明目标。 +- **里程碑**:仅 `incomplete | complete | repair_required`;未知派生结果留空。 +- **阶段**:八列沿用既有 Task 状态机;点击只打开已有 Task 摘要。 +- **谁在跑**:只列 `in_progress`;执行者、最近 `dispatch` 事实、是否有匹配该任务的 `board` 事件,外加一行脱敏账本。 + +缺失或截断的 wave / dispatch / board / ledger 全部 fail-closed。工作区 GET 不创建 overlay 锁。人类 POST 集合不变。 + ### 验收 - 操作者打开已登记工作区详情,能在同一页看到一层家族、直播事件和(P2 后)频道,而不需要新的一级导航。 diff --git a/src/dyro/console/assets.py b/src/dyro/console/assets.py index 5bedd32..c6d3cc9 100644 --- a/src/dyro/console/assets.py +++ b/src/dyro/console/assets.py @@ -22,18 +22,18 @@ class ConsoleAsset: ASSET_MANIFEST = { "index.html": ( "text/html; charset=utf-8", - "eb0c18e8cf20df27c21a9ba8f309ffb66ac3f258371c5b450abd48ac5cf7fba5", - 3812, + "ca77441bfa6fce59c5a015c67d5843003017083392d69f035a2e628ca7388158", + 3795, ), "app.js": ( "text/javascript; charset=utf-8", - "643f02fdf319179cca0d8ce91caa6b97a7dbf41d789bc7cc242ad88ffb6a1b1d", - 68786, + "19a665f3da67838802cefaaab6a9d5030e585e3d9a9828002ee796355cafac52", + 85461, ), "styles.css": ( "text/css; charset=utf-8", - "b17091b8edb7b64b19f079d5c2a37a83aaead4de9ce0f2ba0cbf0d2002208728", - 16056, + "8bdb1b8db171a130e2f1d5de0d432b8c9ce9c7c55722b8a2f043befcab8db057", + 24589, ), } diff --git a/src/dyro/console/assets/app.js b/src/dyro/console/assets/app.js index 1db7960..1f229e2 100644 --- a/src/dyro/console/assets/app.js +++ b/src/dyro/console/assets/app.js @@ -26,6 +26,10 @@ const state = { channelUnacked: false, artifactItems: [], artifactBlobs: new Map(), + operatorTwin: null, + twinTasks: [], + twinAfterSeq: 0, + twinOverlayComplete: false, }; const HEALTH_LABELS = { healthy: "健康", degraded: "需关注", unavailable: "不可用" }; const FRESHNESS_LABELS = { fresh: "读取完整", partial: "部分可读", stale: "待刷新" }; @@ -57,6 +61,16 @@ const OPERATOR_STATE_LABELS = { completed: "已完成", repair_required: "需要修复", }; +const MILESTONE_LABELS = { + incomplete: "未完成", + complete: "已完成", + repair_required: "需要修复", +}; +const DISPATCH_STATE_LABELS = { + running: "在跑", + idle: "已停", + unknown: "状态未知", +}; const REQUESTED_MODE_LABELS = { observe: "只观察", supervised: "监督执行", @@ -184,7 +198,9 @@ const UPDATE_KIND_LABELS = { major: "有主版本更新", }; -const $ = (id) => document.getElementById(id); +const $ = (id) => (typeof document !== "undefined" && document.getElementById + ? document.getElementById(id) + : null); function setStatus(message, error = false) { const node = $("session-status"); @@ -664,7 +680,7 @@ function renderOverview(payload) { const list = $("workspace-list"); list.replaceChildren(); if (!data.workspaces.length) { - const empty = element("p", "没有可展示的工作区。页面不会自动创建或登记项目。"); + const empty = element("p", "没有可展示的工作区。运行 dyro setup、dyro join 或 dyro workspace add。"); empty.className = "empty"; list.append(empty); return; @@ -681,7 +697,11 @@ function renderInventoryList(title, items, describe) { section.className = "inventory"; section.append(element("h3", title)); if (!items.length) { - section.append(element("p", "没有可展示的项目。")); + section.append(element("p", title === "开发线" + ? "没有开发线。回终端跑 dyro setup 或 line spawn。" + : title === "任务" + ? "没有任务。回终端建 Task 后再打开这一页。" + : "没有目标。回终端建 Objective 后再看计划。")); return section; } const list = element("ul"); @@ -716,6 +736,391 @@ function renderInventory(data) { return root; } +function emptyTwin() { + return { + plan: [], + phases: TASK_STATUS_ORDER.map((status) => ({ status, tasks: [] })), + running: [], + latest_ledger: { present: false, at: "", task_id: "", phase: "", facts: {} }, + projected_seq: 0, + overlay_complete: false, + }; +} + +function copyTwin(twin) { + const source = twin && typeof twin === "object" ? twin : emptyTwin(); + return { + plan: Array.isArray(source.plan) ? source.plan.map((row) => ({ + ...row, + task_ids: Array.isArray(row && row.task_ids) ? row.task_ids.slice() : [], + })) : [], + phases: Array.isArray(source.phases) + ? source.phases.map((column) => ({ + status: text(column && column.status), + tasks: Array.isArray(column && column.tasks) + ? column.tasks.map((task) => ({ ...task })) + : [], + })) + : emptyTwin().phases, + running: Array.isArray(source.running) ? source.running.map((row) => ({ ...row })) : [], + latest_ledger: source.latest_ledger && typeof source.latest_ledger === "object" + ? { ...source.latest_ledger, facts: { ...(source.latest_ledger.facts || {}) } } + : emptyTwin().latest_ledger, + projected_seq: Number.isInteger(source.projected_seq) && source.projected_seq >= 0 + ? source.projected_seq + : 0, + overlay_complete: source.overlay_complete === true, + }; +} + +function twinFromData(data) { + const twin = data && data.operator_twin; + if (!twin || typeof twin !== "object") return emptyTwin(); + return copyTwin(twin); +} + +function findTwinTask(taskId) { + return state.twinTasks.find((task) => text(task && task.id) === taskId) || null; +} + +function knownTwinTaskId(value) { + const id = text(value); + if (!id) return ""; + if (findTwinTask(id)) return id; + const twin = state.operatorTwin; + if (!twin) return ""; + for (const column of Array.isArray(twin.phases) ? twin.phases : []) { + for (const task of Array.isArray(column && column.tasks) ? column.tasks : []) { + if (text(task && task.id) === id) return id; + } + } + for (const row of Array.isArray(twin.running) ? twin.running : []) { + if (text(row && row.id) === id) return id; + } + return ""; +} + +function eventKnownTaskId(event) { + return ( + knownTwinTaskId(event && event.subject) + || knownTwinTaskId(event && event.facts && event.facts.task_id) + || knownTwinTaskId(event && event.facts && event.facts.task) + ); +} + +function emptyRunningRow(card) { + return { + id: text(card && card.id), + title: text(card && card.title), + line: text(card && card.line), + executor: text(card && card.executor), + dispatch_present: false, + dispatch_id: "", + dispatch_at: "", + dispatch_state: "unknown", + dispatch_facts: {}, + board_landed: false, + }; +} + +function moveTwinTask(taskId, nextStatus) { + const twin = state.operatorTwin; + if (!twin || !taskId || !TASK_STATUS_ORDER.includes(nextStatus)) return false; + if (!Array.isArray(twin.phases)) twin.phases = emptyTwin().phases; + let card = null; + for (const column of twin.phases) { + const tasks = Array.isArray(column.tasks) ? column.tasks : []; + column.tasks = tasks; + const index = tasks.findIndex((task) => text(task && task.id) === taskId); + if (index >= 0) { + card = tasks[index]; + tasks.splice(index, 1); + break; + } + } + const known = findTwinTask(taskId); + if (!card && !known) return false; + if (!card) { + card = { + id: text(known.id), + title: text(known.title), + line: text(known.line), + executor: text(known.executor), + status: nextStatus, + }; + } else { + card.status = nextStatus; + } + if (known) known.status = nextStatus; + const dest = twin.phases.find((column) => text(column && column.status) === nextStatus); + if (!dest) return false; + dest.tasks = Array.isArray(dest.tasks) ? dest.tasks : []; + if (!dest.tasks.some((task) => text(task && task.id) === taskId)) dest.tasks.push(card); + dest.tasks.sort((left, right) => text(left && left.id).localeCompare(text(right && right.id))); + twin.running = Array.isArray(twin.running) ? twin.running : []; + const runIndex = twin.running.findIndex((row) => text(row && row.id) === taskId); + if (nextStatus === "in_progress") { + if (runIndex < 0) twin.running.push(emptyRunningRow(card)); + } else if (runIndex >= 0) { + twin.running.splice(runIndex, 1); + } + return true; +} + +function showTwinTask(taskId) { + const panel = $("twin-task-summary"); + if (!panel) return; + const task = findTwinTask(taskId); + panel.replaceChildren(); + if (!task) { + panel.hidden = true; + return; + } + panel.hidden = false; + panel.append(element("h4", "任务")); + const title = text(task.title) || text(task.id) || "未命名任务"; + panel.append(element("p", `${title} · ${describeTask(task)}`)); + panel.append(element("p", "点开的是已有任务摘要。这页没有任务工作室。")); +} + +function renderTwinPlan() { + const section = element("section"); + section.className = "twin-plan"; + section.append(element("h4", "计划")); + const plan = state.operatorTwin && Array.isArray(state.operatorTwin.plan) ? state.operatorTwin.plan : []; + if (!plan.length) { + section.append(element("p", "没有目标。先在终端建 Objective,再回到这页看计划。")); + return section; + } + const lanes = element("div"); + lanes.className = "twin-lanes"; + for (const row of plan) { + const lane = element("article"); + lane.className = "twin-lane"; + if (row && row.wave_present) lane.classList.add("has-wave"); + const head = element("header"); + head.className = "twin-lane-head"; + const title = element("strong", text(row && row.title) || text(row && row.id) || "未命名目标"); + const milestone = element("span", `里程碑 · ${displayLabel(row && row.milestone, MILESTONE_LABELS)}`); + milestone.className = "twin-milestone"; + if (text(row && row.milestone) === "repair_required") milestone.dataset.level = "danger"; + if (text(row && row.milestone) === "complete") milestone.dataset.level = "ready"; + head.append(title, milestone); + lane.append(head); + if (row && row.wave_present) { + const mode = text(row.wave_mode); + lane.append(element("p", mode ? `波次 ${mode} · ${count(row.wave_count)} 项` : `波次 · ${count(row.wave_count)} 项`)); + } else { + lane.append(element("p", "未见波次。计划仍按已有目标摊开,不另造 backlog。")); + } + const cells = element("div"); + cells.className = "twin-cells"; + const ids = Array.isArray(row && row.task_ids) ? row.task_ids : []; + if (!ids.length) { + cells.append(element("span", "这一波还没有任务格")); + } else { + for (const id of ids) { + const cell = element("button", text(id)); + cell.type = "button"; + cell.className = "twin-cell"; + cell.addEventListener("click", () => showTwinTask(text(id))); + cells.append(cell); + } + } + lane.append(cells); + lanes.append(lane); + } + section.append(lanes); + return section; +} + +function renderTwinPhases() { + const section = element("section"); + section.className = "twin-phases"; + section.append(element("h4", "阶段")); + const columns = element("div"); + columns.className = "twin-phase-grid"; + const phases = state.operatorTwin && Array.isArray(state.operatorTwin.phases) + ? state.operatorTwin.phases + : emptyTwin().phases; + for (const status of TASK_STATUS_ORDER) { + const column = phases.find((item) => text(item && item.status) === status) || { status, tasks: [] }; + const pane = element("div"); + pane.className = "twin-phase"; + pane.append(element("h5", displayLabel(status, TASK_STATUS_LABELS))); + const tasks = Array.isArray(column.tasks) ? column.tasks : []; + if (!tasks.length) { + pane.classList.add("is-empty"); + pane.append(element("p", "这一列没有任务")); + } else { + for (const task of tasks) { + const button = element("button", text(task.title) || text(task.id) || "未命名任务"); + button.type = "button"; + button.className = "twin-task"; + button.addEventListener("click", () => showTwinTask(text(task.id))); + pane.append(button); + } + } + columns.append(pane); + } + section.append(columns); + return section; +} + +function describeDispatchFacts(facts) { + if (!facts || typeof facts !== "object") return ""; + const parts = []; + for (const key of Object.keys(facts).sort()) { + const value = facts[key]; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + parts.push(`${key} ${value}`); + } + } + return parts.join(" · "); +} + +function renderTwinRunning() { + const section = element("section"); + section.className = "twin-running"; + section.append(element("h4", "谁在跑")); + const running = state.operatorTwin && Array.isArray(state.operatorTwin.running) ? state.operatorTwin.running : []; + if (!running.length) { + section.append(element("p", "没有进行中的任务。要派人,回终端跑 dispatch。")); + } else { + const list = element("ul"); + list.className = "twin-running-list"; + for (const row of running) { + const item = element("li"); + const title = element("button", text(row.title) || text(row.id) || "未命名任务"); + title.type = "button"; + title.className = "twin-task"; + title.addEventListener("click", () => showTwinTask(text(row.id))); + const executor = text(row.executor) ? `执行 ${text(row.executor)}` : "未见执行者"; + const dispatch = row.dispatch_present + ? displayLabel(row.dispatch_state, DISPATCH_STATE_LABELS) + : "未见派发"; + const board = row.board_landed ? "会审已落下" : "未见会审记录"; + const facts = describeDispatchFacts(row.dispatch_facts); + item.append( + title, + element("p", [executor, dispatch, board].join(" · ")), + ); + if (facts) item.append(element("p", facts)); + list.append(item); + } + section.append(list); + } + const ledger = state.operatorTwin && state.operatorTwin.latest_ledger + ? state.operatorTwin.latest_ledger + : emptyTwin().latest_ledger; + const line = element("p"); + line.className = "twin-ledger"; + if (!ledger.present) { + line.textContent = "未见账本行。账本仍是交付审计,这页只读最近一行。"; + } else { + const bits = ["最近账本行"]; + if (text(ledger.at)) bits.push(text(ledger.at)); + if (text(ledger.task_id)) bits.push(text(ledger.task_id)); + if (text(ledger.phase)) bits.push(text(ledger.phase)); + const facts = describeDispatchFacts(ledger.facts); + if (facts) bits.push(facts); + line.textContent = bits.length > 1 ? bits.join(" · ") : "最近账本行已脱敏"; + } + section.append(line); + return section; +} + +function buildOperatorTwin() { + const section = element("section"); + section.className = "operator-twin"; + section.id = "operator-twin"; + section.append(element("h3", "这一家现在怎样")); + section.append(element("p", "计划、里程碑、阶段和谁在跑。页面不另造 backlog,也不改任务。")); + section.append(renderTwinPlan(), renderTwinPhases(), renderTwinRunning()); + const summary = element("div"); + summary.id = "twin-task-summary"; + summary.className = "twin-task-summary"; + summary.hidden = true; + section.append(summary); + return section; +} + +function renderOperatorTwin(data) { + state.operatorTwin = twinFromData(data); + state.twinTasks = Array.isArray(data && data.tasks) ? data.tasks : []; + state.twinAfterSeq = state.operatorTwin.projected_seq; + state.twinOverlayComplete = state.operatorTwin.overlay_complete === true; + return buildOperatorTwin(); +} + +function applyLiveTwinEvents(twin, events, afterSeq, overlayComplete) { + if (!twin || overlayComplete !== true || !Array.isArray(events) || !events.length) return false; + const floor = Number.isInteger(afterSeq) && afterSeq >= 0 ? afterSeq : 0; + const planById = new Map((twin.plan || []).map((row) => [text(row && row.id), row])); + let changed = false; + let maxSeq = floor; + for (const event of events) { + const seq = event && event.seq; + if (!Number.isInteger(seq) || seq <= floor) continue; + const kind = text(event && event.kind); + const subject = text(event && event.subject); + const actor = text(event && event.actor); + if (kind === "objective_wave") { + const row = planById.get(subject) || planById.get(actor); + if (!row) continue; + row.wave_present = true; + row.wave_id = text(event.id); + row.wave_at = text(event.at); + row.wave_mode = text(event.facts && event.facts.mode); + const value = event.facts && event.facts.count; + if (Number.isInteger(value) && value >= 0) row.wave_count = value; + changed = true; + } + if (kind === "task_status") { + const taskId = eventKnownTaskId(event); + const nextStatus = text(event && event.facts && event.facts.to_status); + if (moveTwinTask(taskId, nextStatus)) changed = true; + } + if (kind === "dispatch") { + const row = (twin.running || []).find((item) => text(item && item.id) === eventKnownTaskId(event)); + if (!row) continue; + row.dispatch_present = true; + row.dispatch_id = text(event.id); + row.dispatch_at = text(event.at); + const phase = text(event.facts && event.facts.phase); + const status = text(event.facts && event.facts.status); + row.dispatch_state = phase === "start" ? "running" : phase === "end" && status === "idle" ? "idle" : "unknown"; + row.dispatch_facts = event.facts && typeof event.facts === "object" ? event.facts : {}; + changed = true; + } + if (kind === "board") { + const row = (twin.running || []).find((item) => text(item && item.id) === eventKnownTaskId(event)); + if (!row) continue; + row.board_landed = true; + changed = true; + } + if (seq > maxSeq) maxSeq = seq; + } + if (changed) twin.projected_seq = maxSeq; + return changed; +} + +function renderedTwinText(twin) { + const bits = []; + for (const row of twin && Array.isArray(twin.running) ? twin.running : []) { + bits.push(row && row.board_landed ? "会审已落下" : "未见会审记录"); + } + return bits.join("\n"); +} + +function mergeTwinFromEvents(events) { + const twin = state.operatorTwin; + if (!applyLiveTwinEvents(twin, events, state.twinAfterSeq, state.twinOverlayComplete)) return; + state.twinAfterSeq = twin.projected_seq; + const current = $("operator-twin"); + if (current) current.replaceWith(buildOperatorTwin()); +} + function renderWorkspaceAttention(data) { const section = element("div"); section.className = "inventory"; @@ -895,7 +1300,7 @@ function renderFamilyTree(alias, lines, tasks) { section.id = "family-pane"; section.append(element("h3", "家族")); if (!hasSurface("events")) { - section.append(element("p", "家族树尚未开放。")); + section.append(element("p", "家族尚未开放。运行 dyro console 后打开这一页。")); return section; } const parents = familyParents(lines); @@ -960,37 +1365,47 @@ function edgeLabel(parent, child, live) { return live ? `${parent} → ${child} · 刚有合入或同步` : `${parent} → ${child}`; } +function renderFamilyJack(id, role, tasks) { + const jack = element("div"); + jack.className = role === "parent" ? "family-jack is-focus" : "family-jack"; + jack.dataset.member = id; + jack.dataset.role = role; + const title = element("strong", id); + const label = element( + "span", + role === "parent" ? "父线" : role === "operator" ? "操作者" : "子线", + ); + label.className = "family-role"; + jack.append(title, label, familyBadges(id, tasks)); + return jack; +} + function renderFamilyGraph(alias, lines, parent, tasks) { const wrap = element("div"); wrap.id = "family-tree"; - wrap.className = "family-tree"; + wrap.className = "family-tree family-bay"; const children = familyChildren(lines, parent); - const members = [parent, ...children, "operator"]; - const list = element("ul"); - list.className = "family-nodes"; - for (const id of members) { - const item = element("li"); - item.className = "family-node"; - item.dataset.member = id; - item.dataset.role = id === parent ? "parent" : id === "operator" ? "operator" : "child"; - const title = element("strong", id); - const role = element( - "span", - id === parent ? "父线" : id === "operator" ? "操作者" : "子线", - ); - role.className = "family-role"; - item.append(title, role, familyBadges(id, tasks)); - if (id !== "operator" && id !== parent) { - const live = state.liveEdges.has(`${parent}>${id}`) || state.liveEdges.has(`${id}>${parent}`); - const edge = element("span", edgeLabel(parent, id, live)); - edge.className = live ? "family-edge live" : "family-edge"; - edge.dataset.from = parent; - edge.dataset.to = id; - item.append(edge); - } - list.append(item); - } - wrap.append(list); + const stage = element("div"); + stage.className = "family-stage"; + stage.append(renderFamilyJack(parent, "parent", tasks)); + const outbound = element("div"); + outbound.className = "family-outbound"; + if (!children.length) { + outbound.append(element("p", "还没有子线。开子线:把下面的 dry-run 贴到终端。")); + } + for (const id of children) { + const run = element("div"); + run.className = "family-run"; + const live = state.liveEdges.has(`${parent}>${id}`) || state.liveEdges.has(`${id}>${parent}`); + const thread = element("span", live ? "刚有合入或同步" : "一层亲属"); + thread.className = live ? "family-edge live" : "family-edge"; + thread.dataset.from = parent; + thread.dataset.to = id; + run.append(thread, renderFamilyJack(id, "child", tasks)); + outbound.append(run); + } + stage.append(outbound, renderFamilyJack("operator", "operator", tasks)); + wrap.append(stage); const actions = element("div"); actions.className = "family-actions"; const child = children[0] || `${parent}_new`; @@ -1040,7 +1455,7 @@ function renderEventList() { if (!list) return; list.replaceChildren(); if (!state.eventItems.length) { - list.append(element("li", "还没有直播事件。")); + list.append(element("li", "还没有直播事件。在终端跑 line spawn、merge 或 sync 后回到这一页。")); return; } for (const event of [...state.eventItems].reverse()) { @@ -1062,6 +1477,7 @@ function appendEvents(events) { } applyLiveEdges(state.eventItems); renderEventList(); + mergeTwinFromEvents(events); } function consumeSse(buffer) { @@ -1423,13 +1839,13 @@ function renderChannelPane(alias) { const section = element("section"); section.className = "live-pane channel-pane"; section.id = "channel-pane"; - section.append(element("h3", "频道")); + section.append(element("h3", "决定")); if (!hasSurface("families")) { - section.append(element("p", "尚未开放")); + section.append(element("p", "决定尚未开放。运行 dyro console 后打开这一页。")); section.append(renderArtifactRail(alias, "channel-artifact-rail")); return section; } - section.append(element("p", "人类身份固定为 operator。")); + section.append(element("p", "以 operator 留下决定或约定。这不是交付门。")); const parent = state.familyParent; const members = state.channelMembers.length ? state.channelMembers : []; section.append(renderChannelFilters(members)); @@ -1800,12 +2216,27 @@ async function loadWorkspace(alias, silent = false) { definition("目标", workspaceCount(summary, "objective_count")), definition("证据核验", displayLabel(summary.proof_inspection, PROOF_INSPECTION_LABELS)), ); - content.replaceChildren(grid); - content.append(renderInventory(payload.data)); + const room = element("div"); + room.className = "workspace-room"; + const meta = element("p"); + meta.className = "workspace-room-meta"; + meta.textContent = [ + text(summary.alias), + displayLabel(summary.health, HEALTH_LABELS), + displayLabel(summary.freshness, FRESHNESS_LABELS), + displayLabel(summary.availability, AVAILABILITY_LABELS), + ].filter(Boolean).join(" · "); + const hero = element("div"); + hero.className = "workspace-hero"; + hero.append(renderLivePanes(alias, payload.data)); + const quiet = element("div"); + quiet.className = "workspace-quiet"; + quiet.append(grid, renderInventory(payload.data)); const command = text(summary.recommendation && summary.recommendation.command); - if (command) content.append(commandRow(command)); - content.append(await loadProofInspect(alias)); - content.append(renderLivePanes(alias, payload.data)); + if (command) quiet.append(commandRow(command)); + quiet.append(await loadProofInspect(alias)); + room.append(meta, hero, renderOperatorTwin(payload.data), quiet); + content.replaceChildren(room); renderEventList(); renderChannelMessages(); detail.hidden = false; @@ -1921,4 +2352,15 @@ async function start() { } } -start(); +if (typeof document !== "undefined" && document.getElementById && document.getElementById("refresh")) { + start(); +} +globalThis.__dyroTwinLive = { + twinFromData, + renderOperatorTwin, + mergeTwinFromEvents, + applyLiveTwinEvents, + renderedTwinText, + emptyTwin, + getState: () => state, +}; diff --git a/src/dyro/console/assets/index.html b/src/dyro/console/assets/index.html index 861cd76..7b4b218 100644 --- a/src/dyro/console/assets/index.html +++ b/src/dyro/console/assets/index.html @@ -12,10 +12,10 @@ @@ -23,7 +23,7 @@

Dyro Console

-

你的工程

+

全家

正在读取工程状态

正在读取本地工作区状态。

@@ -44,7 +44,7 @@

正在读取工程状态

-

全部项目

+

已登记

已登记的工作区

@@ -74,7 +74,7 @@

Dyro 版本

-
这页只看状态,不会改你的项目。执行、复核、签收、合并和推送请回到终端。
+ diff --git a/src/dyro/console/assets/styles.css b/src/dyro/console/assets/styles.css index 34cd3f0..47ae53f 100644 --- a/src/dyro/console/assets/styles.css +++ b/src/dyro/console/assets/styles.css @@ -1,26 +1,58 @@ +/* + Patchbay tokens — local loopback control room for one-level 开发线 families. + + --bay: #141A1F page well (graphite chassis, not a void) + --chassis: #1E272E raised plate + --plate: #2A343C inset jack well + --ticket: #E4DDD0 instrument-label text + --mute: #8E9388 oxidized legend + --filament: #C9864A live copper thread; ONLY merge/sync edges and a just-seen wave + --fault: #C16D6D fault lamp + + Type: ui-serif nameplates, ui-sans-serif body, ui-monospace IDs/CLI. + Layout: quiet rack. Overview is a strip, not a dump. Workspace is a + room: family bay in camera focus, twin as the now-reading, inventory + and proofs recede to a side plate. Events and 决定 are side jacks. + Signature: the one-level family graph as a living patchbay. One + motion — copper thread lights on merge/sync. Everything else stays still. + + Not cream+terracotta paper, not near-black acid, not broadsheet hairlines. +*/ + :root { color-scheme: dark; - --canvas: #061323; - --surface: #091a2f; - --surface-strong: #0d2340; - --surface-muted: #102843; - --text: #f0f6ff; - --muted: #a6b7cb; - --subtle: #7289a3; - --border: #28425f; - --border-strong: #38618c; - --accent: #438cff; - --accent-hover: #72aaff; - --accent-contrast: #ffffff; - --danger: #ff9a9a; - --danger-surface: #3d1922; - --warning: #ffd181; - --warning-surface: #3b2b18; - --success: #75dfae; - --success-surface: #123a35; - --radius-large: 1rem; - --radius-medium: .65rem; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --bay: #141A1F; + --chassis: #1E272E; + --plate: #2A343C; + --ticket: #E4DDD0; + --mute: #8E9388; + --filament: #C9864A; + --fault: #C16D6D; + --ready: #8FA37A; + --canvas: var(--bay); + --surface: var(--chassis); + --surface-strong: #252F37; + --surface-muted: var(--plate); + --text: var(--ticket); + --muted: var(--mute); + --subtle: #6F766F; + --border: #3A444C; + --border-strong: #4A555E; + --accent: var(--filament); + --accent-hover: #E0A56A; + --accent-contrast: #1A140E; + --danger: var(--fault); + --danger-surface: #3A2426; + --warning: #C9A36A; + --warning-surface: #33291C; + --success: var(--ready); + --success-surface: #243027; + --radius-large: .6rem; + --radius-medium: .4rem; + --display: ui-serif, "Iowan Old Style", "Palatino Linotype", Palatino, serif; + --body: ui-sans-serif, system-ui, "Segoe UI", sans-serif; + --data: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-family: var(--body); } * { box-sizing: border-box; } @@ -28,35 +60,36 @@ body { margin: 0; min-width: 20rem; - background: var(--canvas); - color: var(--text); + background: var(--bay); + color: var(--ticket); line-height: 1.5; + overflow-x: hidden; } button, code { font: inherit; } button { border: 1px solid transparent; - border-radius: .5rem; + border-radius: var(--radius-medium); cursor: pointer; - font-weight: 700; - padding: .65rem .9rem; + font-weight: 650; + padding: .55rem .8rem; } button:disabled { cursor: not-allowed; opacity: .48; } -button:focus-visible { outline: 3px solid var(--warning); outline-offset: 3px; } +button:focus-visible { outline: 3px solid var(--filament); outline-offset: 3px; } button.secondary { background: transparent; border-color: var(--border-strong); - color: var(--text); + color: var(--ticket); } button.secondary:hover:not(:disabled) { border-color: var(--accent-hover); color: var(--accent-hover); } button.primary-action { align-self: center; - background: var(--accent); + background: var(--ticket); color: var(--accent-contrast); min-width: 7.25rem; } @@ -69,130 +102,148 @@ button.primary-action:hover:not(:disabled) { background: var(--accent-hover); } } .site-header { - align-items: center; + align-items: baseline; display: flex; gap: 1.5rem; justify-content: space-between; - min-height: 4.75rem; + min-height: 4.25rem; } -.brand-lockup { align-items: center; display: flex; gap: 1rem; } -.brand-lockup > div { align-items: center; display: flex; } -.brand-lockup .eyebrow { display: none; } -.brand-lockup h1 { font-size: clamp(1.25rem, 2vw, 1.6rem); letter-spacing: -.035em; margin: 0; } +.brand-lockup { align-items: baseline; display: flex; gap: 1rem; } +.brand-lockup > div { align-items: baseline; display: flex; gap: .85rem; } +.brand-lockup .eyebrow { display: block; margin: 0; } +.brand-lockup h1 { + font-family: var(--display); + font-size: clamp(1.2rem, 2vw, 1.55rem); + font-weight: 600; + letter-spacing: -.02em; + margin: 0; +} .eyebrow { - color: var(--accent-hover); - font-size: .72rem; - font-weight: 800; - letter-spacing: .12em; - margin: 0 0 .45rem; + color: var(--mute); + font-family: var(--data); + font-size: .68rem; + font-weight: 600; + letter-spacing: .08em; + margin: 0 0 .35rem; + text-transform: lowercase; } .read-only-badge { - border: 1px solid #2b60a3; - border-radius: 999px; - color: var(--accent-hover); - font-size: .82rem; - font-weight: 750; - padding: .25rem .6rem; + border: 1px solid var(--border-strong); + border-radius: .3rem; + color: var(--mute); + font-family: var(--data); + font-size: .72rem; + font-weight: 600; + padding: .2rem .5rem; } .session-status { - color: var(--muted); - font-size: .92rem; + color: var(--mute); + font-size: .88rem; margin: 0; max-width: 28rem; text-align: right; } -main { border-top: 1px solid var(--border); padding: 1.9rem 0 3.25rem; } +main { border-top: 1px solid var(--border); padding: 1.4rem 0 3rem; } h1, h2, h3, p { margin-top: 0; } -h2 { font-size: clamp(1.8rem, 3.2vw, 2.85rem); letter-spacing: -.045em; margin-bottom: .45rem; } +h2 { + font-family: var(--display); + font-size: clamp(1.45rem, 2.6vw, 2.1rem); + font-weight: 600; + letter-spacing: -.03em; + margin-bottom: .4rem; +} .command-center { - background: var(--surface); + background: var(--chassis); border: 1px solid var(--border); border-radius: var(--radius-large); - padding: clamp(1.35rem, 3vw, 2.35rem); + padding: clamp(1.1rem, 2.4vw, 1.7rem); } .command-center-top { align-items: flex-start; display: flex; gap: 1.5rem; justify-content: space-between; } -#overview-summary { color: var(--muted); font-size: 1rem; margin-bottom: 0; max-width: 42rem; } +#overview-summary { color: var(--mute); font-size: .95rem; margin-bottom: 0; max-width: 40rem; } .counts { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); - margin: 2.4rem 0 1.8rem; + margin: 1.1rem 0 .9rem; } -.count { border-left: 1px solid var(--border); min-height: 5.2rem; padding: .2rem 1.4rem; } +.count { border-left: 1px solid var(--border); min-height: 3.6rem; padding: .1rem 1rem; } .count:first-child { border-left: 0; padding-left: 0; } -.count strong { display: block; font-size: clamp(1.7rem, 3vw, 2.5rem); letter-spacing: -.04em; line-height: 1; } -.count span { color: var(--muted); display: block; font-size: .84rem; margin-top: .6rem; } -.count[data-level="danger"] strong { color: var(--danger); } +.count strong { + display: block; + font-family: var(--data); + font-size: clamp(1.2rem, 2vw, 1.7rem); + letter-spacing: -.03em; + line-height: 1; +} +.count span { color: var(--mute); display: block; font-size: .78rem; margin-top: .45rem; } +.count[data-level="danger"] strong { color: var(--fault); } .count[data-level="warning"] strong { color: var(--warning); } -.count[data-level="success"] strong { color: var(--success); } +.count[data-level="success"] strong { color: var(--ready); } .task-counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - margin: 0 0 1.8rem; + margin: 0 0 0.4rem; } -.task-count { min-height: 3.6rem; padding: .15rem 1rem; } -.task-count strong { font-size: clamp(1.2rem, 2.2vw, 1.7rem); } +.task-count { min-height: 2.8rem; padding: .1rem .8rem; } +.task-count strong { font-size: clamp(1rem, 1.8vw, 1.3rem); } .next-step { align-items: center; - background: #07182b; + background: var(--plate); border: 1px solid var(--border); border-radius: var(--radius-medium); display: flex; - gap: 1.25rem; + gap: 1.1rem; justify-content: space-between; - padding: 1.15rem 1.35rem; + padding: .9rem 1.1rem; } .next-step-copy { min-width: 0; } -.next-step-label { color: var(--muted); font-size: .82rem; font-weight: 750; margin-bottom: .45rem; } -.next-step-why { color: var(--text); font-size: .95rem; margin: 0 0 .55rem; } +.next-step-label { color: var(--mute); font-size: .78rem; font-weight: 650; margin-bottom: .35rem; } +.next-step-why { color: var(--ticket); font-size: .92rem; margin: 0 0 .45rem; } -.needs-you { - display: grid; - gap: .75rem; -} -.needs-you-heading { font-size: 1rem; letter-spacing: -.02em; margin: 0; } -.needs-you > p { color: var(--muted); margin: 0; } +.needs-you { display: grid; gap: .6rem; } +.needs-you-heading { font-family: var(--display); font-size: 1.05rem; font-weight: 600; margin: 0; } +.needs-you > p { color: var(--mute); margin: 0; } .needs-you-item { align-items: center; - background: #07182b; + background: var(--plate); border: 1px solid var(--border); border-radius: var(--radius-medium); display: grid; - gap: .85rem 1rem; + gap: .75rem 1rem; grid-template-columns: minmax(8rem, .9fr) minmax(12rem, 1.4fr) auto; - padding: .95rem 1.1rem; + padding: .8rem 1rem; } -.needs-you-item[data-level="danger"] { border-color: var(--danger); } +.needs-you-item[data-level="danger"] { border-color: var(--fault); } .needs-you-item[data-level="warning"] { border-color: var(--warning); } .needs-you-item strong { font-size: 1rem; } -.needs-you-item p { color: var(--muted); margin: 0; } -.needs-you-actions { display: flex; flex-wrap: wrap; gap: .5rem; justify-self: end; } -.needs-you-actions button { background: var(--accent); color: var(--accent-contrast); font-size: .84rem; padding: .48rem .72rem; } -.needs-you-actions button.secondary { background: transparent; color: var(--text); } +.needs-you-item p { color: var(--mute); margin: 0; } +.needs-you-actions { display: flex; flex-wrap: wrap; gap: .45rem; justify-self: end; } +.needs-you-actions button { background: var(--ticket); color: var(--accent-contrast); font-size: .82rem; padding: .42rem .66rem; } +.needs-you-actions button.secondary { background: transparent; color: var(--ticket); } code { - color: #dceaff; - font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; - font-size: .95rem; + color: var(--ticket); + font-family: var(--data); + font-size: .9rem; overflow-wrap: anywhere; } -.section-heading { align-items: end; display: flex; gap: 1rem; justify-content: space-between; margin: 3.1rem 0 1rem; } -.section-heading h2 { font-size: 1.55rem; margin: 0; } -.section-heading .eyebrow { margin-bottom: .3rem; } -.section-heading > p { color: var(--muted); font-size: .86rem; margin: 0; } +.section-heading { align-items: end; display: flex; gap: 1rem; justify-content: space-between; margin: 2.2rem 0 .85rem; } +.section-heading h2 { font-size: 1.35rem; margin: 0; } +.section-heading .eyebrow { margin-bottom: .25rem; } +.section-heading > p { color: var(--mute); font-size: .82rem; margin: 0; } -.workspace-surface { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-large); overflow: hidden; } +.workspace-surface { background: var(--chassis); border: 1px solid var(--border); border-radius: var(--radius-large); overflow: hidden; } .workspace-column-headings, .workspace-row { display: grid; grid-template-columns: minmax(15rem, 2.5fr) minmax(6rem, 1fr) minmax(6rem, 1fr) minmax(4.5rem, .7fr) minmax(4.5rem, .7fr) minmax(6rem, .9fr); @@ -201,91 +252,227 @@ code { .workspace-column-headings { border-bottom: 1px solid var(--border); color: var(--subtle); - font-size: .78rem; - font-weight: 750; + font-family: var(--data); + font-size: .72rem; + font-weight: 650; letter-spacing: .04em; - padding: .8rem 1.35rem; + padding: .7rem 1.15rem; } .workspace-list { min-height: 1px; } -.workspace-row { align-items: center; border-bottom: 1px solid var(--border); gap: .75rem; padding: 1.25rem 1.35rem; } +.workspace-row { align-items: center; border-bottom: 1px solid var(--border); gap: .75rem; padding: 1rem 1.15rem; } .workspace-row:last-child { border-bottom: 0; } .workspace-row:hover { background: var(--surface-strong); } -.workspace-identity h3 { font-size: 1rem; letter-spacing: -.012em; margin: 0 0 .3rem; } -.workspace-meta { color: var(--muted); font-size: .86rem; margin: 0; } +.workspace-identity h3 { font-size: 1rem; letter-spacing: -.01em; margin: 0 0 .25rem; } +.workspace-meta { color: var(--mute); font-size: .82rem; margin: 0; } -.workspace-signal { align-items: center; display: flex; gap: .45rem; } -.workspace-signal-label { color: var(--subtle); display: none; font-size: .74rem; } +.workspace-signal { align-items: center; display: flex; gap: .4rem; } +.workspace-signal-label { color: var(--subtle); display: none; font-size: .72rem; } .badge { border: 1px solid var(--border); - border-radius: 999px; - color: var(--muted); + border-radius: .3rem; + color: var(--mute); display: inline-flex; - font-size: .8rem; - font-weight: 750; + font-size: .76rem; + font-weight: 650; line-height: 1; - padding: .38rem .58rem; + padding: .32rem .5rem; } -.badge[data-level="danger"] { background: var(--danger-surface); border-color: var(--danger); color: var(--danger); } +.badge[data-level="danger"] { background: var(--danger-surface); border-color: var(--fault); color: var(--fault); } .badge[data-level="warning"] { background: var(--warning-surface); border-color: var(--warning); color: var(--warning); } -.badge[data-level="success"] { background: var(--success-surface); border-color: #2b806a; color: var(--success); } -.workspace-count { color: var(--text); font-variant-numeric: tabular-nums; } +.badge[data-level="success"] { background: var(--success-surface); border-color: #3d5342; color: var(--ready); } +.workspace-count { color: var(--ticket); font-family: var(--data); font-variant-numeric: tabular-nums; } .workspace-action { justify-self: end; } -.workspace-action button { font-size: .84rem; padding: .48rem .72rem; } +.workspace-action button { font-size: .82rem; padding: .42rem .66rem; } .system-panel, .workspace-detail { - background: var(--surface); + background: var(--chassis); border: 1px solid var(--border); border-radius: var(--radius-large); - margin-top: 1.5rem; - padding: 0 1.35rem 1.35rem; + margin-top: 1.25rem; + padding: 0 1.2rem 1.2rem; } .system-note, -#system-note { color: var(--muted); font-size: .86rem; } -.detail-grid { display: grid; gap: .75rem; grid-template-columns: repeat(3, minmax(0, 1fr)); } -.detail-grid div { border-top: 1px solid var(--border); padding-top: .75rem; } -.detail-grid dt { color: var(--subtle); font-size: .8rem; } -.detail-grid dd { font-weight: 750; margin: .25rem 0 0; } -.workspace-inventory { border-top: 1px solid var(--border); margin-top: 1.15rem; padding-top: .25rem; } -.inventory { margin-top: .9rem; } -.inventory h3 { font-size: .95rem; margin: 0 0 .5rem; } -.inventory p, .inventory ul { color: var(--subtle); font-size: .86rem; margin: 0; } -.inventory ul { padding-left: 1.1rem; } -.inventory li { margin: .2rem 0; } -.proof-inspect { border-top: 1px solid var(--border); margin-top: 1.15rem; padding-top: 1rem; } -.proof-inspect h3 { font-size: .95rem; margin: 0 0 .5rem; } -.proof-inspect p, .proof-inspect ul { color: var(--subtle); font-size: .86rem; margin: 0; } -.proof-inspect ul { padding-left: 1.1rem; } -.proof-inspect li { margin: .2rem 0; } -.command { align-items: center; background: var(--surface-muted); border: 1px solid var(--border); border-radius: var(--radius-medium); display: flex; gap: .75rem; margin-top: 1rem; padding: .9rem; } +#system-note { color: var(--mute); font-size: .82rem; } +.detail-grid { display: grid; gap: .65rem; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.detail-grid div { border-top: 1px solid var(--border); padding-top: .65rem; } +.detail-grid dt { color: var(--subtle); font-family: var(--data); font-size: .72rem; } +.detail-grid dd { font-weight: 650; margin: .2rem 0 0; } +.workspace-room { + display: grid; + gap: 1rem; + margin-top: .35rem; +} +.workspace-room-meta { + color: var(--mute); + font-family: var(--data); + font-size: .74rem; + letter-spacing: .02em; + margin: 0; +} +.workspace-hero { min-width: 0; } +.workspace-quiet { + background: var(--plate); + border: 1px solid var(--border); + border-radius: var(--radius-medium); + min-width: 0; + padding: .75rem .85rem 1rem; +} +.workspace-quiet .detail-grid { + gap: .45rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0 0 .7rem; +} +.workspace-quiet .detail-grid dt { font-size: .66rem; } +.workspace-quiet .detail-grid dd { font-size: .82rem; font-weight: 600; } +.workspace-inventory { border-top: 0; margin-top: 0; padding-top: 0; } +.inventory { margin-top: .55rem; } +.inventory h3 { color: var(--mute); font-size: .72rem; font-weight: 650; letter-spacing: .04em; margin: 0 0 .3rem; text-transform: lowercase; } +.inventory p, .inventory ul { color: var(--subtle); font-size: .76rem; margin: 0; } +.inventory ul { padding-left: 1rem; } +.inventory li { margin: .12rem 0; } +.proof-inspect { border-top: 1px solid var(--border); margin-top: .75rem; padding-top: .65rem; } +.proof-inspect h3 { color: var(--mute); font-size: .78rem; margin: 0 0 .35rem; } +.proof-inspect p, .proof-inspect ul { color: var(--subtle); font-size: .76rem; margin: 0; } +.proof-inspect ul { padding-left: 1rem; } +.proof-inspect li { margin: .12rem 0; } +@media (min-width: 1100px) { + .workspace-room { + align-items: start; + grid-template-columns: minmax(0, 1.75fr) minmax(16rem, .82fr); + grid-template-areas: + "meta meta" + "hero quiet" + "twin quiet"; + } + .workspace-room-meta { grid-area: meta; } + .workspace-hero { grid-area: hero; } + .operator-twin { grid-area: twin; margin-top: 0; } + .workspace-quiet { grid-area: quiet; } +} +.command { align-items: center; background: var(--plate); border: 1px solid var(--border); border-radius: var(--radius-medium); display: flex; gap: .65rem; margin-top: .85rem; padding: .75rem; } .command code { flex: 1; } -.command button { background: var(--accent); color: var(--accent-contrast); flex: 0 0 auto; } +.command button { background: var(--ticket); color: var(--accent-contrast); flex: 0 0 auto; } -.empty, .error { color: var(--muted); margin: 0; padding: 1.35rem; } -.error { color: var(--danger); } -footer { border-top: 1px solid var(--border); color: var(--muted); font-size: .84rem; padding: 1.35rem 0 2.5rem; } +.empty, .error { color: var(--mute); margin: 0; padding: 1.15rem; } +.error { color: var(--fault); } +footer { border-top: 1px solid var(--border); color: var(--mute); font-size: .8rem; padding: 1.15rem 0 2.2rem; } + +.operator-twin { + background: var(--plate); + border: 1px solid var(--border); + border-radius: var(--radius-medium); + margin-top: 0; + padding: 1rem; +} +.operator-twin > h3 { + font-family: var(--display); + font-size: 1.2rem; + font-weight: 600; + margin: 0 0 .35rem; +} +.operator-twin > p { color: var(--mute); font-size: .84rem; margin: 0 0 .85rem; } +.operator-twin h4 { + font-family: var(--data); + font-size: .72rem; + font-weight: 650; + letter-spacing: .06em; + margin: .85rem 0 .45rem; + text-transform: lowercase; +} +.twin-lanes { display: grid; gap: .65rem; } +.twin-lane { + background: var(--chassis); + border: 1px solid var(--border); + border-radius: var(--radius-medium); + padding: .7rem .8rem; +} +.twin-lane.has-wave { border-color: color-mix(in srgb, var(--filament) 45%, var(--border)); } +.twin-lane-head { align-items: baseline; display: flex; flex-wrap: wrap; gap: .45rem 1rem; justify-content: space-between; } +.twin-lane-head strong { font-family: var(--display); font-weight: 600; } +.twin-milestone { + border: 1px solid var(--border); + border-radius: .3rem; + color: var(--mute); + font-family: var(--data); + font-size: .72rem; + padding: .15rem .4rem; +} +.twin-milestone[data-level="danger"] { border-color: var(--fault); color: var(--fault); } +.twin-milestone[data-level="ready"] { border-color: #3d5342; color: var(--ready); } +.twin-lane p { color: var(--mute); font-size: .8rem; margin: .4rem 0; } +.twin-cells { display: flex; flex-wrap: wrap; gap: .35rem; } +.twin-cell, .twin-task { + background: transparent; + border: 1px solid var(--border-strong); + color: var(--ticket); + font-family: var(--data); + font-size: .76rem; + padding: .28rem .45rem; +} +.twin-phase-grid { + display: grid; + gap: .45rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +.twin-phase { + background: var(--chassis); + border: 1px solid var(--border); + border-radius: var(--radius-medium); + min-height: 4.5rem; + padding: .5rem .55rem; +} +.twin-phase.is-empty { + min-height: 3.2rem; + opacity: .48; +} +.twin-phase h5 { font-size: .74rem; margin: 0 0 .35rem; } +.twin-phase p { color: var(--subtle); font-size: .74rem; margin: 0; } +.twin-phase .twin-task { display: block; margin: .25rem 0 0; width: 100%; } +.twin-running-list { list-style: none; margin: 0; padding: 0; } +.twin-running-list li { border-top: 1px solid var(--border); padding: .45rem 0; } +.twin-running-list li:first-child { border-top: 0; } +.twin-running-list p { color: var(--mute); font-size: .8rem; margin: .25rem 0 0; } +.twin-ledger { + border-top: 1px solid var(--border); + color: var(--mute); + font-family: var(--data); + font-size: .76rem; + margin: .7rem 0 0; + padding-top: .55rem; +} +.twin-task-summary { + background: var(--chassis); + border: 1px solid var(--border); + border-radius: var(--radius-medium); + margin-top: .75rem; + padding: .7rem .8rem; +} +.twin-task-summary h4 { margin-top: 0; } +.twin-task-summary p { color: var(--mute); font-size: .82rem; margin: .3rem 0 0; } @media (min-width: 901px) { .command-center { display: grid; - gap: 2rem 2.8rem; - grid-template-columns: minmax(18rem, .92fr) minmax(28rem, 1.18fr); + gap: 1.4rem 2rem; + grid-template-columns: minmax(16rem, .9fr) minmax(26rem, 1.2fr); position: relative; } .command-center-top { display: block; } - .command-center-top .secondary { position: absolute; right: clamp(1.35rem, 3vw, 2.35rem); top: clamp(1.35rem, 3vw, 2.35rem); } + .command-center-top .secondary { position: absolute; right: clamp(1.1rem, 2.4vw, 1.7rem); top: clamp(1.1rem, 2.4vw, 1.7rem); } .command-center-top > div { max-width: 24rem; } .counts { align-self: center; grid-column: 2; grid-row: 1; margin: 0; } - .count { min-height: 5.7rem; padding: .2rem 1rem; } + .count { min-height: 4rem; padding: .15rem .8rem; } .needs-you { grid-column: 1 / -1; } .next-step { grid-column: 1 / -1; } .task-counts { grid-column: 1 / -1; margin: 0; } } @media (max-width: 900px) { - .counts { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: 1.2rem; } - .task-counts { grid-template-columns: repeat(4, minmax(0, 1fr)); row-gap: 1rem; } + .counts { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: 1rem; } + .task-counts { grid-template-columns: repeat(4, minmax(0, 1fr)); row-gap: .8rem; } + .twin-phase-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .count:nth-child(4) { border-left: 0; padding-left: 0; } .workspace-column-headings { display: none; } .workspace-row { gap: 1rem; grid-template-columns: minmax(10rem, 1.6fr) minmax(5.5rem, .7fr) minmax(5.5rem, .7fr) 3.5rem 3.5rem; } @@ -293,143 +480,222 @@ footer { border-top: 1px solid var(--border); color: var(--muted); font-size: .8 } @media (max-width: 640px) { - .site-header, main, footer { width: min(100% - 2rem, 1400px); } - .site-header { align-items: flex-start; flex-direction: column; gap: .7rem; justify-content: center; min-height: 8rem; padding: 1rem 0; } - .brand-lockup { align-items: flex-start; flex-direction: column; gap: .5rem; } + .site-header, main, footer { width: min(100% - 1.5rem, 1400px); } + .site-header { align-items: flex-start; flex-direction: column; gap: .55rem; justify-content: center; min-height: 7rem; padding: .85rem 0; } + .brand-lockup { align-items: flex-start; flex-direction: column; gap: .4rem; } .brand-lockup > div { display: block; } .session-status { max-width: none; text-align: left; } - main { padding-top: 1.25rem; } - .command-center { padding: 1.2rem; } + main { padding-top: 1.1rem; } + .command-center { padding: 1rem; } .needs-you-item { grid-template-columns: 1fr; } .needs-you-actions { justify-self: start; } .command-center-top, .next-step, .section-heading { align-items: flex-start; flex-direction: column; } .command-center-top .secondary { align-self: stretch; } - .counts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 1.8rem 0 1.25rem; row-gap: 1rem; } - .task-counts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0 0 1.25rem; row-gap: 1rem; } - .count, .count:nth-child(4) { border-left: 1px solid var(--border); padding: .2rem 1rem; } + .counts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 1.2rem 0 1rem; row-gap: .8rem; } + .task-counts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0 0 1rem; row-gap: .8rem; } + .twin-phase-grid { grid-template-columns: 1fr; } + .count, .count:nth-child(4) { border-left: 1px solid var(--border); padding: .15rem .8rem; } .count:nth-child(odd) { border-left: 0; padding-left: 0; } - .next-step { padding: 1rem; } + .next-step { padding: .85rem; } .next-step .primary-action { align-self: stretch; width: 100%; } - .section-heading { gap: .45rem; margin-top: 2.2rem; } - .workspace-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 1.1rem; } + .section-heading { gap: .4rem; margin-top: 1.7rem; } + .workspace-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); padding: .95rem; } .workspace-identity { grid-column: 1 / -1; } - .workspace-signal { align-items: flex-start; flex-direction: column; gap: .3rem; } + .workspace-signal { align-items: flex-start; flex-direction: column; gap: .25rem; } .workspace-signal-label { display: block; } - .workspace-count::before { color: var(--subtle); content: attr(data-label); display: block; font-size: .74rem; margin-bottom: .3rem; } - .workspace-action { grid-column: 1 / -1; margin-top: .2rem; } + .workspace-count::before { color: var(--subtle); content: attr(data-label); display: block; font-size: .72rem; margin-bottom: .25rem; } + .workspace-action { grid-column: 1 / -1; margin-top: .15rem; } .workspace-action button { width: 100%; } .detail-grid { grid-template-columns: 1fr; } .command { align-items: stretch; flex-direction: column; } + .workspace-quiet .detail-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 320px) { + .site-header, main, footer { width: calc(100% - 1rem); } + .family-pane { min-height: 0; padding: .7rem; } + .operator-twin, .workspace-quiet, .live-pane { padding: .7rem; } + .twin-cell, .twin-task { width: 100%; } } .live-panes { - border-top: 1px solid var(--border); display: grid; - gap: 1rem; - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin-top: 1.15rem; - padding-top: 1rem; + gap: .7rem; + grid-template-columns: minmax(16rem, 1.55fr) minmax(11rem, .75fr) minmax(12rem, .95fr); + margin: 0; + padding: 0; } .live-tabs { display: none; } .live-pane { - background: var(--surface-muted); + background: var(--plate); border: 1px solid var(--border); border-radius: var(--radius-medium); - padding: 0.9rem; + padding: .85rem; +} +.live-pane h3 { font-family: var(--display); font-size: 1.05rem; font-weight: 600; margin: 0 0 .45rem; } +.live-pane p, .live-pane ul { color: var(--mute); font-size: .82rem; margin: 0; } +.family-pane { + background: + radial-gradient(ellipse 78% 70% at 26% 22%, #24313A 0%, transparent 58%), + #1B2329; + box-shadow: inset 0 0 0 1px #2C363E; + min-height: 16rem; +} +.family-picker { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .7rem; } +.family-bay { min-height: 13rem; } +.family-stage { + display: grid; + gap: .85rem 1rem; + grid-template-columns: minmax(7.5rem, .85fr) minmax(0, 1.5fr); + grid-template-rows: auto auto; + perspective: 900px; } -.live-pane h3 { font-size: .95rem; margin: 0 0 .5rem; } -.live-pane p, .live-pane ul { color: var(--subtle); font-size: .86rem; margin: 0; } -.family-picker { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: .75rem; } +.family-jack { + background: var(--chassis); + border: 1px solid var(--border); + border-radius: var(--radius-medium); + padding: .55rem .65rem; +} +.family-jack.is-focus { + box-shadow: 0 18px 36px rgba(0, 0, 0, .34), 0 0 0 1px #46525B; + grid-row: 1; + min-height: 6.2rem; + transform: translateY(-8px) scale(1.03); + z-index: 2; +} +.family-jack[data-role="child"] { opacity: .96; transform: scale(.97); } +.family-jack[data-role="operator"] { + grid-column: 1; + grid-row: 2; + opacity: .76; + transform: scale(.92); +} +.family-outbound { display: grid; gap: .65rem; grid-column: 2; grid-row: 1 / span 2; align-content: center; } +.family-run { align-items: center; display: grid; gap: .45rem; grid-template-columns: minmax(2.6rem, .5fr) minmax(0, 1fr); } .family-nodes { list-style: none; margin: 0; padding: 0; } .family-node { border-left: 3px solid var(--border-strong); - margin: .45rem 0; - padding: .35rem 0 .35rem .7rem; + margin: .4rem 0; + padding: .3rem 0 .3rem .65rem; } -.family-node[data-role="parent"] { border-left-color: var(--accent); } -.family-node[data-role="operator"] { border-left-color: var(--success); } -.family-role { color: var(--subtle); display: block; font-size: .75rem; } -.family-badges { display: flex; flex-wrap: wrap; gap: .35rem; margin: .3rem 0 0; } +.family-node[data-role="parent"] { border-left-color: var(--filament); } +.family-node[data-role="operator"] { border-left-color: var(--ready); } +.family-role { color: var(--subtle); display: block; font-family: var(--data); font-size: .7rem; } +.family-badges { display: flex; flex-wrap: wrap; gap: .3rem; margin: .25rem 0 0; } .family-badge { border: 1px solid var(--border-strong); - border-radius: .35rem; - color: var(--text); - font-size: .72rem; - padding: .1rem .35rem; + border-radius: .3rem; + color: var(--ticket); + font-family: var(--data); + font-size: .68rem; + padding: .08rem .32rem; +} +.family-edge { + align-self: center; + background: linear-gradient(90deg, #3A444C, #6A737A 55%, #3A444C); + border-radius: 99px; + color: transparent; + display: block; + font-size: .62rem; + height: .28rem; + overflow: hidden; + position: relative; + text-indent: -999px; +} +.family-edge.live { + background: var(--filament); + box-shadow: 0 0 14px color-mix(in srgb, var(--filament) 62%, transparent); + color: var(--accent-contrast); + font-weight: 700; + height: auto; + min-height: .28rem; + padding: .2rem .4rem; + text-indent: 0; } -.family-edge { display: block; font-size: .75rem; margin-top: .2rem; } -.family-edge.live { font-weight: 700; } -.family-actions { margin-top: .75rem; } +.family-actions { margin-top: .7rem; } .event-list { list-style: none; max-height: 16rem; overflow: auto; padding: 0; } -.event-list li { border-top: 1px solid var(--border); padding: .35rem 0; } +.event-list li { border-top: 1px solid var(--border); padding: .32rem 0; } .event-list li:first-child { border-top: 0; } .channel-toolbar { align-items: center; display: flex; flex-wrap: wrap; - gap: .4rem; - margin: .5rem 0; + gap: .35rem; + margin: .45rem 0; } -.channel-views { display: flex; gap: .35rem; } +.channel-views { display: flex; gap: .3rem; } .channel-list { list-style: none; max-height: 16rem; overflow: auto; padding: 0; } -.channel-item { border-top: 1px solid var(--border); padding: .4rem 0; } +.channel-item { border-top: 1px solid var(--border); padding: .35rem 0; } .channel-item:first-child { border-top: 0; } .channel-item.retracted { text-decoration: line-through; } .channel-item.unacked { font-weight: 600; } -.channel-compose { - display: grid; - gap: .45rem; - margin-top: .75rem; -} +.channel-compose { display: grid; gap: .4rem; margin-top: .7rem; } .channel-compose select, .channel-compose textarea { - background: var(--surface); + background: var(--chassis); border: 1px solid var(--border-strong); - border-radius: .35rem; - color: var(--text); + border-radius: .3rem; + color: var(--ticket); font: inherit; - padding: .35rem .45rem; + padding: .32rem .42rem; width: 100%; } .channel-toolbar select { max-width: 100%; } .artifact-rail { border-top: 1px solid var(--border); - margin-top: .75rem; - padding-top: .65rem; + margin-top: .7rem; + padding-top: .55rem; } -.artifact-rail h4 { font-size: .82rem; margin: 0 0 .4rem; } -.artifact-rail-list { display: grid; gap: .5rem; } +.artifact-rail h4 { font-size: .78rem; margin: 0 0 .35rem; } +.artifact-rail-list { display: grid; gap: .45rem; } .artifact-card, .artifact-attachment { - background: var(--surface); + background: var(--chassis); border: 1px solid var(--border); - border-radius: .4rem; - padding: .45rem .55rem; + border-radius: .35rem; + padding: .4rem .5rem; } .artifact-image { display: block; max-width: 100%; - margin-top: .35rem; + margin-top: .3rem; } .artifact-chart { - color: var(--accent); + color: var(--filament); display: block; height: 5rem; - margin-top: .35rem; + margin-top: .3rem; width: 100%; } @media (max-width: 767px) { .live-panes { grid-template-columns: 1fr; } - .live-tabs { display: flex; gap: .4rem; margin-bottom: .75rem; } + .live-tabs { display: flex; gap: .35rem; margin-bottom: .65rem; } .live-panes[data-tab="family"] .event-pane, .live-panes[data-tab="family"] .channel-pane, .live-panes[data-tab="events"] .family-pane, .live-panes[data-tab="events"] .channel-pane, .live-panes[data-tab="channel"] .family-pane, .live-panes[data-tab="channel"] .event-pane { display: none; } + .family-stage { grid-template-columns: 1fr; perspective: none; } + .family-jack.is-focus, + .family-jack[data-role="child"], + .family-jack[data-role="operator"] { transform: none; } + .family-jack[data-role="operator"], + .family-outbound { grid-column: 1; grid-row: auto; } + .family-run { grid-template-columns: 1fr; } } @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; } + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation: none !important; } .family-edge.live { font-weight: 700; } + .twin-lane.has-wave { animation: none; } + .family-jack.is-focus { transform: none; } } + +@keyframes filament-light { + from { filter: brightness(0.86); } + to { filter: brightness(1.08); } +} + +.family-edge.live { animation: filament-light 1.15s ease-in-out infinite alternate; } +.twin-lane.has-wave { animation: filament-light 1.15s ease-in-out 2 alternate; } diff --git a/src/dyro/console/inspection.py b/src/dyro/console/inspection.py index 8136cad..93065cc 100644 --- a/src/dyro/console/inspection.py +++ b/src/dyro/console/inspection.py @@ -22,6 +22,7 @@ from .events import is_safe_event_fact from .overview import ConsoleOverviewError from .redaction import REDACTED, safe_branch, safe_id, safe_sha256, safe_title +from .twin import DISPATCH_STATES, MILESTONES, TASK_STATUSES _CURSOR_SECRET_ENV = "DYRO_CONSOLE_CURSOR_SECRET" @@ -414,10 +415,11 @@ def _validate_data( cls._validate_channel(data) return if expected_operation == "workspace": - if set(data) != {"workspace", "lines", "tasks", "objectives"}: + if set(data) != {"workspace", "lines", "tasks", "objectives", "operator_twin"}: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") cls._validate_summary(data["workspace"]) cls._validate_inventory(data) + cls._validate_operator_twin(data["operator_twin"], data) return if set(data) == {"workspace"}: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") @@ -785,6 +787,192 @@ def _validate_inventory(cls, data: dict[str, object]) -> None: cls._validate_tasks(data["tasks"]) cls._validate_objectives(data["objectives"]) + @classmethod + def _validate_operator_twin(cls, value: object, inventory: Mapping[str, object]) -> None: + if not isinstance(value, dict) or set(value) != { + "plan", + "phases", + "running", + "latest_ledger", + "projected_seq", + "overlay_complete", + }: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + plan = value["plan"] + phases = value["phases"] + running = value["running"] + ledger = value["latest_ledger"] + if ( + not isinstance(plan, list) + or not isinstance(phases, list) + or not isinstance(running, list) + or len(plan) > 1000 + or len(phases) != len(TASK_STATUSES) + or len(running) > 1000 + or not cls._count(value.get("projected_seq")) + or type(value.get("overlay_complete")) is not bool + ): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + objective_ids = { + item.get("id") + for item in inventory.get("objectives", []) + if isinstance(item, dict) + } + task_ids = { + item.get("id") + for item in inventory.get("tasks", []) + if isinstance(item, dict) + } + for item in plan: + cls._validate_twin_plan_row(item) + if item.get("id") not in objective_ids: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + task_ids_row = item.get("task_ids") + if not isinstance(task_ids_row, list) or any(task_id not in task_ids for task_id in task_ids_row): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + in_progress_ids: set[object] = set() + for index, item in enumerate(phases): + if not isinstance(item, dict) or set(item) != {"status", "tasks"}: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if item.get("status") != TASK_STATUSES[index]: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + tasks = item.get("tasks") + if not isinstance(tasks, list) or len(tasks) > 1000: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + for task in tasks: + cls._validate_twin_task_card(task, expected_status=TASK_STATUSES[index]) + if TASK_STATUSES[index] == "in_progress": + in_progress_ids.add(task.get("id")) + for item in running: + cls._validate_twin_running_row(item) + if item.get("id") not in in_progress_ids: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + cls._validate_twin_ledger(ledger) + + @classmethod + def _validate_twin_plan_row(cls, item: object) -> None: + if not isinstance(item, dict) or set(item) != { + "id", + "title", + "line", + "milestone", + "wave_present", + "wave_id", + "wave_at", + "wave_mode", + "wave_count", + "task_ids", + }: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + title = item.get("title") + milestone = item.get("milestone") + wave_at = item.get("wave_at") + task_ids = item.get("task_ids") + if title != REDACTED and safe_title(title) != title: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if ( + not cls._safe_alias(item.get("id")) + or not (item.get("line") == "" or cls._safe_alias(item.get("line"))) + or not (milestone == "" or milestone in MILESTONES) + or type(item.get("wave_present")) is not bool + or not (item.get("wave_id") == "" or cls._safe_alias(item.get("wave_id"))) + or not isinstance(wave_at, str) + or len(wave_at) > 40 + or not (item.get("wave_mode") == "" or cls._safe_code(item.get("wave_mode"))) + or not cls._count(item.get("wave_count")) + or not isinstance(task_ids, list) + or len(task_ids) > 1000 + or not all(cls._safe_alias(task_id) for task_id in task_ids) + ): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + + @classmethod + def _validate_twin_task_card(cls, item: object, *, expected_status: str | None = None) -> None: + if not isinstance(item, dict) or set(item) != { + "id", + "title", + "line", + "executor", + "status", + }: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + title = item.get("title") + status = item.get("status") + if title != REDACTED and safe_title(title) != title: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if ( + not cls._safe_alias(item.get("id")) + or not (item.get("line") == "" or cls._safe_alias(item.get("line"))) + or not (item.get("executor") == "" or cls._safe_code(item.get("executor"))) + or status not in TASK_STATUSES + or (expected_status is not None and status != expected_status) + ): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + + @classmethod + def _validate_twin_running_row(cls, item: object) -> None: + if not isinstance(item, dict) or set(item) != { + "id", + "title", + "line", + "executor", + "dispatch_present", + "dispatch_id", + "dispatch_at", + "dispatch_state", + "dispatch_facts", + "board_landed", + }: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + title = item.get("title") + dispatch_at = item.get("dispatch_at") + facts = item.get("dispatch_facts") + if title != REDACTED and safe_title(title) != title: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if ( + not cls._safe_alias(item.get("id")) + or not (item.get("line") == "" or cls._safe_alias(item.get("line"))) + or not (item.get("executor") == "" or cls._safe_code(item.get("executor"))) + or type(item.get("dispatch_present")) is not bool + or not (item.get("dispatch_id") == "" or cls._safe_alias(item.get("dispatch_id"))) + or not isinstance(dispatch_at, str) + or len(dispatch_at) > 40 + or item.get("dispatch_state") not in DISPATCH_STATES + or type(item.get("board_landed")) is not bool + or not isinstance(facts, dict) + or len(facts) > 16 + ): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + for key, value in facts.items(): + if not is_safe_event_fact(key, value): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + + @classmethod + def _validate_twin_ledger(cls, item: object) -> None: + if not isinstance(item, dict) or set(item) != { + "present", + "at", + "task_id", + "phase", + "facts", + }: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + at = item.get("at") + facts = item.get("facts") + if ( + type(item.get("present")) is not bool + or not isinstance(at, str) + or len(at) > 40 + or not (item.get("task_id") == "" or cls._safe_alias(item.get("task_id"))) + or not (item.get("phase") == "" or cls._safe_code(item.get("phase"))) + or not isinstance(facts, dict) + or len(facts) > 16 + ): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + for key, value in facts.items(): + if not is_safe_event_fact(key, value): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + @classmethod def _validate_lines(cls, value: object) -> None: if not isinstance(value, list) or len(value) > 1000: diff --git a/src/dyro/console/overview.py b/src/dyro/console/overview.py index fc91b17..2284eeb 100644 --- a/src/dyro/console/overview.py +++ b/src/dyro/console/overview.py @@ -34,6 +34,7 @@ from .models import ConsoleEnvelope from .read_model import proof_inspect_data, workspace_envelope from .redaction import REDACTED, safe_id, safe_title +from .twin import empty_operator_twin, project_operator_twin _PAGE_SCHEMA_VERSION = 1 @@ -241,7 +242,22 @@ def workspace(self, alias: str) -> dict[str, object]: summary, warning_codes, inventory = self._capture( record.name, record.root, record.name == registry.default ) - return self._envelope({"workspace": summary, **inventory}, warning_codes) + twin = self._operator_twin(alias, inventory) + return self._envelope( + {"workspace": summary, **inventory, "operator_twin": twin}, + warning_codes, + ) + + def _operator_twin( + self, alias: str, inventory: Mapping[str, object] + ) -> dict[str, object]: + """Fail-closed overlay projection. Missing events or ledger stay empty.""" + try: + config, extra = self._workspace_config(alias) + except ConsoleOverviewError: + return empty_operator_twin() + del extra + return project_operator_twin(config, inventory) def events( self, diff --git a/src/dyro/console/twin.py b/src/dyro/console/twin.py new file mode 100644 index 0000000..d6be3b1 --- /dev/null +++ b/src/dyro/console/twin.py @@ -0,0 +1,312 @@ +"""Read-only operator twin for workspace detail. + +The twin composes already-captured Objective and Task DTOs with overlay +``events.jsonl`` and one redacted ledger line. It does not invent +Objectives, claim a board landed without a ``board`` event, or write +``.dyro`` / git. +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from pathlib import Path +import re + +from ..events import read_overlay_events +from .redaction import REDACTED, safe_id, safe_title + + +TASK_STATUSES = ( + "backlog", + "assigned", + "in_progress", + "waiting_answer", + "review", + "review_pending_signoff", + "done", + "failed", +) +MILESTONES = frozenset({"incomplete", "complete", "repair_required"}) +DISPATCH_STATES = frozenset({"running", "idle", "unknown"}) +MAX_LEDGER_BYTES = 2 * 1024 * 1024 +_LEDGER_TS = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}" + r"(?:Z|[+-][0-9]{2}:[0-9]{2})?$" +) +_BLOCKED_LEDGER_KEYS = frozenset( + { + "prompt", + "answer", + "handoff", + "argv", + "env", + "stdout", + "stderr", + "error", + "command", + "path", + "root", + "cwd", + "url", + "remote", + } +) + + +def empty_operator_twin() -> dict[str, object]: + return { + "plan": [], + "phases": [{"status": status, "tasks": []} for status in TASK_STATUSES], + "running": [], + "latest_ledger": { + "present": False, + "at": "", + "task_id": "", + "phase": "", + "facts": {}, + }, + "projected_seq": 0, + "overlay_complete": False, + } + + +def _safe_token(value: object) -> str: + token = safe_id(value) + return "" if token == REDACTED else token + + +def _safe_label(value: object) -> str: + title = safe_title(value) + return title if title != REDACTED else REDACTED if isinstance(value, str) and value else "" + + +def _task_card(task: Mapping[str, object]) -> dict[str, str]: + status = _safe_token(task.get("status")) + return { + "id": _safe_token(task.get("id")), + "title": _safe_label(task.get("title")), + "line": _safe_token(task.get("line")), + "executor": _safe_token(task.get("executor")), + "status": status if status in TASK_STATUSES else "", + } + + +def _dispatch_state(facts: Mapping[str, object]) -> str: + phase = facts.get("phase") + status = facts.get("status") + if phase == "start": + return "running" + if phase == "end" and status == "idle": + return "idle" + return "unknown" + + +def _event_task_id(event: Mapping[str, object], task_ids: set[str]) -> str: + subject = event.get("subject") + if isinstance(subject, str) and subject in task_ids: + return subject + facts = event.get("facts") + if isinstance(facts, dict): + for key in ("task_id", "task"): + value = facts.get(key) + if isinstance(value, str) and value in task_ids: + return value + return "" + + +def _objective_id(event: Mapping[str, object], objective_ids: set[str]) -> str: + for key in ("subject", "actor"): + value = event.get(key) + if isinstance(value, str) and value in objective_ids: + return value + return "" + + +def _project_latest_ledger(config: object) -> dict[str, object]: + empty = { + "present": False, + "at": "", + "task_id": "", + "phase": "", + "facts": {}, + } + path = getattr(config, "ledger_file", None) + if not isinstance(path, Path): + return empty + try: + if path.is_symlink() or not path.is_file(): + return empty + size = path.stat().st_size + if size <= 0 or size > MAX_LEDGER_BYTES: + return empty + text = path.read_text(encoding="utf-8") + except OSError: + return empty + if not text.endswith("\n"): + return empty + lines = [line for line in text.splitlines() if line] + if not lines: + return empty + try: + decoded = json.loads(lines[-1]) + except json.JSONDecodeError: + return empty + if not isinstance(decoded, dict): + return empty + raw_at = decoded.get("ts") + at = raw_at if isinstance(raw_at, str) and len(raw_at) <= 40 and _LEDGER_TS.fullmatch(raw_at) else "" + task_id = _safe_token(decoded.get("task_id")) + phase = _safe_token(decoded.get("phase")) + facts: dict[str, str | int | bool] = {} + for key, value in decoded.items(): + if key in {"ts", "task_id", "phase"} or key in _BLOCKED_LEDGER_KEYS: + continue + from .events import is_safe_event_fact + + if not is_safe_event_fact(key, value): + continue + token_key = _safe_token(key) + if not token_key: + continue + if type(value) is bool or (type(value) is int and not isinstance(value, bool)): + facts[token_key] = value + else: + cleaned = _safe_token(value) + if cleaned: + facts[token_key] = cleaned + return { + "present": True, + "at": at, + "task_id": task_id, + "phase": phase, + "facts": facts, + } + + +def project_operator_twin( + config: object, + inventory: Mapping[str, object] | None, +) -> dict[str, object]: + """Compose a fail-closed twin from inventory plus overlay reads.""" + twin = empty_operator_twin() + source = inventory if isinstance(inventory, Mapping) else {} + raw_tasks = source.get("tasks") + raw_objectives = source.get("objectives") + tasks = [item for item in raw_tasks if isinstance(item, dict)] if isinstance(raw_tasks, list) else [] + objectives = ( + [item for item in raw_objectives if isinstance(item, dict)] + if isinstance(raw_objectives, list) + else [] + ) + task_by_id: dict[str, dict[str, object]] = {} + for task in tasks: + task_id = _safe_token(task.get("id")) + if task_id: + task_by_id[task_id] = task + objective_ids = {token for token in (_safe_token(item.get("id")) for item in objectives) if token} + + latest_wave: dict[str, Mapping[str, object]] = {} + latest_dispatch: dict[str, Mapping[str, object]] = {} + board_tasks: set[str] = set() + records, overlay_complete = read_overlay_events(config) + projected_seq = 0 + if overlay_complete and records: + last_seq = records[-1].get("seq") + projected_seq = last_seq if type(last_seq) is int and last_seq >= 0 else 0 + for event in records if overlay_complete else (): + kind = event.get("kind") + if kind == "objective_wave": + objective_id = _objective_id(event, objective_ids) + if objective_id: + latest_wave[objective_id] = event + elif kind == "dispatch": + task_id = _event_task_id(event, set(task_by_id)) + if task_id: + latest_dispatch[task_id] = event + elif kind == "board": + task_id = _event_task_id(event, set(task_by_id)) + if task_id: + board_tasks.add(task_id) + + plan: list[dict[str, object]] = [] + for objective in sorted(objectives, key=lambda item: _safe_token(item.get("id"))): + objective_id = _safe_token(objective.get("id")) + if not objective_id: + continue + derived = _safe_token(objective.get("derived_result")) + selected = objective.get("selected_actions") + task_ids: list[str] = [] + if isinstance(selected, list): + for action in selected: + if not isinstance(action, dict): + continue + subject = _safe_token(action.get("subject_id")) + if subject and subject in task_by_id and subject not in task_ids: + task_ids.append(subject) + from .events import project_event + + wave = latest_wave.get(objective_id) + projected = project_event(wave) if wave is not None else None + facts = projected.get("facts") if projected else {} + mode = facts.get("mode") if isinstance(facts, dict) else "" + count = facts.get("count") if isinstance(facts, dict) else 0 + plan.append( + { + "id": objective_id, + "title": _safe_label(objective.get("title")), + "line": _safe_token(objective.get("line")), + "milestone": derived if derived in MILESTONES else "", + "wave_present": wave is not None, + "wave_id": _safe_token(projected.get("id")) if projected else "", + "wave_at": str(projected.get("at") or "") if projected and isinstance(projected.get("at"), str) and len(str(projected.get("at"))) <= 40 else "", + "wave_mode": mode if isinstance(mode, str) and _safe_token(mode) == mode else "", + "wave_count": count if type(count) is int and not isinstance(count, bool) and 0 <= count <= 1_000_000 else 0, + "task_ids": task_ids, + } + ) + twin["plan"] = plan + + phases = {status: [] for status in TASK_STATUSES} + for task in tasks: + card = _task_card(task) + if not card["id"] or card["status"] not in TASK_STATUSES: + continue + phases[card["status"]].append(card) + twin["phases"] = [ + { + "status": status, + "tasks": sorted(phases[status], key=lambda item: item["id"]), + } + for status in TASK_STATUSES + ] + + running: list[dict[str, object]] = [] + for task_id, task in sorted(task_by_id.items()): + if _safe_token(task.get("status")) != "in_progress": + continue + card = _task_card(task) + from .events import project_event + + event = latest_dispatch.get(task_id) + projected = project_event(event) if event is not None else None + facts = dict(projected.get("facts") or {}) if projected else {} + running.append( + { + "id": card["id"], + "title": card["title"], + "line": card["line"], + "executor": card["executor"], + "dispatch_present": event is not None, + "dispatch_id": _safe_token(projected.get("id")) if projected else "", + "dispatch_at": str(projected.get("at") or "") if projected and isinstance(projected.get("at"), str) and len(str(projected.get("at"))) <= 40 else "", + "dispatch_state": _dispatch_state(facts) if event is not None else "unknown", + "dispatch_facts": facts, + "board_landed": task_id in board_tasks, + } + ) + twin["running"] = running + twin["latest_ledger"] = _project_latest_ledger(config) + twin["projected_seq"] = projected_seq + twin["overlay_complete"] = overlay_complete + return twin diff --git a/src/dyro/events.py b/src/dyro/events.py index 9076dc4..00a0896 100644 --- a/src/dyro/events.py +++ b/src/dyro/events.py @@ -183,6 +183,35 @@ def _read_locked_records(path: Path) -> list[dict[str, object]]: return records +def read_overlay_events(config: object) -> tuple[tuple[dict[str, object], ...], bool]: + """Read overlay events without creating a lock or inventing rows. + + A missing file is a complete empty log. Truncation, replacement, or + an unreadable path is incomplete: callers must fail closed to the + snapshot and must not replay a readable prefix. + """ + try: + path = events_path(config) # type: ignore[arg-type] + if path.is_symlink(): + return (), False + if not path.exists(): + return (), True + return tuple(_read_locked_records(path)), True + except (EventLogError, OSError, TypeError, AttributeError): + return (), False + + +def read_events_fail_closed(config: object) -> tuple[dict[str, object], ...]: + """Read overlay events without creating a lock or inventing rows. + + A missing file is empty. Truncation, replacement, or an unreadable + path returns no rows. Callers must not treat emptiness as proof that + no work happened. + """ + records, complete = read_overlay_events(config) + return records if complete else () + + def read_events( config: Config, *, diff --git a/tests/support/console_twin_live.mjs b/tests/support/console_twin_live.mjs new file mode 100644 index 0000000..a387bac --- /dev/null +++ b/tests/support/console_twin_live.mjs @@ -0,0 +1,124 @@ +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const code = fs.readFileSync( + path.join(root, "src/dyro/console/assets/app.js"), + "utf8", +); + +function fakeNode(tag) { + const node = { + tagName: String(tag).toUpperCase(), + children: [], + className: "", + id: "", + hidden: false, + type: "", + textContent: "", + dataset: {}, + classList: { + add(...names) { + const current = new Set(String(node.className).split(/\s+/).filter(Boolean)); + for (const name of names) current.add(name); + node.className = [...current].join(" "); + }, + toggle(name, force) { + const current = new Set(String(node.className).split(/\s+/).filter(Boolean)); + const on = force === undefined ? !current.has(name) : Boolean(force); + if (on) current.add(name); + else current.delete(name); + node.className = [...current].join(" "); + }, + }, + append(...items) { + for (const item of items) node.children.push(item); + }, + addEventListener() {}, + replaceWith() {}, + replaceChildren(...items) { + node.children = items.slice(); + }, + }; + return node; +} + +const context = vm.createContext({ + console, + Set, + Map, + JSON, + Date, + Number, + Boolean, + String, + Array, + Object, + Math, + Error, + TextDecoder, + URLSearchParams, + document: { + hidden: false, + getElementById: () => null, + createElement: (name) => fakeNode(name), + createTextNode: (value) => { + const node = fakeNode("#text"); + node.textContent = String(value); + return node; + }, + addEventListener() {}, + }, + window: { + location: { hash: "", pathname: "/", search: "" }, + history: { replaceState() {} }, + setTimeout() {}, + clearTimeout() {}, + addEventListener() {}, + }, + sessionStorage: { + getItem() { + return null; + }, + setItem() {}, + removeItem() {}, + }, + fetch: async () => ({ ok: false, status: 404, json: async () => null }), +}); +context.globalThis = context; +vm.runInContext(code, context); + +const api = context.__dyroTwinLive; +if ( + !api + || typeof api.mergeTwinFromEvents !== "function" + || typeof api.twinFromData !== "function" + || typeof api.renderOperatorTwin !== "function" +) { + throw new Error("renderOperatorTwin / mergeTwinFromEvents is not loaded"); +} + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const snapshot = input.snapshot || {}; +const events = Array.isArray(input.events) ? input.events : []; +api.renderOperatorTwin(snapshot); +api.mergeTwinFromEvents(events); +const twin = api.getState().operatorTwin; +const done = (twin.phases.find((column) => column.status === "done") || { tasks: [] }).tasks.map( + (task) => task.id, +); +process.stdout.write( + JSON.stringify({ + running: twin.running, + done_ids: done, + plan_ids: (twin.plan || []).map((row) => row.id), + rendered: api.renderedTwinText(twin), + board_landed: (twin.running || []).some((row) => row.board_landed), + wave_ids: (twin.plan || []).filter((row) => row.wave_present).map((row) => row.id), + projected_seq: twin.projected_seq, + overlay_complete: twin.overlay_complete === true, + after_seq: api.getState().twinAfterSeq, + }), +); diff --git a/tests/test_console_assets.py b/tests/test_console_assets.py index 62d6903..a2740bc 100644 --- a/tests/test_console_assets.py +++ b/tests/test_console_assets.py @@ -1,6 +1,8 @@ from __future__ import annotations from http.client import HTTPConnection +import json +import re from threading import Thread import unittest @@ -111,6 +113,28 @@ def test_shell_exposes_a_semantic_command_center(self) -> None: self.assertIn("function loadArtifacts".encode(), script.body) self.assertIn("function channelRowKey".encode(), script.body) self.assertIn("function requestWrite".encode(), script.body) + self.assertIn("function renderOperatorTwin".encode(), script.body) + self.assertIn("function renderTwinPlan".encode(), script.body) + self.assertIn("function renderTwinPhases".encode(), script.body) + self.assertIn("function renderTwinRunning".encode(), script.body) + self.assertIn("function mergeTwinFromEvents".encode(), script.body) + self.assertIn("function applyLiveTwinEvents".encode(), script.body) + self.assertIn("overlayComplete !== true".encode(), script.body) + self.assertIn("seq <= floor".encode(), script.body) + self.assertIn("function eventKnownTaskId".encode(), script.body) + self.assertIn("function moveTwinTask".encode(), script.body) + self.assertIn("to_status".encode(), script.body) + self.assertIn("workspace-room".encode(), script.body) + self.assertIn("这一列没有任务".encode(), script.body) + self.assertIn("状态未知".encode(), script.body) + self.assertIn("计划".encode(), script.body) + self.assertIn("里程碑".encode(), script.body) + self.assertIn("阶段".encode(), script.body) + self.assertIn("谁在跑".encode(), script.body) + self.assertIn("未见会审记录".encode(), script.body) + self.assertIn("未见波次".encode(), script.body) + self.assertIn("未见账本行".encode(), script.body) + self.assertIn("会审已落下".encode(), script.body) self.assertIn("events/stream".encode(), script.body) self.assertIn("families/".encode(), script.body) self.assertIn("以 operator 身份发送".encode(), script.body) @@ -133,6 +157,38 @@ def test_shell_exposes_a_semantic_command_center(self) -> None: self.assertIn("未检查".encode(), script.body) self.assertNotIn("干净".encode(), script.body) self.assertNotIn("远端已绑定".encode(), script.body) + self.assertNotIn(b"fonts.googleapis", script.body) + self.assertNotIn(b"fonts.gstatic", script.body) + self.assertNotIn(b"unpkg.com", script.body) + self.assertNotIn(b"three.js", script.body) + styles = load_asset("styles.css") + bundled = shell.body + script.body + styles.body + lowered = bundled.lower() + self.assertNotIn(b"websocket", lowered) + self.assertNotIn(b"serviceworker", lowered) + self.assertNotIn(b"webgl", lowered) + live_tabs = re.findall( + rb"function renderLivePanes[\s\S]*?for \(const \[id, label\] of (\[\[.*?\]\])\)", + script.body, + ) + self.assertEqual(len(live_tabs), 1) + tabs = json.loads(live_tabs[0]) + self.assertEqual([row[0] for row in tabs], ["family", "events", "channel"]) + self.assertEqual(len(tabs), 3) + self.assertEqual( + script.body.count('[["family", "家族"], ["events", "事件"], ["channel", "频道"]]'.encode()), + 1, + ) + self.assertIn(b"--filament", styles.body) + self.assertIn(b"--bay: #141A1F", styles.body) + self.assertIn(b"ui-serif", styles.body) + self.assertIn(b"ui-monospace", styles.body) + self.assertIn(b"workspace-room", styles.body) + self.assertIn(b"family-jack.is-focus", styles.body) + self.assertNotIn(b"fonts.googleapis", styles.body) + self.assertNotIn(b"fonts.gstatic", styles.body) + self.assertNotIn(b"@import", styles.body) + self.assertIn(b"prefers-reduced-motion", styles.body) if __name__ == "__main__": diff --git a/tests/test_console_inspection.py b/tests/test_console_inspection.py index 8be7b8c..bb363f2 100644 --- a/tests/test_console_inspection.py +++ b/tests/test_console_inspection.py @@ -50,7 +50,17 @@ def test_exec_worker_returns_overview_and_single_workspace_without_root_disclosu self.assertEqual(inspect["data"]["proof_inspection"], "inspected") self.assertEqual(overview["data"]["workspaces"][0]["proof_inspection"], "not_inspected") self.assertEqual(workspace["data"]["workspace"]["proof_inspection"], "not_inspected") - self.assertEqual(set(workspace["data"]), {"workspace", "lines", "tasks", "objectives"}) + self.assertEqual( + set(workspace["data"]), + {"workspace", "lines", "tasks", "objectives", "operator_twin"}, + ) + twin = workspace["data"]["operator_twin"] + self.assertEqual( + set(twin), + {"plan", "phases", "running", "latest_ledger", "projected_seq", "overlay_complete"}, + ) + self.assertFalse(twin["latest_ledger"]["present"]) + self.assertEqual(twin["running"], []) self.assertTrue( all("parent" in item for item in workspace["data"]["lines"]) ) @@ -403,6 +413,88 @@ def test_parent_rejects_an_inspected_summary_card(self) -> None: with self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_UNAVAILABLE"): service._parse_worker_output(raw, expected_operation="workspace") + def test_parent_rejects_twin_plan_or_running_unbound_from_inventory(self) -> None: + service = IsolatedOverviewService( + registry_state_home=self.home, + timeout_seconds=5, + cursor_secret=b"q" * 32, + ) + valid = service.workspace("demo") + digest = "a" * 64 + objective = { + "id": "release", + "title": "Release readiness", + "line": "core", + "revision": 1, + "operator_state": "active", + "derived_result": "incomplete", + "requested_mode": "supervised", + "operations": ["execute"], + "scope_count": 1, + "budget": {"max_actions": 1}, + "selected_actions": [], + "blocked_actions": [], + "attention": [], + "contract_sha256": digest, + "scope_sha256": digest, + "event_sha256": digest, + } + ghost_plan = { + "id": "ghost", + "title": "Ghost", + "line": "core", + "milestone": "incomplete", + "wave_present": False, + "wave_id": "", + "wave_at": "", + "wave_mode": "", + "wave_count": 0, + "task_ids": [], + } + running = { + "id": "TASK-A", + "title": "Pay path", + "line": "core", + "executor": "noop", + "dispatch_present": False, + "dispatch_id": "", + "dispatch_at": "", + "dispatch_state": "unknown", + "dispatch_facts": {}, + "board_landed": True, + } + + def resign(payload: dict[str, object]) -> bytes: + payload["snapshot_sha256"] = hashlib.sha256( + canonical_json_bytes( + { + "schema_version": 1, + "freshness": payload["freshness"], + "data": payload["data"], + } + ) + ).hexdigest() + return json.dumps({"ok": True, "payload": payload}).encode("utf-8") + + ghost = deepcopy(valid) + ghost["data"]["objectives"] = [objective] + ghost["data"]["operator_twin"]["plan"] = [ghost_plan] + with self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_UNAVAILABLE"): + service._parse_worker_output(resign(ghost), expected_operation="workspace") + + unbound_running = deepcopy(valid) + unbound_running["data"]["operator_twin"]["running"] = [running] + in_progress = next( + column + for column in unbound_running["data"]["operator_twin"]["phases"] + if column["status"] == "in_progress" + ) + in_progress["tasks"] = [] + with self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_UNAVAILABLE"): + service._parse_worker_output( + resign(unbound_running), expected_operation="workspace" + ) + def test_parent_rejects_workspace_inventory_that_leaks_inspect(self) -> None: service = IsolatedOverviewService( registry_state_home=self.home, diff --git a/tests/test_console_overview.py b/tests/test_console_overview.py index c68aaf1..621971e 100644 --- a/tests/test_console_overview.py +++ b/tests/test_console_overview.py @@ -276,7 +276,11 @@ def test_single_workspace_reuses_the_same_summary_and_rejects_unsafe_aliases(sel self.assertEqual(payload["data"]["tasks"][0]["id"], "TASK-A") self.assertEqual(payload["data"]["tasks"][0]["integration_state"], "not_inspected") self.assertEqual(payload["data"]["objectives"][0]["id"], "release") + self.assertEqual(payload["data"]["operator_twin"]["plan"][0]["id"], "release") + self.assertFalse(payload["data"]["operator_twin"]["plan"][0]["wave_present"]) + self.assertFalse(payload["data"]["operator_twin"]["latest_ledger"]["present"]) self.assertNotIn("proofs", payload["data"]) + self.assertNotIn("operator_twin", page["data"]) self.assertNotIn("lines", page["data"]) self.assertNotIn("tasks", page["data"]) self.assertNotIn("objectives", page["data"]) @@ -312,6 +316,8 @@ def test_unavailable_workspace_keeps_empty_inventory_keys(self) -> None: self.assertEqual(payload["data"]["lines"], []) self.assertEqual(payload["data"]["tasks"], []) self.assertEqual(payload["data"]["objectives"], []) + self.assertEqual(payload["data"]["operator_twin"]["plan"], []) + self.assertFalse(payload["data"]["operator_twin"]["latest_ledger"]["present"]) self.assertNotIn("proofs", payload["data"]) def test_inspect_proofs_does_not_use_summary_loader_and_can_show_decay(self) -> None: diff --git a/tests/test_console_twin.py b/tests/test_console_twin.py new file mode 100644 index 0000000..673e1d8 --- /dev/null +++ b/tests/test_console_twin.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import json +import os +import unittest +from unittest.mock import patch + +from dyro.config import load +from dyro.console.inspection import IsolatedOverviewService +from dyro.console.overview import ConsoleOverviewService +from dyro.console.twin import empty_operator_twin, project_operator_twin +from dyro.continuation.store import create_objective +from dyro.events import append_event +from dyro.hub import add_workspace +from dyro.tasks import ledger, set_status, task_template +from dyro.workspace import create_line + +from .support import WorkspaceCase + + +OBJECTIVE = '''schema_version = 1 +id = "release" +title = "Release readiness" +line = "core" +targets = ["TASK-A"] + +[continuation] +requested_mode = "supervised" +operations = ["execute", "review"] + +[budget] +max_actions = 5 +max_attempts_per_task = 2 +max_failures = 2 +max_no_progress_cycles = 2 +max_parallel = 1 +''' + + +class OperatorTwinProjectionTests(WorkspaceCase): + def setUp(self) -> None: + super().setUp() + self.config = load(self.root) + create_line(self.config, line_id="core", branch="feat/core", base="main") + task = self.config.task_specs_dir / "TASK-A" + task.mkdir(parents=True) + task.joinpath("task.toml").write_text( + task_template("TASK-A", "Pay path", "core", "api", "services/api"), + encoding="utf-8", + ) + task.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + create_objective(self.config, OBJECTIVE) + self.clock = lambda: datetime(2026, 8, 20, 12, 0, tzinfo=timezone.utc) + + def _service(self) -> ConsoleOverviewService: + home = self.root / "console-state" + with patch.dict(os.environ, {"DYRO_HOME": str(home)}): + add_workspace(self.root, name=self.config.name, make_default=True) + from dyro.hub import load_registry_from_home + + return ConsoleOverviewService( + registry_loader=lambda: load_registry_from_home(home), + cursor_secret=b"k" * 32, + ) + + def test_missing_wave_dispatch_board_and_ledger_fail_closed(self) -> None: + service = self._service() + payload = service.workspace(self.config.name) + twin = payload["data"]["operator_twin"] + self.assertEqual([row["id"] for row in twin["plan"]], ["release"]) + self.assertFalse(twin["plan"][0]["wave_present"]) + self.assertEqual(twin["plan"][0]["milestone"], "incomplete") + self.assertEqual(twin["running"], []) + self.assertFalse(twin["latest_ledger"]["present"]) + self.assertEqual(twin["latest_ledger"]["facts"], {}) + self.assertFalse((self.root / ".dyro" / "events.lock").exists()) + + def test_swimlanes_do_not_invent_objectives_from_wave_events(self) -> None: + append_event( + self.config, + kind="objective_wave", + actor="ghost", + subject="ghost", + family="core", + facts={"mode": "apply", "count": 2}, + clock=self.clock, + ) + twin = project_operator_twin( + self.config, + { + "tasks": [{"id": "TASK-A", "title": "Pay path", "line": "core", "status": "backlog", "executor": "noop"}], + "objectives": [ + { + "id": "release", + "title": "Release readiness", + "line": "core", + "derived_result": "incomplete", + "selected_actions": [{"kind": "execute", "subject_id": "TASK-A", "reason": "TASK_READY"}], + } + ], + }, + ) + self.assertEqual([row["id"] for row in twin["plan"]], ["release"]) + self.assertFalse(twin["plan"][0]["wave_present"]) + self.assertEqual(twin["plan"][0]["task_ids"], ["TASK-A"]) + + def test_wave_and_dispatch_and_board_project_only_from_existing_rows(self) -> None: + item = __import__("dyro.tasks", fromlist=["load_task"]).load_task(self.config, "TASK-A") + set_status(self.config, item, "assigned") + set_status(self.config, item, "in_progress") + append_event( + self.config, + kind="objective_wave", + actor="release", + subject="release", + family="core", + facts={"mode": "apply", "count": 1}, + clock=self.clock, + ) + append_event( + self.config, + kind="dispatch", + actor="core", + subject="TASK-A", + family="core", + facts={"executor": "noop", "phase": "start"}, + clock=self.clock, + ) + append_event( + self.config, + kind="board", + actor="core", + subject="TASK-A", + family="core", + facts={"result": "recorded"}, + clock=self.clock, + ) + service = self._service() + twin = service.workspace(self.config.name)["data"]["operator_twin"] + self.assertTrue(twin["plan"][0]["wave_present"]) + self.assertEqual(twin["plan"][0]["wave_mode"], "apply") + self.assertEqual(len(twin["running"]), 1) + self.assertEqual(twin["running"][0]["id"], "TASK-A") + self.assertEqual(twin["running"][0]["executor"], "codex") + self.assertTrue(twin["running"][0]["dispatch_present"]) + self.assertEqual(twin["running"][0]["dispatch_state"], "running") + self.assertTrue(twin["running"][0]["board_landed"]) + in_progress = next(column for column in twin["phases"] if column["status"] == "in_progress") + self.assertEqual([task["id"] for task in in_progress["tasks"]], ["TASK-A"]) + + def test_who_is_running_does_not_claim_board_without_board_event(self) -> None: + item = __import__("dyro.tasks", fromlist=["load_task"]).load_task(self.config, "TASK-A") + set_status(self.config, item, "assigned") + set_status(self.config, item, "in_progress") + append_event( + self.config, + kind="dispatch", + actor="core", + subject="TASK-A", + family="core", + facts={"executor": "noop", "phase": "end", "status": "idle"}, + clock=self.clock, + ) + twin = project_operator_twin( + self.config, + { + "tasks": [ + { + "id": "TASK-A", + "title": "Pay path", + "line": "core", + "status": "in_progress", + "executor": "noop", + } + ], + "objectives": [], + }, + ) + self.assertEqual(twin["running"][0]["dispatch_state"], "idle") + self.assertFalse(twin["running"][0]["board_landed"]) + + def test_who_is_running_does_not_claim_a_board_for_another_task(self) -> None: + item = __import__("dyro.tasks", fromlist=["load_task"]).load_task(self.config, "TASK-A") + set_status(self.config, item, "assigned") + set_status(self.config, item, "in_progress") + other = self.config.task_specs_dir / "TASK-B" + other.mkdir(parents=True) + other.joinpath("task.toml").write_text( + task_template("TASK-B", "Other path", "core", "api", "services/api"), + encoding="utf-8", + ) + other.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + inventory = { + "tasks": [ + { + "id": "TASK-A", + "title": "Pay path", + "line": "core", + "status": "in_progress", + "executor": "noop", + }, + { + "id": "TASK-B", + "title": "Other path", + "line": "core", + "status": "backlog", + "executor": "noop", + }, + ], + "objectives": [], + } + for subject, facts in ( + ("ghost-task", {"result": "recorded"}), + ("TASK-B", {"task_id": "TASK-B", "result": "recorded"}), + ): + with self.subTest(subject=subject): + append_event( + self.config, + kind="board", + actor="core", + subject=subject, + family="core", + facts=facts, + clock=self.clock, + ) + twin = project_operator_twin(self.config, inventory) + self.assertEqual(twin["running"][0]["id"], "TASK-A") + self.assertFalse(twin["running"][0]["board_landed"]) + + def test_truncated_events_and_ledger_fail_closed(self) -> None: + append_event( + self.config, + kind="objective_wave", + actor="release", + subject="release", + family="core", + facts={"mode": "apply", "count": 1}, + clock=self.clock, + ) + events = self.root / ".dyro" / "events.jsonl" + events.write_text(events.read_text(encoding="utf-8").rstrip("\n"), encoding="utf-8") + ledger( + self.config, + "TASK-A", + "execution_heads", + task_heads_sha256="a" * 64, + prompt="do not leak", + argv="/usr/bin/true", + ) + ledger_path = self.config.ledger_file + ledger_path.write_text(ledger_path.read_text(encoding="utf-8") + '{"ts":', encoding="utf-8") + inventory = { + "tasks": [ + { + "id": "TASK-A", + "title": "Pay path", + "line": "core", + "status": "in_progress", + "executor": "noop", + } + ], + "objectives": [ + { + "id": "release", + "title": "Release readiness", + "line": "core", + "derived_result": "complete", + "selected_actions": [], + } + ], + } + twin = project_operator_twin(self.config, inventory) + self.assertEqual(twin["plan"][0]["milestone"], "complete") + self.assertFalse(twin["plan"][0]["wave_present"]) + self.assertFalse(twin["running"][0]["dispatch_present"]) + self.assertFalse(twin["running"][0]["board_landed"]) + self.assertFalse(twin["latest_ledger"]["present"]) + self.assertFalse(twin["overlay_complete"]) + got = self._service().workspace(self.config.name)["data"]["operator_twin"] + self.assertFalse(got["overlay_complete"]) + + def test_latest_ledger_line_is_redacted(self) -> None: + ledger( + self.config, + "TASK-A", + "execution_heads", + task_heads_sha256="ab" * 32, + prompt="secret prompt", + argv="/tmp/bin --yes", + error=str(self.root / "private.log"), + parent="core", + child="core_pay", + ) + twin = project_operator_twin(self.config, {"tasks": [], "objectives": []}) + row = twin["latest_ledger"] + self.assertTrue(row["present"]) + self.assertEqual(row["task_id"], "TASK-A") + self.assertEqual(row["phase"], "execution_heads") + self.assertEqual(row["facts"]["parent"], "core") + self.assertEqual(row["facts"]["child"], "core_pay") + rendered = json.dumps(row) + self.assertNotIn("secret prompt", rendered) + self.assertNotIn("/tmp/bin", rendered) + self.assertNotIn("private.log", rendered) + self.assertNotIn("--yes", rendered) + self.assertNotIn(str(self.root), rendered) + + def test_latest_ledger_drops_blocked_keys_even_when_values_are_safe_tokens(self) -> None: + ledger( + self.config, + "TASK-A", + "execution_heads", + prompt="apply", + argv="codex", + parent="core", + ) + twin = project_operator_twin(self.config, {"tasks": [], "objectives": []}) + facts = twin["latest_ledger"]["facts"] + self.assertNotIn("prompt", facts) + self.assertNotIn("argv", facts) + self.assertEqual(facts.get("parent"), "core") + + def test_milestone_does_not_invent_a_fourth_state(self) -> None: + twin = project_operator_twin( + self.config, + { + "tasks": [], + "objectives": [ + { + "id": "release", + "title": "Release readiness", + "line": "core", + "derived_result": "shipped", + "selected_actions": [], + } + ], + }, + ) + self.assertEqual(twin["plan"][0]["milestone"], "") + + def test_workspace_read_does_not_create_overlay_lock_or_ledger(self) -> None: + service = self._service() + before = {path.relative_to(self.root) for path in self.root.rglob("*") if path.is_file()} + service.workspace(self.config.name) + after = {path.relative_to(self.root) for path in self.root.rglob("*") if path.is_file()} + self.assertEqual(before, after) + + def test_workspace_get_does_not_append_events_or_ledger_in_place(self) -> None: + append_event( + self.config, + kind="spawn", + actor="core", + subject="core_pay", + family="core", + facts={"parent": "core", "child": "core_pay"}, + clock=self.clock, + ) + ledger( + self.config, + "TASK-A", + "execution_heads", + task_heads_sha256="ab" * 32, + parent="core", + ) + events = self.root / ".dyro" / "events.jsonl" + ledger_path = self.config.ledger_file + before_events = hashlib.sha256(events.read_bytes()).hexdigest() + before_ledger = hashlib.sha256(ledger_path.read_bytes()).hexdigest() + self._service().workspace(self.config.name) + self.assertEqual(hashlib.sha256(events.read_bytes()).hexdigest(), before_events) + self.assertEqual(hashlib.sha256(ledger_path.read_bytes()).hexdigest(), before_ledger) + + def test_task_status_events_do_not_invent_phase_cards(self) -> None: + append_event( + self.config, + kind="task_status", + actor="core", + subject="ghost-task", + family="core", + facts={"from_status": "backlog", "to_status": "in_progress"}, + clock=self.clock, + ) + twin = project_operator_twin( + self.config, + { + "tasks": [ + { + "id": "TASK-A", + "title": "Pay path", + "line": "core", + "status": "backlog", + "executor": "noop", + } + ], + "objectives": [], + }, + ) + backlog = next(column for column in twin["phases"] if column["status"] == "backlog") + in_progress = next(column for column in twin["phases"] if column["status"] == "in_progress") + self.assertEqual([task["id"] for task in backlog["tasks"]], ["TASK-A"]) + self.assertEqual(in_progress["tasks"], []) + self.assertEqual(twin["running"], []) + + def test_empty_twin_shape_is_stable(self) -> None: + twin = empty_operator_twin() + self.assertEqual( + set(twin), + {"plan", "phases", "running", "latest_ledger", "projected_seq", "overlay_complete"}, + ) + self.assertEqual(twin["projected_seq"], 0) + self.assertFalse(twin["overlay_complete"]) + self.assertEqual([column["status"] for column in twin["phases"]], [ + "backlog", + "assigned", + "in_progress", + "waiting_answer", + "review", + "review_pending_signoff", + "done", + "failed", + ]) + + +class IsolatedOperatorTwinTests(WorkspaceCase): + def setUp(self) -> None: + super().setUp() + self.home = self.root / "console-state" + self.environment = patch.dict(os.environ, {"DYRO_HOME": str(self.home)}) + self.environment.start() + add_workspace(self.root, name="demo", make_default=True) + + def tearDown(self) -> None: + self.environment.stop() + super().tearDown() + + def test_isolated_workspace_accepts_operator_twin(self) -> None: + service = IsolatedOverviewService( + registry_state_home=self.home, + timeout_seconds=5, + cursor_secret=b"q" * 32, + ) + payload = service.workspace("demo") + twin = payload["data"]["operator_twin"] + self.assertEqual( + set(twin), + {"plan", "phases", "running", "latest_ledger", "projected_seq", "overlay_complete"}, + ) + self.assertTrue(twin["overlay_complete"]) + self.assertEqual(twin["projected_seq"], 0) + self.assertFalse(twin["latest_ledger"]["present"]) + self.assertNotIn(str(self.root), repr(payload)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_console_twin_live.py b/tests/test_console_twin_live.py new file mode 100644 index 0000000..2959d05 --- /dev/null +++ b/tests/test_console_twin_live.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import json +import os +import shutil +import subprocess +import unittest +from pathlib import Path +from unittest.mock import patch + +from dyro.config import load +from dyro.console.overview import ConsoleOverviewError, ConsoleOverviewService +from dyro.console.twin import TASK_STATUSES +from dyro.continuation.store import create_objective +from dyro.events import append_event, read_overlay_events +from dyro.hub import add_workspace +from dyro.tasks import load_task, set_status, task_template +from dyro.workspace import create_line + +from .support import WorkspaceCase + + +OBJECTIVE = '''schema_version = 1 +id = "release" +title = "Release readiness" +line = "core" +targets = ["TASK-A"] + +[continuation] +requested_mode = "supervised" +operations = ["execute", "review"] + +[budget] +max_actions = 5 +max_attempts_per_task = 2 +max_failures = 2 +max_no_progress_cycles = 2 +max_parallel = 1 +''' + + +HARNESS = Path(__file__).resolve().parent / "support" / "console_twin_live.mjs" +APP_JS = Path(__file__).resolve().parents[1] / "src" / "dyro" / "console" / "assets" / "app.js" + + +def _task_card(task_id: str, status: str) -> dict[str, str]: + return { + "id": task_id, + "title": "Pay path", + "line": "core", + "executor": "noop", + "status": status, + } + + +def _empty_phases(done_id: str = "") -> list[dict[str, object]]: + phases = [{"status": status, "tasks": []} for status in TASK_STATUSES] + if done_id: + done = next(column for column in phases if column["status"] == "done") + done["tasks"] = [_task_card(done_id, "done")] + return phases + + +def _snapshot(*, projected_seq: int, overlay_complete: bool = True) -> dict[str, object]: + return { + "tasks": [ + { + "id": "TASK-A", + "title": "Pay path", + "line": "core", + "status": "done", + "executor": "noop", + } + ], + "operator_twin": { + "plan": [ + { + "id": "release", + "title": "Release readiness", + "line": "core", + "milestone": "complete", + "wave_present": False, + "wave_id": "", + "wave_at": "", + "wave_mode": "", + "wave_count": 0, + "task_ids": ["TASK-A"], + } + ], + "phases": _empty_phases("TASK-A"), + "running": [], + "latest_ledger": { + "present": False, + "at": "", + "task_id": "", + "phase": "", + "facts": {}, + }, + "projected_seq": projected_seq, + "overlay_complete": overlay_complete, + }, + } + + +def _event(seq: int, kind: str, subject: str, facts: dict[str, object] | None = None) -> dict[str, object]: + return { + "seq": seq, + "id": f"evt_{seq}", + "kind": kind, + "at": "2026-08-20T12:00:00Z", + "actor": "core", + "subject": subject, + "family": "core", + "facts": facts or {}, + } + + +def _prefix_page() -> list[dict[str, object]]: + """Oldest 50 rows: early in_progress + board, then fillers.""" + events = [ + _event(1, "task_status", "TASK-A", {"from_status": "assigned", "to_status": "in_progress"}), + _event(2, "board", "TASK-A", {"result": "recorded"}), + ] + for seq in range(3, 51): + events.append(_event(seq, "spawn", "core_pay", {"parent": "core", "child": "core_pay"})) + return events + + +def _run_live(payload: dict[str, object]) -> dict[str, object]: + node = shutil.which("node") + if not node: + raise AssertionError("node is required to exercise mergeTwinFromEvents") + completed = subprocess.run( + [node, str(HARNESS)], + input=json.dumps(payload), + text=True, + capture_output=True, + check=False, + ) + if completed.returncode != 0: + raise AssertionError(completed.stderr or completed.stdout or "twin live harness failed") + return json.loads(completed.stdout) + + +class MergeTwinFromEventsTests(unittest.TestCase): + def test_ghost_wave_does_not_push_a_lane(self) -> None: + snapshot = _snapshot(projected_seq=0, overlay_complete=True) + result = _run_live( + { + "snapshot": snapshot, + "events": [ + _event(1, "objective_wave", "ghost", {"mode": "apply", "count": 2}), + ], + } + ) + self.assertEqual(result["plan_ids"], ["release"]) + self.assertEqual(result["wave_ids"], []) + + def test_board_for_other_or_non_running_task_does_not_land(self) -> None: + snapshot = _snapshot(projected_seq=0, overlay_complete=True) + in_progress = next( + column + for column in snapshot["operator_twin"]["phases"] + if column["status"] == "in_progress" + ) + done = next( + column + for column in snapshot["operator_twin"]["phases"] + if column["status"] == "done" + ) + done["tasks"] = [] + in_progress["tasks"] = [_task_card("TASK-A", "in_progress")] + snapshot["tasks"][0]["status"] = "in_progress" + snapshot["operator_twin"]["running"] = [ + { + "id": "TASK-A", + "title": "Pay path", + "line": "core", + "executor": "noop", + "dispatch_present": False, + "dispatch_id": "", + "dispatch_at": "", + "dispatch_state": "unknown", + "dispatch_facts": {}, + "board_landed": False, + } + ] + for subject in ("ghost-task", "TASK-B"): + with self.subTest(subject=subject): + result = _run_live( + { + "snapshot": snapshot, + "events": [_event(1, "board", subject, {"task_id": subject})], + } + ) + self.assertEqual(len(result["running"]), 1) + self.assertFalse(result["board_landed"]) + self.assertNotIn("会审已落下", result["rendered"]) + + def test_harness_loads_the_page_merge_functions(self) -> None: + script = APP_JS.read_text(encoding="utf-8") + self.assertIn("function mergeTwinFromEvents", script) + self.assertIn("function twinFromData", script) + self.assertIn("function renderOperatorTwin", script) + self.assertIn("function applyLiveTwinEvents", script) + self.assertIn("source.overlay_complete === true", script) + self.assertIn("state.twinAfterSeq = state.operatorTwin.projected_seq", script) + self.assertIn("overlayComplete !== true", script) + self.assertIn("seq <= floor", script) + + +class GetTwinFloorBindTests(WorkspaceCase): + def setUp(self) -> None: + super().setUp() + self.config = load(self.root) + create_line(self.config, line_id="core", branch="feat/core", base="main") + task = self.config.task_specs_dir / "TASK-A" + task.mkdir(parents=True) + task.joinpath("task.toml").write_text( + task_template("TASK-A", "Pay path", "core", "api", "services/api"), + encoding="utf-8", + ) + task.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + create_objective(self.config, OBJECTIVE) + self.clock = lambda: datetime(2026, 8, 20, 12, 0, tzinfo=timezone.utc) + + def _service(self) -> ConsoleOverviewService: + home = self.root / "console-state" + with patch.dict(os.environ, {"DYRO_HOME": str(home)}): + add_workspace(self.root, name=self.config.name, make_default=True) + from dyro.hub import load_registry_from_home + + return ConsoleOverviewService( + registry_loader=lambda: load_registry_from_home(home), + cursor_secret=b"k" * 32, + ) + + def _early_in_progress_and_board(self) -> None: + item = load_task(self.config, "TASK-A") + set_status(self.config, item, "assigned") + set_status(self.config, item, "in_progress") + append_event( + self.config, + kind="board", + actor="core", + subject="TASK-A", + family="core", + facts={"result": "recorded"}, + clock=self.clock, + ) + + def _force_inventory_done(self) -> None: + item = load_task(self.config, "TASK-A") + (item.directory / "status").write_text("done\n", encoding="utf-8") + + def _assert_prefix_does_not_invent_running(self, result: dict[str, object]) -> None: + self.assertEqual(result["running"], []) + self.assertEqual(result["done_ids"], ["TASK-A"]) + self.assertFalse(result["board_landed"]) + self.assertNotIn("会审已落下", result["rendered"]) + + def test_workspace_projected_seq_is_the_live_floor_for_the_first_page(self) -> None: + self._early_in_progress_and_board() + for _ in range(52): + append_event( + self.config, + kind="spawn", + actor="core", + subject="core_pay", + family="core", + facts={"parent": "core", "child": "core_pay"}, + clock=self.clock, + ) + self._force_inventory_done() + records, complete = read_overlay_events(self.config) + self.assertTrue(complete) + self.assertGreater(len(records), 50) + last_seq = records[-1]["seq"] + self.assertEqual(last_seq, len(records)) + self.assertGreater(last_seq, 50) + + service = self._service() + payload = service.workspace(self.config.name) + data = payload["data"] + twin = data["operator_twin"] + self.assertTrue(twin["overlay_complete"]) + self.assertEqual(twin["projected_seq"], last_seq) + self.assertEqual(twin["running"], []) + done = next(column for column in twin["phases"] if column["status"] == "done") + self.assertEqual([task["id"] for task in done["tasks"]], ["TASK-A"]) + + first_page = service.events(self.config.name)["data"]["events"] + self.assertEqual(len(first_page), 50) + self.assertTrue(all(event["seq"] <= 50 for event in first_page)) + self.assertTrue( + any( + event["kind"] == "task_status" and event["facts"].get("to_status") == "in_progress" + for event in first_page + ) + ) + self.assertTrue(any(event["kind"] == "board" and event["subject"] == "TASK-A" for event in first_page)) + + result = _run_live({"snapshot": data, "events": first_page}) + self._assert_prefix_does_not_invent_running(result) + self.assertEqual(result["after_seq"], last_seq) + self.assertEqual(result["projected_seq"], last_seq) + + def test_truncated_workspace_get_fail_closes_before_prefix_merge(self) -> None: + self._early_in_progress_and_board() + events = self.root / ".dyro" / "events.jsonl" + events.write_text(events.read_text(encoding="utf-8").rstrip("\n"), encoding="utf-8") + self._force_inventory_done() + service = self._service() + payload = service.workspace(self.config.name) + data = payload["data"] + twin = data["operator_twin"] + self.assertFalse(twin["overlay_complete"]) + self.assertEqual(twin["running"], []) + done = next(column for column in twin["phases"] if column["status"] == "done") + self.assertEqual([task["id"] for task in done["tasks"]], ["TASK-A"]) + + first_page = _prefix_page() + self.assertEqual(len(first_page), 50) + result = _run_live({"snapshot": data, "events": first_page}) + self._assert_prefix_does_not_invent_running(result) + self.assertFalse(result["overlay_complete"]) + + def test_workspace_fallback_empty_twin_fail_closes_before_prefix_merge(self) -> None: + self._force_inventory_done() + service = self._service() + with patch.object( + service, + "_workspace_config", + side_effect=ConsoleOverviewError("WORKSPACE_UNAVAILABLE"), + ): + payload = service.workspace(self.config.name) + data = payload["data"] + self.assertEqual(data["workspace"]["availability"], "available") + task = next(item for item in data["tasks"] if item["id"] == "TASK-A") + self.assertEqual(task["status"], "done") + twin = data["operator_twin"] + self.assertFalse(twin["overlay_complete"]) + self.assertEqual(twin["projected_seq"], 0) + self.assertEqual(twin["running"], []) + self.assertEqual(twin["plan"], []) + self.assertTrue(all(column["tasks"] == [] for column in twin["phases"])) + + first_page = _prefix_page() + self.assertEqual(len(first_page), 50) + result = _run_live({"snapshot": data, "events": first_page}) + self.assertEqual(result["running"], []) + self.assertFalse(result["board_landed"]) + self.assertNotIn("会审已落下", result["rendered"]) + self.assertFalse(result["overlay_complete"]) + + +if __name__ == "__main__": + unittest.main()