diff --git a/AGENTS.md b/AGENTS.md index af1f44da..0ffb7897 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ V2 development is paused; V1 is the active product line and is not feature-froze - When two designs satisfy the current need, choose the one with fewer concepts, states, configuration paths, and maintenance costs. - For model-facing execution, prefer structured process/argv and durable Job/observation primitives over shell-text orchestration. Keep shell as an escape hatch; structured lifecycle state is the source of truth for retry safety. - Treat demonstrated host features such as MCP App orchestration as optional adapters. Core execution and Job semantics must remain protocol-, UI-, transport-, and OS-neutral. +- Never assume a model-facing HTTP/MCP request has stable model-window or Workflow Session identity. Treat requests as stateless unless that exact adapter/protocol contract explicitly supplies a stable `ClientWindow`. Stateless MCP 2026 must not derive hidden continuity from `Mcp-Session-Id`, connection state, credentials, project identity, or prior requests. Transport/audit/session correlation ids are not Workflow Session, model-context-retention, or authority proofs by themselves; use explicit durable task/session ids and recorder metadata only under their own contracts. Product direction: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). diff --git a/crates/webcodex-core/src/shell_protocol.rs b/crates/webcodex-core/src/shell_protocol.rs index d485c035..2f304ede 100644 --- a/crates/webcodex-core/src/shell_protocol.rs +++ b/crates/webcodex-core/src/shell_protocol.rs @@ -2154,6 +2154,8 @@ pub struct ShellJobValidationMetadata { pub effective_timeout_secs: u64, pub sync_wait_secs: u64, pub adapter: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub validation_target_id: Option, } impl ShellJobValidationMetadata { @@ -2163,6 +2165,12 @@ impl ShellJobValidationMetadata { || !self.steps[0].is_canonical() || self.effective_timeout_secs < 1 || self.sync_wait_secs > self.effective_timeout_secs + || self.validation_target_id.as_deref().is_some_and(|value| { + let Some(suffix) = value.strip_prefix("target:") else { + return true; + }; + suffix.len() != 24 || !suffix.as_bytes().iter().all(u8::is_ascii_hexdigit) + }) { return false; } @@ -4344,6 +4352,7 @@ mod filter_canonical_tests { effective_timeout_secs: 1800, sync_wait_secs: 10, adapter: tool.to_string(), + validation_target_id: None, } } diff --git a/crates/webcodex-runner/src/main_tests.rs b/crates/webcodex-runner/src/main_tests.rs index b53e3fbe..d3564352 100644 --- a/crates/webcodex-runner/src/main_tests.rs +++ b/crates/webcodex-runner/src/main_tests.rs @@ -481,6 +481,7 @@ fn runner_recovery_context_rejects_cross_product_go_test_metadata() { effective_timeout_secs: 1800, sync_wait_secs: 10, adapter: "go_test".to_string(), + validation_target_id: None, }); let context = context.clone(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9bbc16eb..287da0ec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,9 +65,11 @@ is the id registered by that Runner in its `projects.d` registry. ## Task / Job / session continuity - **Task** — a bounded unit of project work created by the model and reviewed - by a human. A project-bound Connector binds a chat window to its active task, - so follow-up instructions continue the same repository context. Tasks are - durable and can be resumed. + by a human. Tasks are durable and can be resumed. A project-bound Connector + may bind an active task only when its exact adapter/protocol supplies a stable + `ClientWindow`. Stateless MCP 2026 has no hidden window continuity: each + `task_start` is independent and existing work continues explicitly with its + durable `task_id` through `task_resume`. - **Job** — a long-running command or validation that continues after the initiating call returns. A single execution is promoted to a Job with the same `job_id` when it outlives the synchronous grace period; it is never @@ -117,11 +119,13 @@ See [SECURITY.md](../SECURITY.md) and [AUTH_MODEL.md](AUTH_MODEL.md). ## Persistence and recovery The Server persists users, tokens, projects, audit entries, and OAuth rows in a -SQLite database. Task history and per-repository window mappings are durable. -Process-local "currently viewed project" state is deliberately discarded on -restart; a client that retains its transport window identity restores the -matching repository on its next `task_start`, and an explicit durable task id -recovers it otherwise. +SQLite database. Task history is durable. Per-repository window mappings exist +only for adapters that explicitly provide a stable `ClientWindow`; they are not +inferred from credentials, connections, or project identity. Process-local +"currently viewed project" state is deliberately discarded on restart. +Stateless MCP 2026 restores no hidden window mapping: callers continue exact work +with an explicit durable task id. A stateful adapter may restore only its exact +window/repository mapping under that adapter's own contract. Runner Job state is reconciled from the Runner's inventory on reconnect. Ordinary Jobs remain owned by the Runner process, so a Runner process restart diff --git a/docs/MCP.md b/docs/MCP.md index 237794e2..b80535f5 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -204,10 +204,15 @@ code_impact The Connector context already binds the configured repository. Start with `task_start`; do not call project-discovery, session, or runtime tools, and do -not put a runtime project id in the prompt. The same chat window continues the -current repository automatically. `task_list` and `task_resume` are explicit -recovery tools when a client can no longer present its transport window -identity. +not put a runtime project id in the prompt. On Stateless MCP 2026, each +`tools/call` is application-stateless with respect to chat/window continuity: +`task_start` returns a durable `task_id`, and a later `task_start` begins +independent work even if the client sends a legacy `Mcp-Session-Id`. Continue +exact existing work explicitly with `task_resume(task_id)`; use `task_list` to +recover a task identity when needed. Do not infer continuity from the same chat, +connection, credential, project, or transport header. Older stateful adapter +contracts may expose a stable `ClientWindow`, but that is not a general MCP +property and is not Workflow Session or model-context identity. ## Golden coding loop diff --git a/docs/MCP.zh-CN.md b/docs/MCP.zh-CN.md index a0836ab1..588fe4ff 100644 --- a/docs/MCP.zh-CN.md +++ b/docs/MCP.zh-CN.md @@ -182,9 +182,14 @@ code_impact ``` Connector context 已绑定配置的仓库。用 `task_start` 开始;不要调用项目发现、 -session 或 runtime 工具,也不要在 prompt 里放 runtime project id。同一个聊天窗口 -会自动延续当前仓库的工作。`task_list` 与 `task_resume` 是客户端无法再提供传输 -窗口身份时的显式恢复工具。 +session 或 runtime 工具,也不要在 prompt 里放 runtime project id。在 Stateless +MCP 2026 中,每次 `tools/call` 对聊天/窗口连续性而言都是应用层无状态请求: +`task_start` 会返回 durable `task_id`,后续再次 `task_start` 会开始独立工作,即使 +客户端仍发送旧的 `Mcp-Session-Id` 也不能形成隐藏连续性。要继续现有工作,必须显式 +调用 `task_resume(task_id)`;需要恢复 task identity 时可使用 `task_list`。不要从同一 +聊天、连接、credential、project 或 transport header 推断连续性。旧的 stateful +adapter 契约可以显式提供 stable `ClientWindow`,但这不是 MCP 的普遍属性,也不是 +Workflow Session 或 model-context identity。 ## 黄金 coding 循环 diff --git a/docs/agent/manual-window-collaboration.md b/docs/agent/manual-window-collaboration.md index 1562434b..4b1f15d2 100644 --- a/docs/agent/manual-window-collaboration.md +++ b/docs/agent/manual-window-collaboration.md @@ -81,6 +81,12 @@ Retention is explicit. Each retained message carries internal latest-revision bo Message observation is **not** a delivery receipt, **not** proof of model-context retention, **not** a subscription/stream, and **not** an orchestrator wake-up. It never automatically wakes a model or spawns/routes work. Room/Discussion remains only a future additive direction; this Workflow Session primitive does not create Room, participant, presence, typing, scheduler, worker-pool, or routing state. +## Runtime Collaboration Console + +The Server-hosted `/runtime` page presents the same authoritative runtime and Workflow Session state without creating a second Session store or collaboration truth. It keeps a bounded Server overview, a focused per-Runner machine view, one compact/searchable Project selector, compact Workflow Session activity, and retained collaboration messages. The narrow Human Join composer is the only collaboration mutation affordance: it posts bounded Session messages through the canonical kernel path described below. Existing Project and Workflow Session console reads retain their `project:read` boundary; Server-wide/Runner-wide facts and full collaboration message/observation/post routes require `runtime:read` and still re-authorize the exact target Session/project. + +The collaboration panel establishes an observation baseline before reading the retained snapshot, then uses bounded long-polls and merges deltas by `message_id`. `has_more` is drained before the next wait, while `history_lost` causes a retained-board reload and a new baseline rather than claiming complete history. Manual Refresh reports visible refreshing/success/failure state and preserves prior usable data; a healthy live collaboration loop is not restarted merely because Refresh was clicked, while a paused/failed loop performs a retained reload, new baseline, and bounded reconnect. Session liveness is derived only from WebCodex facts such as a running call, owned running Job, or recent retained activity; it never claims to know whether the host/model is processing, frozen, or present. All aggregate counts remain bounded/truncation-aware. Browser observation remains UI refresh only: it is not a model wake-up, subscription, participant-presence mechanism, scheduler, worker claim, or execution lease. + ## Provenance is metadata, not authority A completed answer can identify the independent worker with `author_session_id`. That value is derived first from the trusted recording Session that owns the completion tool evidence, then from the trusted current-Session binding only when no recording Session exists. It is not a caller-authored claim. In stateless MCP 2026, `recording_session_id` is explicit wrapper provenance metadata, not a transport Session and not an authority grant; the legacy `mcp-session-id` header remains irrelevant. @@ -119,6 +125,12 @@ Do not treat todo state, `reply_to`, `completion_key`, `author_session_id`, or ` When multiple workers operate on the same source, use normal Git/WebCodex Project isolation and revalidate current state before acting on collaboration messages. +## Human join and acknowledgement ergonomics + +The hosted Runtime Console may post `note`, `guidance`, `question`, and `todo` messages into an exact authorized Workflow Session through the same `post_session_message` kernel path. This is a browser affordance, not a Participant entity, membership record, presence signal, or identity-spoofing surface. The browser route keeps the current collaboration metadata authority policy (`runtime:read`) and still applies the stored Session/project authority fence. + +High-priority Guidance may opt into `requires_ack`. A Stateless MCP 2026 caller can echo the visible message id in `ack_session_message_ids` on an otherwise ordinary recorded tool call. The original tool executes normally whether the ACK is present, missing, unknown, foreign, or stale. A valid ACK suppresses that Guidance body only for the same request/response. If the model later omits the ACK while the Guidance remains open, the Server may piggyback the bounded body again. The first observed ACK timestamp is observability only; it must never be described as delivered, read, or currently remembered. Durable completion still requires normal message resolution. + ## Bounded payload guidance Keep todos and answers small enough to be useful as handoff state. Prefer stable references over copied authoritative objects: diff --git a/docs/agent/session-model.md b/docs/agent/session-model.md index 584ff8d4..81dfceed 100644 --- a/docs/agent/session-model.md +++ b/docs/agent/session-model.md @@ -26,39 +26,40 @@ statement is true for only one kind, name that kind explicitly. ## Project Connector continuity is not a third session type The ordinary project-bound product path uses existing durable Connector Tasks -and task events. A lightweight SQLite map associates the hashed client-window -identity, authenticated subject, exact Connector project, and canonical-root -hash with one current durable task. It does not create another event ledger and -must never be cross-wired to either session system below. - -`task_start` resolves get-or-create/continue context without duplication: - -- no mapping creates a durable Connector Task; -- an active exact mapping appends a `task_instruction` event to that task; -- changing repository activates a separate mapping without closing the first; -- returning to the repository restores its mapping; -- a read-only-to-write transition rechecks project-write authority and upgrades - the same task's execution workspace; -- a terminal task advances that repository mapping to a new task while keeping - old history. - -Raw window identifiers are neither tool arguments nor stored data. MCP uses the -server-minted `Mcp-Session-Id` from initialize; hosted Actions use their -conversation-scoped request header; other HTTP clients use a server-minted -HttpOnly cookie and one cookie jar per logical window. Only a domain-separated -SHA-256 key is stored. Every lookup is also scoped by authenticated subject, -Connector project id, and canonical-root hash. The process-local -current-project navigation map is intentionally separate from the durable -per-repository task mapping. - -Restart recovery has a strict boundary: task history and the durable exact -mapping survive; current navigation does not. A retained MCP header or HTTP -cookie can recover the exact repository. Missing identity never falls back to a -user, credential, project name, or repository path. MCP rejects anonymous -`task_start`; HTTP clients that discard cookies require explicit task recovery. -When `task_resume` has a new stable window identity, it moves the lightweight -binding to that window without copying history or sharing one active task -between two windows. +and task events. Connector continuity is adapter-specific; it is never inferred +merely because two requests come from the same credential, connection, project, +or apparent chat. + +An adapter/protocol that explicitly supplies a stable `ClientWindow` may use a +lightweight SQLite map from the domain-separated hashed window identity, +authenticated subject, exact Connector project, and canonical-root hash to one +current durable task. That mapping does not create another event ledger and must +never be cross-wired to either session system below. On such a stateful adapter, +`task_start` may continue the exact active mapping, repository switches remain +isolated, write upgrades recheck project-write authority, and a terminal task +advances only that exact mapping while preserving history. + +**Stateless MCP 2026 deliberately supplies no stable `ClientWindow`.** Every +`task_start` therefore starts independent durable work; even a caller-supplied +legacy `Mcp-Session-Id` must not create hidden continuity. Existing work is +continued explicitly with its durable `task_id` through `task_resume` (and may be +discovered with `task_list`). This stateless path never falls back to a user, +credential, project identity, connection, or prior request. + +Legacy/stateful MCP and first-party/hosted HTTP adapters may have their own +explicit window sources, such as the older server-minted MCP session header, a +conversation-scoped request header, or a first-party HttpOnly window cookie. +Those are adapter-local `ClientWindow` inputs, not a general property of HTTP or +MCP and never proof of Workflow Session identity, model-context retention, or +authority. Raw window values are not stored; only their domain-separated hash is +used where that adapter contract permits window binding. + +Restart recovery follows the same boundary: durable task history always +survives; only adapters with an explicit stable window may restore an exact +window/repository mapping automatically. Stateless callers recover explicitly by +`task_id`. `task_resume` may rebind only when the current adapter actually +supplies a new stable `ClientWindow`; otherwise the durable task resumes without +manufacturing one. --- @@ -99,6 +100,10 @@ handoff, and finish can reason about the same unit of work. Stateless MCP 2026 does not have a reliable Workflow Session or ChatGPT-window transport identity. Its `tools/list` schema therefore projects `recording_session_id` as explicit wrapper metadata for runtime tools. A call may carry `recording_session_id=W` while the concrete tool body carries business `session_id=C`; the MCP adapter removes the recorder field before concrete parsing and the kernel independently authorizes `W` before it can record evidence or supply trusted collaboration provenance. This does not revive legacy `mcp-session-id`, grant target authority, or infer a recorder from credentials, project identity, or connection state. +Stateless MCP 2026 also projects optional `ack_session_message_ids` wrapper metadata, bounded to eight opaque `wc_msg_*` ids. An ACK is request-scoped evidence that the current model context still remembers an unresolved message in the exact authorized recording Workflow Session. The adapter removes ACK metadata before concrete tool parsing; it never grants authority, resolves a message, or gates the concrete tool effect. In the first version only open high-priority Guidance can require ACK. Accepted ids suppress that Guidance body only in the current response; if a later request omits the id, the unresolved Guidance is eligible for bounded redelivery again. Historical ACK state is never used to infer current model-context retention. + +A required Guidance message may persist `first_ack_observed_at` for observability. Only the first accepted ACK advances message-observation revision; repeated echoes do not create revision churn. This field means only that the Server once observed an explicit ACK echo. It is not a delivery/read receipt and does not change `status=open`. `resolve_session_message` remains the durable processed-state transition; resolved messages no longer participate in hints or urgent redelivery. + ### Message observation state The Session-local message board has a separate durable monotonic **message-observation revision** used by `observe_session_messages`. The public observation token is bounded and opaque; it binds the exact Workflow Session plus durable cursor state without exposing the internal revision as the caller cursor. It is observation state only and grants no authority. Malformed, oversized, wrong-Session, and future-revision tokens fail closed. @@ -487,6 +492,7 @@ a `finish_coding_task` verdict. `insufficient_scope_identity`, `validation_not_requested`). Count deltas are signed integers (a decrease in passed tests yields a negative `passed_delta`); zero-test success never resolves a prior test failure. +- **Async terminal validation evidence:** structured validation Job metadata carries the same opaque `validation_target_id` as the originating validation attempt. When an authorized validation-summary or Runtime Console Session read observes a retained terminal Job, WebCodex idempotently materializes one bounded `validation_job_terminal` event in that exact Workflow Session before projecting validation state. Idempotence does not depend on that event remaining in the 200-event Session FIFO: the version-1 ledger also persists a serde-defaulted exact Job-id marker set bounded to the Runner authoritative terminal inventory limit (64), and a new materialization evicts only markers absent from the current terminal-candidate snapshot. The marker check, marker insertion, and event append commit under one Session-store mutation, so concurrent reconcilers append at most once and restart restoration keeps the same suppression identity. Terminal reconciliation also serializes authoritative candidate-snapshot acquisition through marker/event materialization within one runtime: a later snapshot cannot commit first, so an older snapshot never gains eviction authority over a marker established from newer inventory. Synthetic evidence uses the authoritative Job `finished_at`; reconciliation never advances Session activity to the wall-clock read time. This is recovery/materialization only: it never re-runs validation, never treats acceptance/handoff as terminal success, and never exposes raw Job output. A later terminal success for the same structured target can therefore resolve an older retained failure even after the acceptance event is gone; the materialized terminal evidence then follows normal Session persistence/retention across Server restart. - **Opaque scope identity:** `comparison.scope_identity` is a domain-separated, opaque stable identity (`validation_scope:v1:`) over the normalized *structured* scope. It never returns a raw command, absolute path, or test diff --git a/frontend/dist/app.js b/frontend/dist/app.js index 9f0f51ac..e9d332a8 100644 --- a/frontend/dist/app.js +++ b/frontend/dist/app.js @@ -395,6 +395,46 @@ function workflowSessionOverviewPresentation(overview) { progressAt: progress && typeof progress.reported_at === "number" ? progress.reported_at : null, }; } +function hasPendingAttention(overview) { + const attention = overview && typeof overview === "object" ? overview.attention : null; + return ["open_guidance", "open_questions", "open_risks", "open_todos"] + .some((key) => overviewCount(attention && attention[key]) > 0); +} +function idleAgeLabel(ageSeconds) { + if (ageSeconds < 60) + return "<1m"; + const minutes = Math.floor(ageSeconds / 60); + if (minutes < 60) + return minutes + "m"; + const hours = Math.floor(minutes / 60); + if (hours < 24) + return hours + "h"; + return Math.floor(hours / 24) + "d"; +} +function workflowSessionLivenessPresentation(session, nowSeconds = Date.now() / 1000) { + const runningCall = !!session?.running_call; + const runningJobs = typeof session?.running_jobs === "number" ? Math.max(0, session.running_jobs) : 0; + const tooltip = "WebCodex activity only; host/model state is unknown."; + if (runningCall || runningJobs > 0) { + return { state: "working", label: "working", tooltip }; + } + const updatedAt = typeof session?.updated_at === "number" ? session.updated_at : 0; + const ageSeconds = updatedAt > 0 ? Math.max(0, nowSeconds - updatedAt) : Number.POSITIVE_INFINITY; + if (ageSeconds <= 120) { + return { state: "recent", label: "recently active", tooltip }; + } + if (hasPendingAttention(session?.overview)) { + return { state: "attention", label: "idle · pending attention", tooltip }; + } + return { + state: "idle", + label: Number.isFinite(ageSeconds) ? "idle · " + idleAgeLabel(ageSeconds) : "idle", + tooltip, + }; +} +function workflowSessionIdleAttentionLabel(runningCall, overview) { + return workflowSessionLivenessPresentation({ running_call: runningCall, overview, updated_at: 0 }, 0).label; +} function initialWorkflowSessionState() { return { selectedSessionId: "", diff --git a/frontend/dist/runtime.css b/frontend/dist/runtime.css index e4511cf1..95cf1caf 100644 --- a/frontend/dist/runtime.css +++ b/frontend/dist/runtime.css @@ -1 +1 @@ -:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light dark;--bg:#f5f6f8;--panel:#ffffff;--text:#18202a;--muted:#68717d;--border:#d7dce2;--accent:#3467d6;--pass:#277a47;--warn:#9a6500;--fail:#b23333}@media (prefers-color-scheme:dark){:root{--bg:#15181d;--panel:#1e232a;--text:#e8ebef;--muted:#a1aab5;--border:#39414b;--accent:#7ca4ff;--pass:#65bd83;--warn:#e3b052;--fail:#ed7777}}*{box-sizing:border-box}[hidden]{display:none !important}body{margin:0;background:var(--bg);color:var(--text)}button,input,select{font:inherit}#runtime-page{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:24px 0 40px}.topbar{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:18px}h1,h2,h3,h4,p{margin-top:0}h1{margin-bottom:3px;font-size:1.35rem}h2{font-size:1rem}h4{margin-bottom:5px;color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.05em}.muted{color:var(--muted)}.small{font-size:.8rem}.topbar-controls,.field-row,.chips,.summary-facts,.timeline-controls{display:flex;gap:7px;align-items:center}.btn{border:1px solid var(--border);border-radius:6px;background:var(--panel);color:var(--text);padding:6px 10px;cursor:pointer}.btn.primary{border-color:var(--accent);background:var(--accent);color:white}.panel,.gate{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:14px;margin-bottom:14px}.gate{max-width:620px}.field-row input{flex:1;min-width:0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--bg);color:var(--text)}.error{color:var(--fail)}.banner{border-left:3px solid var(--fail);padding:8px 10px;background:var(--panel)}.project-panel{display:grid;grid-template-columns:auto minmax(260px,1fr) auto;gap:8px 12px;align-items:center}.project-panel label{font-weight:700}.project-panel select{min-width:0;border:1px solid var(--border);border-radius:6px;padding:7px 9px;background:var(--bg);color:var(--text)}.project-id{grid-column:2 / -1;overflow-wrap:anywhere;color:var(--muted)}.panel-head{display:flex;justify-content:space-between;align-items:center}.session-grid{display:grid;grid-template-columns:minmax(260px,340px) 1fr;gap:14px;align-items:start}.session-list{display:grid;gap:7px;list-style:none;margin:0;padding:0;max-height:680px;overflow:auto}.session-card{border:1px solid var(--border);border-radius:7px;padding:9px;cursor:pointer;background:var(--bg)}.session-card.selected{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}.session-title{font-weight:650;overflow-wrap:anywhere;margin-bottom:5px}.chips,.summary-facts{flex-wrap:wrap}.chip{display:inline-block;border:1px solid var(--border);border-radius:999px;padding:2px 7px;font-size:.7rem;color:var(--muted)}.tone-runtime{color:var(--accent);border-color:var(--accent)}.tone-pass{color:var(--pass);border-color:var(--pass)}.tone-warn{color:var(--warn);border-color:var(--warn)}.tone-fail{color:var(--fail);border-color:var(--fail)}.summary-facts{margin-top:6px}.activity-preview{display:flex;gap:6px;margin-top:5px;overflow-wrap:anywhere}.activity-preview-label{flex:0 0 auto;font-weight:700}.detail-column{min-width:0}.detail-title{margin-bottom:8px;overflow-wrap:anywhere}.overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0}.overview-card{min-width:0;border-left:3px solid var(--border);background:var(--bg);padding:9px 10px}.overview-card p{margin-bottom:0;overflow-wrap:anywhere}.runtime-fact{border-left-color:var(--accent)}.reported-card{border-left-color:var(--muted);opacity:.84}.tone-card-pass{border-left-color:var(--pass)}.tone-card-warn{border-left-color:var(--warn)}.tone-card-fail{border-left-color:var(--fail)}.tone-card-muted{border-left-color:var(--muted)}.timeline-controls{justify-content:space-between;margin:10px 0 6px}.timeline{display:grid;gap:5px;list-style:none;margin:0;padding:0;max-height:360px;overflow:auto}.timeline-event{border-left:3px solid var(--border);background:var(--bg);padding:6px 8px}.timeline-event.failed{border-left-color:var(--fail)}.timeline-event.reported-progress{border-left-color:var(--muted);opacity:.82}.timeline-head{display:flex;justify-content:space-between;gap:10px;align-items:baseline}.timeline-kind{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.8rem}.timeline-body{margin-top:4px;overflow-wrap:anywhere}@media (max-width:760px){#runtime-page{width:min(100% - 20px,1180px);padding-top:14px}.topbar{align-items:flex-start}.session-grid,.overview{grid-template-columns:1fr}.project-panel{grid-template-columns:1fr}.project-id{grid-column:auto}} +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light dark;--bg:#f5f6f8;--panel:#ffffff;--text:#18202a;--muted:#68717d;--border:#d7dce2;--accent:#3467d6;--pass:#277a47;--warn:#9a6500;--fail:#b23333}@media (prefers-color-scheme:dark){:root{--bg:#15181d;--panel:#1e232a;--text:#e8ebef;--muted:#a1aab5;--border:#39414b;--accent:#7ca4ff;--pass:#65bd83;--warn:#e3b052;--fail:#ed7777}}*{box-sizing:border-box}[hidden]{display:none !important}body{margin:0;background:var(--bg);color:var(--text)}button,input,select,textarea{font:inherit}#runtime-page{width:min(1240px,calc(100% - 32px));margin:0 auto;padding:24px 0 40px}.topbar{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:18px}h1,h2,h3,h4,p{margin-top:0}h1{margin-bottom:3px;font-size:1.35rem}h2{font-size:1rem}h4{margin-bottom:5px;color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.05em}.muted{color:var(--muted)}.small{font-size:.8rem}.topbar-controls,.field-row,.chips,.summary-facts,.timeline-controls,.section-head,.runner-head{display:flex;gap:7px;align-items:center}.btn{border:1px solid var(--border);border-radius:6px;background:var(--panel);color:var(--text);padding:6px 10px;cursor:pointer}.btn:disabled{cursor:wait;opacity:.62}.btn.primary{border-color:var(--accent);background:var(--accent);color:white}.panel,.gate{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:14px;margin-bottom:14px}.gate{max-width:680px}.field-row input{flex:1;min-width:0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--bg);color:var(--text)}.error{color:var(--fail)}.banner{border-left:3px solid var(--fail);padding:8px 10px;background:var(--panel)}.panel-head,.section-head{display:flex;justify-content:space-between;align-items:center}.metric-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.metric{min-width:0;background:var(--bg);border-left:3px solid var(--border);padding:9px 10px;display:grid;gap:3px}.metric strong,.metric span{overflow-wrap:anywhere}.metric-label{color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.04em}.runner-head{margin-bottom:10px}.runner-head label{font-weight:700}.runner-head select,.project-search-row input,.collaboration-composer select,.collaboration-composer textarea{min-width:0;border:1px solid var(--border);border-radius:6px;padding:7px 9px;background:var(--bg);color:var(--text)}.runner-head select{min-width:220px}.project-search-row{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);gap:8px;align-items:center;margin-bottom:8px}.project-id{overflow-wrap:anywhere;color:var(--muted);text-align:right}.project-list{display:grid;gap:5px;max-height:360px;overflow:auto}.project-row{display:grid;grid-template-columns:minmax(180px,1.4fr) minmax(0,1fr);gap:8px;align-items:center;border:1px solid var(--border);border-radius:6px;padding:7px 9px;background:var(--bg);cursor:pointer;min-width:0}.project-row.selected{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}.project-row-main,.project-row-facts{min-width:0}.project-row-title{font-weight:650;overflow-wrap:anywhere}.project-row-id{color:var(--muted);font-size:.74rem;overflow-wrap:anywhere}.project-row-facts{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.session-grid{display:grid;grid-template-columns:minmax(260px,340px) 1fr;gap:14px;align-items:start}.session-list{display:grid;gap:7px;list-style:none;margin:0;padding:0;max-height:760px;overflow:auto}.session-card{border:1px solid var(--border);border-radius:7px;padding:9px;cursor:pointer;background:var(--bg)}.session-card.selected{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}.session-title{font-weight:650;overflow-wrap:anywhere;margin-bottom:5px;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.chips,.summary-facts{flex-wrap:wrap}.chip{display:inline-block;border:1px solid var(--border);border-radius:999px;padding:2px 7px;font-size:.7rem;color:var(--muted)}.tone-runtime{color:var(--accent);border-color:var(--accent)}.tone-pass{color:var(--pass);border-color:var(--pass)}.tone-warn{color:var(--warn);border-color:var(--warn)}.tone-fail{color:var(--fail);border-color:var(--fail)}.summary-facts{margin-top:6px}.activity-preview{display:flex;gap:6px;margin-top:5px;overflow-wrap:anywhere}.activity-preview-label{flex:0 0 auto;font-weight:700}.detail-column{min-width:0}.detail-title{margin-bottom:8px;overflow-wrap:anywhere}.overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0}.overview-card{min-width:0;border-left:3px solid var(--border);background:var(--bg);padding:9px 10px}.overview-card p{margin-bottom:0;overflow-wrap:anywhere}.runtime-fact{border-left-color:var(--accent)}.reported-card{border-left-color:var(--muted);opacity:.84}.tone-card-pass{border-left-color:var(--pass)}.tone-card-warn{border-left-color:var(--warn)}.tone-card-fail{border-left-color:var(--fail)}.tone-card-muted{border-left-color:var(--muted)}.detail-section{border-top:1px solid var(--border);padding-top:12px;margin-top:12px}.collaboration-board{display:grid;gap:7px;max-height:440px;overflow:auto;margin-top:7px}.message-card{background:var(--bg);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:6px;padding:8px;min-width:0}.message-card.resolved{border-left-color:var(--pass)}.message-card.risk{border-left-color:var(--fail)}.message-card.question,.message-card.todo,.message-card.guidance{border-left-color:var(--warn)}.message-card.retained-reply{border-left-style:dashed}.message-head{display:flex;justify-content:space-between;gap:8px;align-items:baseline}.message-kind{font-weight:700}.message-meta,.message-links,.message-resolution{color:var(--muted);font-size:.76rem;overflow-wrap:anywhere}.message-body{margin-top:6px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:14rem;overflow:auto}.message-ack{margin-top:5px;color:var(--warn);font-size:.76rem}.message-actions{display:flex;justify-content:flex-end;margin-top:5px}.text-button{appearance:none;border:0;background:transparent;color:var(--accent);padding:2px 4px;cursor:pointer;font:inherit}.collaboration-composer{border:1px solid var(--border);border-radius:7px;padding:8px;margin-top:7px;background:var(--bg)}.composer-fields{display:flex;gap:8px;align-items:end;flex-wrap:wrap}.composer-fields label{display:grid;gap:3px;color:var(--muted);font-size:.76rem}.composer-fields .ack-field{display:flex;align-items:center;gap:5px;padding-bottom:6px}.collaboration-composer textarea{width:100%;resize:vertical;margin-top:7px}.composer-actions{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:6px}.reply-target{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-top:6px}.message-thread{margin-left:min(28px,5vw)}.retention-note{margin:8px 0 0}.timeline-controls{justify-content:space-between;margin:0 0 6px}.timeline{display:grid;gap:5px;list-style:none;margin:0;padding:0;max-height:360px;overflow:auto}.timeline-event{border-left:3px solid var(--border);background:var(--bg);padding:6px 8px}.timeline-event.failed{border-left-color:var(--fail)}.timeline-event.reported-progress{border-left-color:var(--muted);opacity:.82}.timeline-head{display:flex;justify-content:space-between;gap:10px;align-items:baseline}.timeline-kind{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.8rem}.timeline-body{margin-top:4px;overflow-wrap:anywhere}@media (max-width:900px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:760px){#runtime-page{width:min(100% - 20px,1240px);padding-top:14px}.topbar{align-items:flex-start}.session-grid,.overview,.metric-grid{grid-template-columns:1fr}.project-search-row,.project-row{grid-template-columns:1fr}.project-id{text-align:left}.project-row-facts{justify-content:flex-start}.runner-head{align-items:stretch;flex-direction:column}.runner-head select{width:100%;min-width:0}} diff --git a/frontend/dist/runtime.html b/frontend/dist/runtime.html index 617d0f06..26be3d92 100644 --- a/frontend/dist/runtime.html +++ b/frontend/dist/runtime.html @@ -12,9 +12,10 @@

WebCodex Runtime Console

-

Hosted Workflow Session observability

+

Server, Runner, Project, Workflow Session, and collaboration observability

@@ -23,7 +24,7 @@

WebCodex Runtime Console

Connect to Runtime

-

Enter an existing runtime Bearer credential with project read access. It stays in page memory only.

+

Enter an existing runtime Bearer credential with project read access. Runtime-wide and collaboration views additionally require runtime read access. The credential stays in page memory only.

@@ -34,14 +35,48 @@

Connect to Runtime

diff --git a/frontend/dist/runtime.js b/frontend/dist/runtime.js index cb5e9a01..7a544720 100644 --- a/frontend/dist/runtime.js +++ b/frontend/dist/runtime.js @@ -140,6 +140,46 @@ function workflowSessionOverviewPresentation(overview) { progressAt: progress && typeof progress.reported_at === "number" ? progress.reported_at : null, }; } +function hasPendingAttention(overview) { + const attention = overview && typeof overview === "object" ? overview.attention : null; + return ["open_guidance", "open_questions", "open_risks", "open_todos"] + .some((key) => overviewCount(attention && attention[key]) > 0); +} +function idleAgeLabel(ageSeconds) { + if (ageSeconds < 60) + return "<1m"; + const minutes = Math.floor(ageSeconds / 60); + if (minutes < 60) + return minutes + "m"; + const hours = Math.floor(minutes / 60); + if (hours < 24) + return hours + "h"; + return Math.floor(hours / 24) + "d"; +} +function workflowSessionLivenessPresentation(session, nowSeconds = Date.now() / 1000) { + const runningCall = !!session?.running_call; + const runningJobs = typeof session?.running_jobs === "number" ? Math.max(0, session.running_jobs) : 0; + const tooltip = "WebCodex activity only; host/model state is unknown."; + if (runningCall || runningJobs > 0) { + return { state: "working", label: "working", tooltip }; + } + const updatedAt = typeof session?.updated_at === "number" ? session.updated_at : 0; + const ageSeconds = updatedAt > 0 ? Math.max(0, nowSeconds - updatedAt) : Number.POSITIVE_INFINITY; + if (ageSeconds <= 120) { + return { state: "recent", label: "recently active", tooltip }; + } + if (hasPendingAttention(session?.overview)) { + return { state: "attention", label: "idle · pending attention", tooltip }; + } + return { + state: "idle", + label: Number.isFinite(ageSeconds) ? "idle · " + idleAgeLabel(ageSeconds) : "idle", + tooltip, + }; +} +function workflowSessionIdleAttentionLabel(runningCall, overview) { + return workflowSessionLivenessPresentation({ running_call: runningCall, overview, updated_at: 0 }, 0).label; +} function initialWorkflowSessionState() { return { selectedSessionId: "", @@ -210,6 +250,41 @@ function shouldFollowWorkflowSessionLatest(state) { function compareText(left, right) { return left < right ? -1 : left > right ? 1 : 0; } +function emptyCollaborationState() { + return { + generation: 0, + sessionId: "", + messages: [], + observationToken: "", + available: true, + phase: "idle", + }; +} +function messageCreatedAt(message) { + return typeof message?.created_at === "number" ? message.created_at : 0; +} +function mergeRuntimeCollaborationMessages(current, updates) { + const byId = new Map(); + for (const message of Array.isArray(current) ? current : []) { + const id = typeof message?.message_id === "string" ? message.message_id : ""; + if (id) + byId.set(id, message); + } + for (const message of Array.isArray(updates) ? updates : []) { + const id = typeof message?.message_id === "string" ? message.message_id : ""; + if (id) + byId.set(id, message); + } + return Array.from(byId.values()).sort((left, right) => messageCreatedAt(left) - messageCreatedAt(right) || + compareText(String(left?.message_id || ""), String(right?.message_id || ""))); +} +function runtimeCollaborationObservationAction(payload) { + if (payload?.history_lost) + return "reload"; + if (payload?.has_more) + return "drain"; + return "wait"; +} function runtimeDeviceIds(projects) { const devices = new Set(); for (const project of Array.isArray(projects) ? projects : []) { @@ -229,6 +304,39 @@ function runtimeProjectsForDevice(projects, clientId) { return compareText(leftName, rightName) || compareText(left.id, right.id); }); } +function projectAttentionCount(project) { + const attention = project?.sessions?.attention; + return ["open_guidance", "open_questions", "open_risks", "open_todos"] + .reduce((total, key) => total + (typeof attention?.[key] === "number" ? Math.max(0, attention[key]) : 0), 0); +} +function filterAndSortRuntimeProjects(projects, clientId, query) { + const needle = String(query || "").trim().toLocaleLowerCase(); + return runtimeProjectsForDevice(projects, clientId) + .filter((project) => { + if (!needle) + return true; + return [project?.name, project?.id] + .filter((value) => typeof value === "string") + .some((value) => String(value).toLocaleLowerCase().includes(needle)); + }) + .sort((left, right) => { + const leftRunning = typeof left?.sessions?.running_sessions === "number" ? left.sessions.running_sessions : 0; + const rightRunning = typeof right?.sessions?.running_sessions === "number" ? right.sessions.running_sessions : 0; + if (!!rightRunning !== !!leftRunning) + return rightRunning ? 1 : -1; + const leftAttention = projectAttentionCount(left); + const rightAttention = projectAttentionCount(right); + if (!!rightAttention !== !!leftAttention) + return rightAttention ? 1 : -1; + const leftUpdated = typeof left?.sessions?.latest_updated_at === "number" ? left.sessions.latest_updated_at : 0; + const rightUpdated = typeof right?.sessions?.latest_updated_at === "number" ? right.sessions.latest_updated_at : 0; + if (leftUpdated !== rightUpdated) + return rightUpdated - leftUpdated; + const leftName = typeof left?.name === "string" && left.name ? left.name : left.id; + const rightName = typeof right?.name === "string" && right.name ? right.name : right.id; + return compareText(String(leftName || ""), String(rightName || "")) || compareText(String(left?.id || ""), String(right?.id || "")); + }); +} function preferredRuntimeProjectSelection(projects, selectedDevice, selectedProject) { const rows = Array.isArray(projects) ? projects : []; if (selectedProject) { @@ -244,27 +352,45 @@ function preferredRuntimeProjectSelection(projects, selectedDevice, selectedProj function initialRuntimeConsoleState() { return { credentialGeneration: 0, + overviewGeneration: 0, projectsGeneration: 0, + runnerGeneration: 0, selectedDevice: "", selectedProject: "", projectGeneration: 0, sessionListGeneration: 0, workflow: initialWorkflowSessionState(), + collaboration: emptyCollaborationState(), }; } function invalidateRuntimeCredential(state) { state.credentialGeneration += 1; + state.overviewGeneration += 1; state.projectsGeneration += 1; + state.runnerGeneration += 1; state.selectedDevice = ""; state.selectedProject = ""; state.projectGeneration += 1; state.sessionListGeneration += 1; clearWorkflowSessionSelection(state.workflow); + state.collaboration.generation += 1; + state.collaboration.sessionId = ""; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; + state.collaboration.available = true; + state.collaboration.phase = "idle"; } function beginRuntimeCredential(state) { invalidateRuntimeCredential(state); return refreshRuntimeProjects(state); } +function refreshRuntimeOverview(state) { + state.overviewGeneration += 1; + return { credentialGeneration: state.credentialGeneration, generation: state.overviewGeneration }; +} +function isCurrentRuntimeOverviewRequest(state, request) { + return !!request && request.credentialGeneration === state.credentialGeneration && request.generation === state.overviewGeneration; +} function refreshRuntimeProjects(state) { state.projectsGeneration += 1; return { @@ -279,18 +405,35 @@ function isCurrentRuntimeProjectsRequest(state, request) { request.projectGeneration === state.projectGeneration && request.generation === state.projectsGeneration; } +function refreshRuntimeRunner(state) { + if (!state.selectedDevice) + return null; + state.runnerGeneration += 1; + return { credentialGeneration: state.credentialGeneration, device: state.selectedDevice, generation: state.runnerGeneration }; +} +function isCurrentRuntimeRunnerRequest(state, request) { + return !!request && request.credentialGeneration === state.credentialGeneration && + request.device === state.selectedDevice && request.generation === state.runnerGeneration; +} function selectRuntimeProject(state, device, project) { + if (state.selectedDevice !== device) + state.runnerGeneration += 1; state.selectedDevice = device; state.selectedProject = project; state.projectGeneration += 1; state.sessionListGeneration += 1; clearWorkflowSessionSelection(state.workflow); + state.collaboration.generation += 1; + state.collaboration.sessionId = ""; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; + state.collaboration.available = true; + state.collaboration.phase = "idle"; return refreshRuntimeSessionList(state); } function refreshRuntimeSessionList(state) { - if (!state.selectedProject) { + if (!state.selectedProject) return null; - } state.sessionListGeneration += 1; return { credentialGeneration: state.credentialGeneration, @@ -300,16 +443,13 @@ function refreshRuntimeSessionList(state) { }; } function isCurrentRuntimeSessionListRequest(state, request) { - return !!request && - request.credentialGeneration === state.credentialGeneration && - request.project === state.selectedProject && - request.projectGeneration === state.projectGeneration && + return !!request && request.credentialGeneration === state.credentialGeneration && + request.project === state.selectedProject && request.projectGeneration === state.projectGeneration && request.generation === state.sessionListGeneration; } function wrapWorkflowRequest(state, request) { - if (!request || !state.selectedProject) { + if (!request || !state.selectedProject) return null; - } return { credentialGeneration: state.credentialGeneration, project: state.selectedProject, @@ -319,6 +459,12 @@ function wrapWorkflowRequest(state, request) { }; } function selectRuntimeWorkflowSession(state, sessionId) { + state.collaboration.generation += 1; + state.collaboration.sessionId = sessionId; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; + state.collaboration.available = true; + state.collaboration.phase = "idle"; return wrapWorkflowRequest(state, selectWorkflowSession(state.workflow, sessionId)); } function refreshRuntimeWorkflowSession(state) { @@ -326,32 +472,83 @@ function refreshRuntimeWorkflowSession(state) { } function clearRuntimeWorkflowSession(state) { clearWorkflowSessionSelection(state.workflow); + state.collaboration.generation += 1; + state.collaboration.sessionId = ""; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; +} +function runtimeCollaborationRequest(state) { + if (!state.selectedProject || !state.collaboration.sessionId) + return null; + return { + credentialGeneration: state.credentialGeneration, + project: state.selectedProject, + projectGeneration: state.projectGeneration, + sessionId: state.collaboration.sessionId, + generation: state.collaboration.generation, + }; +} +function isCurrentRuntimeCollaborationRequest(state, request) { + return !!request && request.credentialGeneration === state.credentialGeneration && + request.project === state.selectedProject && request.projectGeneration === state.projectGeneration && + request.sessionId === state.collaboration.sessionId && request.generation === state.collaboration.generation; +} +function adoptRuntimeCollaborationList(state, request, messages) { + if (!isCurrentRuntimeCollaborationRequest(state, request)) + return false; + state.collaboration.messages = mergeRuntimeCollaborationMessages([], messages); + return true; +} +function adoptRuntimeCollaborationObservation(state, request, payload) { + if (!isCurrentRuntimeCollaborationRequest(state, request)) + return false; + state.collaboration.messages = mergeRuntimeCollaborationMessages(state.collaboration.messages, Array.isArray(payload?.messages) ? payload.messages : []); + if (typeof payload?.observation_token === "string") + state.collaboration.observationToken = payload.observation_token; + return true; +} +function setRuntimeCollaborationAvailable(state, request, available) { + if (!isCurrentRuntimeCollaborationRequest(state, request)) + return false; + state.collaboration.available = available; + return true; +} +function setRuntimeCollaborationPhase(state, request, phase) { + if (!isCurrentRuntimeCollaborationRequest(state, request)) + return false; + state.collaboration.phase = phase; + return true; +} +function runtimeCollaborationNeedsRefreshRecovery(state) { + return state?.collaboration?.phase === "paused"; } function isCurrentRuntimeWorkflowSessionRequest(state, request) { - return !!request && - request.credentialGeneration === state.credentialGeneration && - request.project === state.selectedProject && - request.projectGeneration === state.projectGeneration && - isCurrentWorkflowSessionDetailRequest(state.workflow, { - sessionId: request.sessionId, - generation: request.generation, - }); + return !!request && request.credentialGeneration === state.credentialGeneration && + request.project === state.selectedProject && request.projectGeneration === state.projectGeneration && + isCurrentWorkflowSessionDetailRequest(state.workflow, { sessionId: request.sessionId, generation: request.generation }); } function adoptRuntimeWorkflowSessionDetail(state, request, detail) { - if (!isCurrentRuntimeWorkflowSessionRequest(state, request)) { + if (!isCurrentRuntimeWorkflowSessionRequest(state, request)) return false; - } return adoptWorkflowSessionDetail(state.workflow, { sessionId: request.sessionId, generation: request.generation }, detail); } const API_BASE = "/api/runtime-console/"; const REFRESH_MS = 8000; +const COLLABORATION_WAIT_SECS = 25; let token = ""; let timer = 0; +let overviewAbort = null; let projectsAbort = null; +let runnerAbort = null; let sessionsAbort = null; let detailAbort = null; +let collaborationAbort = null; let projectRows = []; +let runnerProjectRows = []; +let projectSearch = ""; +let collaborationReplyTo = ""; +let refreshInFlight = false; let projectRowsTruncated = false; let sessionRows = []; const state = initialRuntimeConsoleState(); @@ -360,50 +557,54 @@ function el(id) { } function setText(id, value) { const node = el(id); - if (node) { + if (node) node.textContent = value === null || value === undefined || value === "" ? "—" : String(value); - } } function show(id, visible) { const node = el(id); - if (node) { + if (node) node.hidden = !visible; - } } function clearNode(node) { - while (node && node.firstChild) { + while (node && node.firstChild) node.removeChild(node.firstChild); - } } function appendChip(parent, text, extraClass = "") { const chip = document.createElement("span"); chip.className = "chip" + (extraClass ? " " + extraClass : ""); chip.textContent = text; parent.appendChild(chip); + return chip; } function abort(controller) { if (controller) controller.abort(); } +function abortCollaboration() { + abort(collaborationAbort); + collaborationAbort = null; +} function abortProjectWork() { abort(sessionsAbort); abort(detailAbort); + abortCollaboration(); sessionsAbort = null; detailAbort = null; } function abortAll() { + abort(overviewAbort); abort(projectsAbort); + abort(runnerAbort); + overviewAbort = null; projectsAbort = null; + runnerAbort = null; abortProjectWork(); } async function api(path, payload, signal) { try { const response = await fetch(API_BASE + path, { method: "POST", - headers: { - Authorization: "Bearer " + token, - "Content-Type": "application/json", - }, + headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" }, body: JSON.stringify(payload), signal, }); @@ -426,12 +627,14 @@ function hideDetail() { show("runtime-session-detail", false); show("runtime-session-detail-empty", true); show("runtime-jump-latest", false); + clearNode(el("runtime-collaboration-board")); } function clearSessionSurface() { sessionRows = []; clearNode(el("runtime-session-list")); show("runtime-sessions-empty", false); clearRuntimeWorkflowSession(state); + abortCollaboration(); hideDetail(); } function lock(message = "") { @@ -439,13 +642,18 @@ function lock(message = "") { abortAll(); invalidateRuntimeCredential(state); projectRows = []; + runnerProjectRows = []; projectRowsTruncated = false; + projectSearch = ""; + collaborationReplyTo = ""; clearSessionSurface(); + clearNode(el("runtime-project-list")); show("runtime-token-gate", true); show("runtime-console", false); show("runtime-topbar-controls", false); stopAuto(); setText("runtime-token-error", message); + setText("runtime-refresh-status", ""); const input = el("runtime-token-input"); if (input) { input.value = ""; @@ -463,13 +671,59 @@ function showError(message) { setText("runtime-error", message); show("runtime-error", !!message); } +function countLabel(value, singular, plural = singular + "s") { + const count = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; + return count + " " + (count === 1 ? singular : plural); +} +function attentionLabel(attention) { + const parts = []; + for (const [key, singular] of [["open_risks", "risk"], ["open_todos", "todo"], ["open_questions", "question"], ["open_guidance", "guidance"]]) { + const count = typeof attention?.[key] === "number" ? attention[key] : 0; + if (count) + parts.push(countLabel(count, singular)); + } + return parts.length ? parts.join(" · ") : "No retained pending attention"; +} +async function fetchOverview(request) { + abort(overviewAbort); + const controller = new AbortController(); + overviewAbort = controller; + const response = await api("overview", {}, controller.signal); + if (overviewAbort === controller) + overviewAbort = null; + if (!response || !isCurrentRuntimeOverviewRequest(state, request)) + return false; + if (response.status === 401) { + lock("Credential rejected."); + return false; + } + if (response.status === 403) { + show("runtime-overview-unavailable", true); + setText("runtime-overview-access", "runtime:read unavailable"); + return true; + } + if (!response.ok || !response.data) { + setText("runtime-overview-access", "refresh unavailable"); + return false; + } + show("runtime-overview-unavailable", false); + setText("runtime-overview-access", "runtime:read"); + const data = response.data; + setText("runtime-server-identity", [data.service, data.version].filter(Boolean).join(" · ")); + setText("runtime-server-build", data.build_git_commit ? "build " + data.build_git_commit + (data.build_git_dirty ? " · dirty" : "") : "build unavailable"); + setText("runtime-server-runners", countLabel(data.runner_count, "Runner")); + setText("runtime-server-alignment", countLabel(data.runners_online, "online") + " · " + countLabel(data.runners_stale, "stale") + " · " + countLabel(data.runners_unavailable, "unavailable")); + setText("runtime-server-projects", data.projects_available ? countLabel(data.visible_projects, "visible Project") + (data.projects_truncated ? " +" : "") : "project:read unavailable"); + setText("runtime-server-jobs", countLabel(data.active_jobs, "active Job") + (data.mixed_builds_present ? " · mixed builds" : "")); + setText("runtime-server-attention", attentionLabel(data.workflow_sessions)); + setText("runtime-server-sessions", countLabel(data.workflow_sessions?.active, "active Session") + " · " + countLabel(data.workflow_sessions?.running, "running call") + (data.workflow_sessions?.truncated ? " · bounded aggregate" : "")); + return true; +} function projectLabel(project) { const name = project && project.name ? String(project.name) : ""; const id = project && project.id ? String(project.id) : ""; const identity = name && name !== id ? name + " — " + id : id; - const status = project && project.connected - ? String(project.agent_status || "online") - : "offline"; + const status = project && project.connected ? String(project.agent_status || "online") : "offline"; return identity + " · " + status; } async function fetchProjects(request, unlocking = false) { @@ -480,17 +734,17 @@ async function fetchProjects(request, unlocking = false) { if (projectsAbort === controller) projectsAbort = null; if (!response || !isCurrentRuntimeProjectsRequest(state, request)) - return; + return false; if (response.status === 401 || response.status === 403) { - lock("Credential does not have Runtime Console access."); - return; + lock("Credential does not have Runtime Console project access."); + return false; } if (!response.ok || !response.data) { if (unlocking) lock("Runtime Console is unavailable."); else showError("Could not refresh projects."); - return; + return false; } projectRows = Array.isArray(response.data.projects) ? response.data.projects : []; projectRowsTruncated = !!response.data.truncated; @@ -502,27 +756,45 @@ async function fetchProjects(request, unlocking = false) { if (!selection.project) { if (currentDevice || currentProject) { abortProjectWork(); - selectRuntimeProject(state, "", ""); + selectRuntimeProject(state, selection.device || "", ""); } renderProjectSelectors(projectRows, projectRowsTruncated); clearSessionSurface(); setText("runtime-selected-project", "No project selected"); - return; + const runnerRequest = refreshRuntimeRunner(state); + if (runnerRequest) + void fetchRunner(runnerRequest); + return true; } if (selection.device !== currentDevice || selection.project !== currentProject) { switchProject(selection.device, selection.project); } else { renderProjectSelectors(projectRows, projectRowsTruncated); + const runnerRequest = refreshRuntimeRunner(state); + if (runnerRequest) + void fetchRunner(runnerRequest); const listRequest = refreshRuntimeSessionList(state); if (listRequest) void fetchSessions(listRequest); } + return true; +} +function effectiveProjects(projects) { + const aggregates = new Map(); + for (const row of runnerProjectRows) { + if (row && typeof row.id === "string") + aggregates.set(row.id, row); + } + return (Array.isArray(projects) ? projects : []).map((project) => { + const aggregate = aggregates.get(String(project?.id || "")); + return aggregate ? { ...project, sessions: aggregate.sessions } : project; + }); } function renderProjectSelectors(projects, truncated) { const deviceSelect = el("runtime-device-select"); - const projectSelect = el("runtime-project-select"); - if (!deviceSelect || !projectSelect) + const projectList = el("runtime-project-list"); + if (!deviceSelect || !projectList) return; const devices = runtimeDeviceIds(projects); clearNode(deviceSelect); @@ -534,32 +806,109 @@ function renderProjectSelectors(projects, truncated) { } if (state.selectedDevice) deviceSelect.value = state.selectedDevice; + const rows = filterAndSortRuntimeProjects(effectiveProjects(projects), String(state.selectedDevice || ""), projectSearch); + clearNode(projectList); + show("runtime-projects-empty", !!state.selectedDevice && rows.length === 0); + for (const project of rows) { + const row = document.createElement("div"); + row.className = "project-row" + (project.id === state.selectedProject ? " selected" : ""); + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", project.id === state.selectedProject ? "true" : "false"); + row.tabIndex = 0; + const main = document.createElement("div"); + main.className = "project-row-main"; + const title = document.createElement("div"); + title.className = "project-row-title"; + title.textContent = project.name || project.id; + const id = document.createElement("div"); + id.className = "project-row-id"; + id.textContent = String(project.id || ""); + main.appendChild(title); + main.appendChild(id); + const facts = document.createElement("div"); + facts.className = "project-row-facts"; + appendChip(facts, project.connected ? String(project.agent_status || "online") : "offline"); + if (project.sessions) { + appendChip(facts, countLabel(project.sessions.retained_sessions, "retained Session")); + if (project.sessions.running_sessions) + appendChip(facts, countLabel(project.sessions.running_sessions, "working"), "tone-runtime"); + const attention = attentionLabel(project.sessions.attention); + if (!attention.startsWith("No retained")) + appendChip(facts, attention, "tone-warn"); + if (typeof project.sessions.latest_updated_at === "number") + appendChip(facts, "updated " + updatedLabel(project.sessions.latest_updated_at)); + } + row.appendChild(main); + row.appendChild(facts); + const select = () => switchProject(String(state.selectedDevice || ""), String(project.id || "")); + row.addEventListener("click", select); + row.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + select(); + } }); + projectList.appendChild(row); + } const deviceProjects = runtimeProjectsForDevice(projects, String(state.selectedDevice || "")); - clearNode(projectSelect); - for (const project of deviceProjects) { - const option = document.createElement("option"); - option.value = project.id; - option.textContent = projectLabel(project); - projectSelect.appendChild(option); - } - if (state.selectedProject) - projectSelect.value = state.selectedProject; - setText("runtime-device-status", devices.length - ? devices.length + " device" + (devices.length === 1 ? "" : "s") + " shown" + (truncated ? " · bounded project list" : "") - : "No authorized devices"); - setText("runtime-project-status", state.selectedDevice - ? deviceProjects.length + " authorized project" + (deviceProjects.length === 1 ? "" : "s") + " on this device" + (truncated ? " · from bounded list" : "") - : "No authorized projects"); + setText("runtime-device-status", devices.length ? countLabel(devices.length, "authorized Runner") + (truncated ? " · bounded project list" : "") : "No authorized Runners"); + setText("runtime-project-status", state.selectedDevice ? countLabel(deviceProjects.length, "authorized Project") + " on this Runner" + (truncated ? " · bounded list" : "") : "No authorized Projects"); } function switchProject(device, project) { abortProjectWork(); + if (state.selectedDevice !== device) { + abort(runnerAbort); + runnerAbort = null; + runnerProjectRows = []; + } + collaborationReplyTo = ""; clearSessionSurface(); const request = selectRuntimeProject(state, device, project); renderProjectSelectors(projectRows, projectRowsTruncated); setText("runtime-selected-project", project || "No project selected"); + const runnerRequest = refreshRuntimeRunner(state); + if (runnerRequest) + void fetchRunner(runnerRequest); if (request) void fetchSessions(request); } +async function fetchRunner(request) { + abort(runnerAbort); + const controller = new AbortController(); + runnerAbort = controller; + const response = await api("runner", { client_id: request.device, project_limit: 24 }, controller.signal); + if (runnerAbort === controller) + runnerAbort = null; + if (!response || !isCurrentRuntimeRunnerRequest(state, request)) + return; + if (response.status === 401) + return lock("Credential rejected."); + if (response.status === 403) { + show("runtime-runner-unavailable", true); + setText("runtime-runner-access", "runtime:read unavailable"); + runnerProjectRows = []; + renderProjectSelectors(projectRows, projectRowsTruncated); + return; + } + if (!response.ok || !response.data) { + show("runtime-runner-unavailable", true); + setText("runtime-runner-access", "Runner view unavailable"); + return; + } + show("runtime-runner-unavailable", false); + setText("runtime-runner-access", response.data.projects_truncated ? "bounded Project aggregate" : "runtime:read"); + runnerProjectRows = Array.isArray(response.data.projects) ? response.data.projects : []; + renderRunner(response.data); + renderProjectSelectors(projectRows, projectRowsTruncated); +} +function renderRunner(data) { + setText("runtime-runner-id", data.client_id); + setText("runtime-runner-health", (data.connected ? "connected" : "disconnected") + " · " + String(data.status || "unknown")); + setText("runtime-runner-version", data.version ? "v" + data.version : "version unavailable"); + setText("runtime-runner-build", data.build_git_commit ? String(data.build_git_commit) + (data.build_git_dirty ? " · dirty" : "") : "build unavailable"); + setText("runtime-runner-jobs", countLabel(data.active_jobs, "active Job")); + setText("runtime-runner-concurrency", countLabel(data.jobs_running, "running") + " · " + countLabel(data.jobs_queued, "queued") + (typeof data.job_concurrency_limit === "number" ? " · limit " + data.job_concurrency_limit : "")); + setText("runtime-runner-alignment", data.source_alignment || "unknown"); + setText("runtime-runner-project-count", data.projects_available ? countLabel(data.visible_project_count, "visible Project") : "project:read unavailable"); +} async function fetchSessions(request) { abort(sessionsAbort); const controller = new AbortController(); @@ -589,6 +938,7 @@ async function fetchSessions(request) { void fetchSessionDetail(detailRequest); } else if (selected) { + abortCollaboration(); clearRuntimeWorkflowSession(state); hideDetail(); } @@ -606,40 +956,33 @@ function activityKindLabel(activity) { if (kind === "Ran") return "Command"; } - if (kind === "Explored" && activity && typeof activity.group_count === "number") { + if (kind === "Explored" && activity && typeof activity.group_count === "number") return "Explored ×" + activity.group_count; - } return kind; } function activityFacts(activity, includeTiming) { const facts = []; if (activity && typeof activity.group_count === "number") { - if (Array.isArray(activity.group_kinds) && activity.group_kinds.length) { - facts.push(activity.group_kinds.map((value) => String(value)).join(" / ")); - } - if (Array.isArray(activity.group_tools) && activity.group_tools.length) { - facts.push(activity.group_tools.map((value) => String(value)).join(", ")); - } + if (Array.isArray(activity.group_kinds) && activity.group_kinds.length) + facts.push(activity.group_kinds.map(String).join(" / ")); + if (Array.isArray(activity.group_tools) && activity.group_tools.length) + facts.push(activity.group_tools.map(String).join(", ")); } - else if (activity && activity.tool) { + else if (activity && activity.tool) facts.push(String(activity.tool)); - } - if (activity && activity.kind === "Progress") { + if (activity && activity.kind === "Progress") facts.push("informational"); - } else if (activity && activity.job_handoff) { facts.push("handed off"); if (activity.execution_state) facts.push("execution " + String(activity.execution_state)); } - else if (activity && activity.state) { + else if (activity && activity.state) facts.push(String(activity.state)); - } if (activity && activity.job_id) facts.push("job " + String(activity.job_id)); - if (includeTiming && activity && typeof activity.started_at === "number") { + if (includeTiming && activity && typeof activity.started_at === "number") facts.push(new Date(activity.started_at * 1000).toLocaleTimeString()); - } return facts; } function activityDescription(activity) { @@ -685,8 +1028,9 @@ function renderSessionList(sessions, payload) { const meta = document.createElement("div"); meta.className = "chips"; appendChip(meta, String(session.lifecycle || "unknown")); - if (session.running_call) - appendChip(meta, "running"); + const liveness = workflowSessionLivenessPresentation(session); + const livenessChip = appendChip(meta, liveness.label, liveness.state === "working" ? "tone-runtime" : liveness.state === "attention" ? "tone-warn" : ""); + livenessChip.title = liveness.tooltip; appendChip(meta, updatedLabel(session.updated_at)); item.appendChild(title); item.appendChild(meta); @@ -707,11 +1051,16 @@ function renderSessionList(sessions, payload) { function selectSession(sessionId) { abort(detailAbort); detailAbort = null; + abortCollaboration(); hideDetail(); + setHumanJoinSendEnabled(false); const request = selectRuntimeWorkflowSession(state, sessionId); renderSessionList(sessionRows, { total: sessionRows.length, truncated: false }); if (request) void fetchSessionDetail(request); + const collaborationRequest = runtimeCollaborationRequest(state); + if (collaborationRequest) + void startCollaboration(collaborationRequest); } async function fetchSessionDetail(request) { abort(detailAbort); @@ -725,6 +1074,7 @@ async function fetchSessionDetail(request) { if (response.status === 401) return lock("Credential rejected."); if (response.status === 404) { + abortCollaboration(); clearRuntimeWorkflowSession(state); hideDetail(); return; @@ -741,9 +1091,8 @@ function setTone(id, tone) { const node = el(id); if (!node) return; - for (const name of ["pass", "warn", "fail", "muted"]) { + for (const name of ["pass", "warn", "fail", "muted"]) node.classList.toggle("tone-card-" + name, tone === name); - } } function renderOverview(overview) { const view = workflowSessionOverviewPresentation(overview); @@ -763,9 +1112,14 @@ function renderDetail(detail) { setText("runtime-session-title", detail.title); setText("runtime-session-lifecycle", detail.lifecycle); setText("runtime-session-mode", "mode " + String(detail.mode || "unknown")); - setText("runtime-session-running", detail.running_call ? "running call" : "no running call"); + const liveness = workflowSessionLivenessPresentation(detail); + setText("runtime-session-running", liveness.label); + const livenessNode = el("runtime-session-running"); + if (livenessNode) + livenessNode.title = liveness.tooltip; setText("runtime-session-updated", "Updated " + updatedLabel(detail.updated_at)); renderOverview(detail.overview); + renderCollaboration(); const activities = Array.isArray(detail.activity) ? detail.activity : []; const node = el("runtime-timeline"); const previousScrollTop = node ? node.scrollTop : 0; @@ -778,9 +1132,8 @@ function renderDetail(detail) { item.className = "timeline-event"; if (activity && activity.kind === "Progress") item.classList.add("reported-progress"); - if (activity && ["failed", "timed_out"].includes(String(activity.state || ""))) { + if (activity && ["failed", "timed_out"].includes(String(activity.state || ""))) item.classList.add("failed"); - } const head = document.createElement("div"); head.className = "timeline-head"; const kind = document.createElement("span"); @@ -801,7 +1154,7 @@ function renderDetail(detail) { if (activity && Array.isArray(activity.paths) && activity.paths.length) { const paths = document.createElement("div"); paths.className = "muted small"; - paths.textContent = activity.paths.map((path) => String(path)).join(" · "); + paths.textContent = activity.paths.map(String).join(" · "); item.appendChild(paths); } node.appendChild(item); @@ -809,6 +1162,274 @@ function renderDetail(detail) { node.scrollTop = workflowSessionScrollTopAfterRender(state.workflow, previousScrollTop, node.clientHeight, node.scrollHeight); syncFollowUi(); } +function collaborationPhaseLabel() { + switch (state.collaboration.phase) { + case "live": return "Live"; + case "reconnecting": return "Reconnecting"; + case "paused": return "Paused"; + default: return "Idle"; + } +} +function setCollaborationReplyTarget(messageId) { + collaborationReplyTo = messageId; + const reply = el("runtime-message-reply"); + if (reply) + reply.hidden = !messageId; + setText("runtime-message-reply-text", messageId ? "Reply to " + messageId : ""); +} +function renderCollaboration(statusText) { + const available = state.collaboration.available !== false; + show("runtime-collaboration-unavailable", !available); + show("runtime-collaboration-form", available); + const messages = available && Array.isArray(state.collaboration.messages) ? state.collaboration.messages : []; + show("runtime-collaboration-empty", available && messages.length === 0); + const status = available + ? "Collaboration: " + collaborationPhaseLabel() + " · " + countLabel(messages.length, "retained message") + (statusText ? " · " + statusText : "") + : "runtime:read unavailable"; + setText("runtime-collaboration-status", status); + const node = el("runtime-collaboration-board"); + clearNode(node); + if (!node || !available) + return; + const byId = new Map(); + const children = new Map(); + for (const message of messages) { + const id = String(message?.message_id || ""); + if (id) + byId.set(id, message); + } + for (const message of messages) { + const parent = typeof message?.reply_to === "string" ? message.reply_to : ""; + if (parent && byId.has(parent)) { + const list = children.get(parent) || []; + list.push(message); + children.set(parent, list); + } + } + const visited = new Set(); + const appendMessage = (message, depth, parentUnavailable) => { + const id = String(message?.message_id || ""); + if (!id || visited.has(id)) + return; + visited.add(id); + const card = document.createElement("article"); + card.className = "message-card " + String(message?.kind || "note") + (String(message?.status || "") === "resolved" ? " resolved" : "") + (parentUnavailable ? " retained-reply" : ""); + if (depth > 0) + card.classList.add("message-thread"); + const head = document.createElement("div"); + head.className = "message-head"; + const kind = document.createElement("span"); + kind.className = "message-kind"; + kind.textContent = String(message?.kind || "message") + " · " + String(message?.priority || "normal") + " · " + String(message?.status || "unknown"); + const time = document.createElement("span"); + time.className = "muted small"; + time.textContent = updatedLabel(message?.created_at); + head.appendChild(kind); + head.appendChild(time); + card.appendChild(head); + const meta = document.createElement("div"); + meta.className = "message-meta"; + const metaParts = [id]; + if (message?.author_session_id) + metaParts.push("author " + String(message.author_session_id)); + meta.textContent = metaParts.join(" · "); + card.appendChild(meta); + if (parentUnavailable) { + const unavailable = document.createElement("div"); + unavailable.className = "message-links"; + unavailable.textContent = "retained reply · parent unavailable"; + card.appendChild(unavailable); + } + else if (message?.reply_to) { + const reply = document.createElement("div"); + reply.className = "message-links"; + reply.textContent = "reply to " + String(message.reply_to); + card.appendChild(reply); + } + const body = document.createElement("div"); + body.className = "message-body"; + body.textContent = String(message?.message || ""); + card.appendChild(body); + if (message?.requires_ack) { + const ack = document.createElement("div"); + ack.className = "message-ack"; + ack.textContent = typeof message?.first_ack_observed_at === "number" + ? "ACK required · First ACK observed " + updatedLabel(message.first_ack_observed_at) + : "ACK required"; + card.appendChild(ack); + } + if (message?.resolved_at || message?.resolution || message?.resolved_by_message_id) { + const resolution = document.createElement("div"); + resolution.className = "message-resolution"; + const parts = []; + if (message.resolved_at) + parts.push("resolved " + updatedLabel(message.resolved_at)); + if (message.resolution) + parts.push(String(message.resolution)); + if (message.resolved_by_message_id) + parts.push("by " + String(message.resolved_by_message_id)); + resolution.textContent = parts.join(" · "); + card.appendChild(resolution); + } + const actions = document.createElement("div"); + actions.className = "message-actions"; + const replyButton = document.createElement("button"); + replyButton.type = "button"; + replyButton.className = "text-button"; + replyButton.textContent = "Reply"; + replyButton.addEventListener("click", () => setCollaborationReplyTarget(id)); + actions.appendChild(replyButton); + card.appendChild(actions); + node.appendChild(card); + for (const child of children.get(id) || []) + appendMessage(child, depth + 1, false); + }; + for (const message of messages) { + const parent = typeof message?.reply_to === "string" ? message.reply_to : ""; + if (!parent || !byId.has(parent)) + appendMessage(message, 0, !!parent); + } + for (const message of messages) + appendMessage(message, 0, false); +} +async function loadRetainedCollaboration(request, controller) { + // Establish the cursor before the retained snapshot. A mutation between these + // two reads is then present in the snapshot, the subsequent delta, or both; + // merge-by-id makes the overlap harmless. Listing first and baselining second + // would permanently skip a mutation that lands in that gap. + setRuntimeCollaborationPhase(state, request, "reconnecting"); + renderCollaboration("establishing retained baseline"); + const baseline = await api("workflow-session-observe", { project: request.project, session_id: request.sessionId, limit: 100 }, controller.signal); + if (!baseline || !isCurrentRuntimeCollaborationRequest(state, request)) + return null; + if (baseline.status === 401) { + lock("Credential rejected."); + return null; + } + if (baseline.status === 403) { + setRuntimeCollaborationAvailable(state, request, false); + setRuntimeCollaborationPhase(state, request, "paused"); + renderCollaboration(); + return null; + } + if (baseline.status === 404) { + setRuntimeCollaborationAvailable(state, request, false); + setRuntimeCollaborationPhase(state, request, "paused"); + renderCollaboration("Session unavailable"); + return null; + } + if (!baseline.ok || !baseline.data || typeof baseline.data.observation_token !== "string") { + setRuntimeCollaborationPhase(state, request, "paused"); + renderCollaboration("observation unavailable"); + return null; + } + const response = await api("workflow-session-messages", { project: request.project, session_id: request.sessionId, limit: 100 }, controller.signal); + if (!response || !isCurrentRuntimeCollaborationRequest(state, request)) + return null; + if (response.status === 401) { + lock("Credential rejected."); + return null; + } + if (response.status === 403) { + setRuntimeCollaborationAvailable(state, request, false); + setRuntimeCollaborationPhase(state, request, "paused"); + renderCollaboration(); + return null; + } + if (response.status === 404) { + setRuntimeCollaborationAvailable(state, request, false); + setRuntimeCollaborationPhase(state, request, "paused"); + renderCollaboration("Session unavailable"); + return null; + } + if (!response.ok || !response.data) { + setRuntimeCollaborationPhase(state, request, "paused"); + renderCollaboration("retained snapshot failed"); + return null; + } + setRuntimeCollaborationAvailable(state, request, true); + if (!adoptRuntimeCollaborationList(state, request, Array.isArray(response.data.messages) ? response.data.messages : [])) + return null; + adoptRuntimeCollaborationObservation(state, request, baseline.data); + setRuntimeCollaborationPhase(state, request, "live"); + setHumanJoinSendEnabled(true); + renderCollaboration("bounded long-poll"); + return baseline.data.observation_token; +} +async function startCollaboration(request) { + abortCollaboration(); + const controller = new AbortController(); + collaborationAbort = controller; + let observationToken = await loadRetainedCollaboration(request, controller); + while (observationToken && collaborationAbort === controller && isCurrentRuntimeCollaborationRequest(state, request)) { + const response = await api("workflow-session-observe", { + project: request.project, + session_id: request.sessionId, + after_observation_token: observationToken, + wait_secs: COLLABORATION_WAIT_SECS, + limit: 100, + }, controller.signal); + if (!response || collaborationAbort !== controller || !isCurrentRuntimeCollaborationRequest(state, request)) + break; + if (response.status === 401) { + lock("Credential rejected."); + break; + } + if (response.status === 403) { + setRuntimeCollaborationAvailable(state, request, false); + setRuntimeCollaborationPhase(state, request, "paused"); + renderCollaboration(); + break; + } + if (!response.ok || !response.data) { + setRuntimeCollaborationPhase(state, request, "paused"); + renderCollaboration("request failed"); + break; + } + const action = runtimeCollaborationObservationAction(response.data); + if (action === "reload") { + renderCollaboration("retention changed · reloading"); + observationToken = await loadRetainedCollaboration(request, controller); + continue; + } + if (!adoptRuntimeCollaborationObservation(state, request, response.data)) + break; + observationToken = String(response.data.observation_token || observationToken); + setRuntimeCollaborationPhase(state, request, "live"); + renderCollaboration(action === "drain" ? "draining retained changes" : "bounded long-poll"); + if (action === "drain") { + let draining = true; + while (draining && observationToken && collaborationAbort === controller && isCurrentRuntimeCollaborationRequest(state, request)) { + const drain = await api("workflow-session-observe", { + project: request.project, + session_id: request.sessionId, + after_observation_token: observationToken, + limit: 100, + }, controller.signal); + if (!drain || collaborationAbort !== controller || !isCurrentRuntimeCollaborationRequest(state, request)) + break; + if (!drain.ok || !drain.data) { + setRuntimeCollaborationPhase(state, request, "paused"); + renderCollaboration("delta drain failed"); + observationToken = null; + break; + } + if (runtimeCollaborationObservationAction(drain.data) === "reload") { + observationToken = await loadRetainedCollaboration(request, controller); + draining = false; + continue; + } + adoptRuntimeCollaborationObservation(state, request, drain.data); + observationToken = String(drain.data.observation_token || observationToken); + draining = !!drain.data.has_more; + setRuntimeCollaborationPhase(state, request, "live"); + renderCollaboration(draining ? "draining retained changes" : "bounded long-poll"); + } + } + } + if (collaborationAbort === controller) + collaborationAbort = null; +} function jumpLatest() { jumpWorkflowSessionToLatest(state.workflow); const node = el("runtime-timeline"); @@ -816,10 +1437,115 @@ function jumpLatest() { node.scrollTop = node.scrollHeight; syncFollowUi(); } +function setHumanJoinSendEnabled(enabled) { + const send = el("runtime-message-send"); + if (send) + send.disabled = !enabled; +} +function syncAckComposer() { + const kind = el("runtime-message-kind"); + const priority = el("runtime-message-priority"); + const checkbox = el("runtime-message-requires-ack"); + const guidance = kind?.value === "guidance"; + show("runtime-message-ack-label", guidance); + if (!checkbox) + return; + checkbox.disabled = !guidance || priority?.value !== "high"; + if (checkbox.disabled) + checkbox.checked = false; + checkbox.title = guidance && priority?.value !== "high" ? "ACK requirement is available for High priority guidance." : ""; +} +async function postHumanCollaborationMessage(event) { + event.preventDefault(); + const request = runtimeCollaborationRequest(state); + if (!request || state.collaboration.available === false) + return; + const kind = el("runtime-message-kind"); + const priority = el("runtime-message-priority"); + const body = el("runtime-message-body"); + const checkbox = el("runtime-message-requires-ack"); + const send = el("runtime-message-send"); + const message = body?.value.trim() || ""; + if (!message) { + setText("runtime-message-send-status", "Enter a message."); + return; + } + if (send) + send.disabled = true; + setText("runtime-message-send-status", "Sending…"); + const response = await api("workflow-session-post-message", { + project: request.project, + session_id: request.sessionId, + kind: kind?.value || "note", + priority: priority?.value || "normal", + message, + reply_to: collaborationReplyTo || null, + requires_ack: !!checkbox?.checked, + }); + if (!isCurrentRuntimeCollaborationRequest(state, request)) + return; + if (response?.status === 0) { + abortCollaboration(); + setRuntimeCollaborationPhase(state, request, "paused"); + setText("runtime-message-send-status", "Send outcome unknown. Refresh and review retained messages before retrying."); + renderCollaboration("send outcome unknown · refresh before retry"); + return; + } + if (send) + send.disabled = false; + if (response?.status === 401) { + lock("Credential rejected."); + return; + } + if (!response?.ok || !response.data) { + setText("runtime-message-send-status", "Send failed."); + return; + } + adoptRuntimeCollaborationObservation(state, request, { messages: [response.data] }); + if (body) + body.value = ""; + setCollaborationReplyTarget(""); + setText("runtime-message-send-status", "Sent."); + renderCollaboration(); +} +function setRefreshBusy(active) { + refreshInFlight = active; + const button = el("runtime-refresh"); + if (button) { + button.disabled = active; + button.textContent = active ? "Refreshing…" : "Refresh"; + } +} async function refreshAll() { - if (!token) + if (!token || refreshInFlight) return; - await fetchProjects(refreshRuntimeProjects(state)); + setRefreshBusy(true); + setText("runtime-refresh-status", "Refreshing…"); + const recoverCollaboration = runtimeCollaborationNeedsRefreshRecovery(state); + const overviewRequest = refreshRuntimeOverview(state); + const projectsRequest = refreshRuntimeProjects(state); + try { + const [overviewOk, projectsOk] = await Promise.all([ + fetchOverview(overviewRequest), + fetchProjects(projectsRequest), + ]); + if (!token) + return; + if (overviewOk && projectsOk) { + setText("runtime-refresh-status", "Refreshed " + new Date().toLocaleTimeString()); + } + else { + setText("runtime-refresh-status", "Refresh failed · showing previous data"); + } + if (recoverCollaboration && runtimeCollaborationNeedsRefreshRecovery(state)) { + const collaborationRequest = runtimeCollaborationRequest(state); + if (collaborationRequest) + void startCollaboration(collaborationRequest); + } + } + finally { + setRefreshBusy(false); + } } function startAuto() { stopAuto(); @@ -829,11 +1555,8 @@ function startAuto() { void fetchSessions(request); }, REFRESH_MS); } -function stopAuto() { - if (timer) - window.clearInterval(timer); - timer = 0; -} +function stopAuto() { if (timer) + window.clearInterval(timer); timer = 0; } el("runtime-token-form")?.addEventListener("submit", (event) => { event.preventDefault(); const input = el("runtime-token-input"); @@ -846,20 +1569,25 @@ el("runtime-token-form")?.addEventListener("submit", (event) => { } token = nextToken; const request = beginRuntimeCredential(state); + void fetchOverview(refreshRuntimeOverview(state)); void fetchProjects(request, true); }); el("runtime-device-select")?.addEventListener("change", () => { const select = el("runtime-device-select"); if (!select) return; - const projects = runtimeProjectsForDevice(projectRows, select.value); + const projects = filterAndSortRuntimeProjects(effectiveProjects(projectRows), select.value, ""); switchProject(select.value, projects.length ? String(projects[0].id) : ""); }); -el("runtime-project-select")?.addEventListener("change", () => { - const select = el("runtime-project-select"); - if (select) - switchProject(String(state.selectedDevice || ""), select.value); +el("runtime-project-search")?.addEventListener("input", () => { + const input = el("runtime-project-search"); + projectSearch = input?.value || ""; + renderProjectSelectors(projectRows, projectRowsTruncated); }); +el("runtime-message-kind")?.addEventListener("change", syncAckComposer); +el("runtime-message-priority")?.addEventListener("change", syncAckComposer); +el("runtime-message-reply-clear")?.addEventListener("click", () => setCollaborationReplyTarget("")); +el("runtime-collaboration-form")?.addEventListener("submit", (event) => void postHumanCollaborationMessage(event)); el("runtime-refresh")?.addEventListener("click", () => void refreshAll()); el("runtime-lock")?.addEventListener("click", () => lock()); el("runtime-jump-latest")?.addEventListener("click", jumpLatest); @@ -870,9 +1598,6 @@ el("runtime-timeline")?.addEventListener("scroll", () => { updateWorkflowSessionFollowFromScroll(state.workflow, node.scrollTop, node.clientHeight, node.scrollHeight); syncFollowUi(); }); -window.addEventListener("pagehide", () => { - token = ""; - abortAll(); - stopAuto(); -}); +syncAckComposer(); +window.addEventListener("pagehide", () => { token = ""; abortAll(); stopAuto(); }); lock(); diff --git a/frontend/dist/runtime_console_state.js b/frontend/dist/runtime_console_state.js index b2af75a1..a92e7e01 100644 --- a/frontend/dist/runtime_console_state.js +++ b/frontend/dist/runtime_console_state.js @@ -2,6 +2,41 @@ import { initialWorkflowSessionState, selectWorkflowSession, refreshWorkflowSess function compareText(left, right) { return left < right ? -1 : left > right ? 1 : 0; } +function emptyCollaborationState() { + return { + generation: 0, + sessionId: "", + messages: [], + observationToken: "", + available: true, + phase: "idle", + }; +} +function messageCreatedAt(message) { + return typeof message?.created_at === "number" ? message.created_at : 0; +} +export function mergeRuntimeCollaborationMessages(current, updates) { + const byId = new Map(); + for (const message of Array.isArray(current) ? current : []) { + const id = typeof message?.message_id === "string" ? message.message_id : ""; + if (id) + byId.set(id, message); + } + for (const message of Array.isArray(updates) ? updates : []) { + const id = typeof message?.message_id === "string" ? message.message_id : ""; + if (id) + byId.set(id, message); + } + return Array.from(byId.values()).sort((left, right) => messageCreatedAt(left) - messageCreatedAt(right) || + compareText(String(left?.message_id || ""), String(right?.message_id || ""))); +} +export function runtimeCollaborationObservationAction(payload) { + if (payload?.history_lost) + return "reload"; + if (payload?.has_more) + return "drain"; + return "wait"; +} export function runtimeDeviceIds(projects) { const devices = new Set(); for (const project of Array.isArray(projects) ? projects : []) { @@ -21,6 +56,39 @@ export function runtimeProjectsForDevice(projects, clientId) { return compareText(leftName, rightName) || compareText(left.id, right.id); }); } +function projectAttentionCount(project) { + const attention = project?.sessions?.attention; + return ["open_guidance", "open_questions", "open_risks", "open_todos"] + .reduce((total, key) => total + (typeof attention?.[key] === "number" ? Math.max(0, attention[key]) : 0), 0); +} +export function filterAndSortRuntimeProjects(projects, clientId, query) { + const needle = String(query || "").trim().toLocaleLowerCase(); + return runtimeProjectsForDevice(projects, clientId) + .filter((project) => { + if (!needle) + return true; + return [project?.name, project?.id] + .filter((value) => typeof value === "string") + .some((value) => String(value).toLocaleLowerCase().includes(needle)); + }) + .sort((left, right) => { + const leftRunning = typeof left?.sessions?.running_sessions === "number" ? left.sessions.running_sessions : 0; + const rightRunning = typeof right?.sessions?.running_sessions === "number" ? right.sessions.running_sessions : 0; + if (!!rightRunning !== !!leftRunning) + return rightRunning ? 1 : -1; + const leftAttention = projectAttentionCount(left); + const rightAttention = projectAttentionCount(right); + if (!!rightAttention !== !!leftAttention) + return rightAttention ? 1 : -1; + const leftUpdated = typeof left?.sessions?.latest_updated_at === "number" ? left.sessions.latest_updated_at : 0; + const rightUpdated = typeof right?.sessions?.latest_updated_at === "number" ? right.sessions.latest_updated_at : 0; + if (leftUpdated !== rightUpdated) + return rightUpdated - leftUpdated; + const leftName = typeof left?.name === "string" && left.name ? left.name : left.id; + const rightName = typeof right?.name === "string" && right.name ? right.name : right.id; + return compareText(String(leftName || ""), String(rightName || "")) || compareText(String(left?.id || ""), String(right?.id || "")); + }); +} export function preferredRuntimeProjectSelection(projects, selectedDevice, selectedProject) { const rows = Array.isArray(projects) ? projects : []; if (selectedProject) { @@ -36,27 +104,45 @@ export function preferredRuntimeProjectSelection(projects, selectedDevice, selec export function initialRuntimeConsoleState() { return { credentialGeneration: 0, + overviewGeneration: 0, projectsGeneration: 0, + runnerGeneration: 0, selectedDevice: "", selectedProject: "", projectGeneration: 0, sessionListGeneration: 0, workflow: initialWorkflowSessionState(), + collaboration: emptyCollaborationState(), }; } export function invalidateRuntimeCredential(state) { state.credentialGeneration += 1; + state.overviewGeneration += 1; state.projectsGeneration += 1; + state.runnerGeneration += 1; state.selectedDevice = ""; state.selectedProject = ""; state.projectGeneration += 1; state.sessionListGeneration += 1; clearWorkflowSessionSelection(state.workflow); + state.collaboration.generation += 1; + state.collaboration.sessionId = ""; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; + state.collaboration.available = true; + state.collaboration.phase = "idle"; } export function beginRuntimeCredential(state) { invalidateRuntimeCredential(state); return refreshRuntimeProjects(state); } +export function refreshRuntimeOverview(state) { + state.overviewGeneration += 1; + return { credentialGeneration: state.credentialGeneration, generation: state.overviewGeneration }; +} +export function isCurrentRuntimeOverviewRequest(state, request) { + return !!request && request.credentialGeneration === state.credentialGeneration && request.generation === state.overviewGeneration; +} export function refreshRuntimeProjects(state) { state.projectsGeneration += 1; return { @@ -71,18 +157,35 @@ export function isCurrentRuntimeProjectsRequest(state, request) { request.projectGeneration === state.projectGeneration && request.generation === state.projectsGeneration; } +export function refreshRuntimeRunner(state) { + if (!state.selectedDevice) + return null; + state.runnerGeneration += 1; + return { credentialGeneration: state.credentialGeneration, device: state.selectedDevice, generation: state.runnerGeneration }; +} +export function isCurrentRuntimeRunnerRequest(state, request) { + return !!request && request.credentialGeneration === state.credentialGeneration && + request.device === state.selectedDevice && request.generation === state.runnerGeneration; +} export function selectRuntimeProject(state, device, project) { + if (state.selectedDevice !== device) + state.runnerGeneration += 1; state.selectedDevice = device; state.selectedProject = project; state.projectGeneration += 1; state.sessionListGeneration += 1; clearWorkflowSessionSelection(state.workflow); + state.collaboration.generation += 1; + state.collaboration.sessionId = ""; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; + state.collaboration.available = true; + state.collaboration.phase = "idle"; return refreshRuntimeSessionList(state); } export function refreshRuntimeSessionList(state) { - if (!state.selectedProject) { + if (!state.selectedProject) return null; - } state.sessionListGeneration += 1; return { credentialGeneration: state.credentialGeneration, @@ -92,16 +195,13 @@ export function refreshRuntimeSessionList(state) { }; } export function isCurrentRuntimeSessionListRequest(state, request) { - return !!request && - request.credentialGeneration === state.credentialGeneration && - request.project === state.selectedProject && - request.projectGeneration === state.projectGeneration && + return !!request && request.credentialGeneration === state.credentialGeneration && + request.project === state.selectedProject && request.projectGeneration === state.projectGeneration && request.generation === state.sessionListGeneration; } function wrapWorkflowRequest(state, request) { - if (!request || !state.selectedProject) { + if (!request || !state.selectedProject) return null; - } return { credentialGeneration: state.credentialGeneration, project: state.selectedProject, @@ -111,6 +211,12 @@ function wrapWorkflowRequest(state, request) { }; } export function selectRuntimeWorkflowSession(state, sessionId) { + state.collaboration.generation += 1; + state.collaboration.sessionId = sessionId; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; + state.collaboration.available = true; + state.collaboration.phase = "idle"; return wrapWorkflowRequest(state, selectWorkflowSession(state.workflow, sessionId)); } export function refreshRuntimeWorkflowSession(state) { @@ -118,20 +224,63 @@ export function refreshRuntimeWorkflowSession(state) { } export function clearRuntimeWorkflowSession(state) { clearWorkflowSessionSelection(state.workflow); + state.collaboration.generation += 1; + state.collaboration.sessionId = ""; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; +} +export function runtimeCollaborationRequest(state) { + if (!state.selectedProject || !state.collaboration.sessionId) + return null; + return { + credentialGeneration: state.credentialGeneration, + project: state.selectedProject, + projectGeneration: state.projectGeneration, + sessionId: state.collaboration.sessionId, + generation: state.collaboration.generation, + }; +} +export function isCurrentRuntimeCollaborationRequest(state, request) { + return !!request && request.credentialGeneration === state.credentialGeneration && + request.project === state.selectedProject && request.projectGeneration === state.projectGeneration && + request.sessionId === state.collaboration.sessionId && request.generation === state.collaboration.generation; +} +export function adoptRuntimeCollaborationList(state, request, messages) { + if (!isCurrentRuntimeCollaborationRequest(state, request)) + return false; + state.collaboration.messages = mergeRuntimeCollaborationMessages([], messages); + return true; +} +export function adoptRuntimeCollaborationObservation(state, request, payload) { + if (!isCurrentRuntimeCollaborationRequest(state, request)) + return false; + state.collaboration.messages = mergeRuntimeCollaborationMessages(state.collaboration.messages, Array.isArray(payload?.messages) ? payload.messages : []); + if (typeof payload?.observation_token === "string") + state.collaboration.observationToken = payload.observation_token; + return true; +} +export function setRuntimeCollaborationAvailable(state, request, available) { + if (!isCurrentRuntimeCollaborationRequest(state, request)) + return false; + state.collaboration.available = available; + return true; +} +export function setRuntimeCollaborationPhase(state, request, phase) { + if (!isCurrentRuntimeCollaborationRequest(state, request)) + return false; + state.collaboration.phase = phase; + return true; +} +export function runtimeCollaborationNeedsRefreshRecovery(state) { + return state?.collaboration?.phase === "paused"; } export function isCurrentRuntimeWorkflowSessionRequest(state, request) { - return !!request && - request.credentialGeneration === state.credentialGeneration && - request.project === state.selectedProject && - request.projectGeneration === state.projectGeneration && - isCurrentWorkflowSessionDetailRequest(state.workflow, { - sessionId: request.sessionId, - generation: request.generation, - }); + return !!request && request.credentialGeneration === state.credentialGeneration && + request.project === state.selectedProject && request.projectGeneration === state.projectGeneration && + isCurrentWorkflowSessionDetailRequest(state.workflow, { sessionId: request.sessionId, generation: request.generation }); } export function adoptRuntimeWorkflowSessionDetail(state, request, detail) { - if (!isCurrentRuntimeWorkflowSessionRequest(state, request)) { + if (!isCurrentRuntimeWorkflowSessionRequest(state, request)) return false; - } return adoptWorkflowSessionDetail(state.workflow, { sessionId: request.sessionId, generation: request.generation }, detail); } diff --git a/frontend/dist/workflow_session_state.js b/frontend/dist/workflow_session_state.js index 0a8a4980..fceb7141 100644 --- a/frontend/dist/workflow_session_state.js +++ b/frontend/dist/workflow_session_state.js @@ -140,6 +140,46 @@ export function workflowSessionOverviewPresentation(overview) { progressAt: progress && typeof progress.reported_at === "number" ? progress.reported_at : null, }; } +function hasPendingAttention(overview) { + const attention = overview && typeof overview === "object" ? overview.attention : null; + return ["open_guidance", "open_questions", "open_risks", "open_todos"] + .some((key) => overviewCount(attention && attention[key]) > 0); +} +function idleAgeLabel(ageSeconds) { + if (ageSeconds < 60) + return "<1m"; + const minutes = Math.floor(ageSeconds / 60); + if (minutes < 60) + return minutes + "m"; + const hours = Math.floor(minutes / 60); + if (hours < 24) + return hours + "h"; + return Math.floor(hours / 24) + "d"; +} +export function workflowSessionLivenessPresentation(session, nowSeconds = Date.now() / 1000) { + const runningCall = !!session?.running_call; + const runningJobs = typeof session?.running_jobs === "number" ? Math.max(0, session.running_jobs) : 0; + const tooltip = "WebCodex activity only; host/model state is unknown."; + if (runningCall || runningJobs > 0) { + return { state: "working", label: "working", tooltip }; + } + const updatedAt = typeof session?.updated_at === "number" ? session.updated_at : 0; + const ageSeconds = updatedAt > 0 ? Math.max(0, nowSeconds - updatedAt) : Number.POSITIVE_INFINITY; + if (ageSeconds <= 120) { + return { state: "recent", label: "recently active", tooltip }; + } + if (hasPendingAttention(session?.overview)) { + return { state: "attention", label: "idle · pending attention", tooltip }; + } + return { + state: "idle", + label: Number.isFinite(ageSeconds) ? "idle · " + idleAgeLabel(ageSeconds) : "idle", + tooltip, + }; +} +export function workflowSessionIdleAttentionLabel(runningCall, overview) { + return workflowSessionLivenessPresentation({ running_call: runningCall, overview, updated_at: 0 }, 0).label; +} export function initialWorkflowSessionState() { return { selectedSessionId: "", diff --git a/frontend/src/runtime.css b/frontend/src/runtime.css index 90b687a5..8d4baf91 100644 --- a/frontend/src/runtime.css +++ b/frontend/src/runtime.css @@ -1,36 +1,17 @@ :root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color-scheme: light dark; - --bg: #f5f6f8; - --panel: #ffffff; - --text: #18202a; - --muted: #68717d; - --border: #d7dce2; - --accent: #3467d6; - --pass: #277a47; - --warn: #9a6500; - --fail: #b23333; + --bg: #f5f6f8; --panel: #ffffff; --text: #18202a; --muted: #68717d; --border: #d7dce2; + --accent: #3467d6; --pass: #277a47; --warn: #9a6500; --fail: #b23333; } - @media (prefers-color-scheme: dark) { - :root { - --bg: #15181d; - --panel: #1e232a; - --text: #e8ebef; - --muted: #a1aab5; - --border: #39414b; - --accent: #7ca4ff; - --pass: #65bd83; - --warn: #e3b052; - --fail: #ed7777; - } + :root { --bg: #15181d; --panel: #1e232a; --text: #e8ebef; --muted: #a1aab5; --border: #39414b; --accent: #7ca4ff; --pass: #65bd83; --warn: #e3b052; --fail: #ed7777; } } - * { box-sizing: border-box; } [hidden] { display: none !important; } body { margin: 0; background: var(--bg); color: var(--text); } -button, input, select { font: inherit; } -#runtime-page { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 40px; } +button, input, select, textarea { font: inherit; } +#runtime-page { width: min(1240px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 40px; } .topbar { display: flex; justify-content: space-between; gap: 16px; align-items: center; margin-bottom: 18px; } h1, h2, h3, h4, p { margin-top: 0; } h1 { margin-bottom: 3px; font-size: 1.35rem; } @@ -38,24 +19,38 @@ h2 { font-size: 1rem; } h4 { margin-bottom: 5px; color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; } .muted { color: var(--muted); } .small { font-size: .8rem; } -.topbar-controls, .field-row, .chips, .summary-facts, .timeline-controls { display: flex; gap: 7px; align-items: center; } +.topbar-controls, .field-row, .chips, .summary-facts, .timeline-controls, .section-head, .runner-head { display: flex; gap: 7px; align-items: center; } .btn { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); padding: 6px 10px; cursor: pointer; } +.btn:disabled { cursor: wait; opacity: .62; } .btn.primary { border-color: var(--accent); background: var(--accent); color: white; } .panel, .gate { background: var(--panel); border: 1px solid var(--border); border-radius: 9px; padding: 14px; margin-bottom: 14px; } -.gate { max-width: 620px; } +.gate { max-width: 680px; } .field-row input { flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; background: var(--bg); color: var(--text); } .error { color: var(--fail); } .banner { border-left: 3px solid var(--fail); padding: 8px 10px; background: var(--panel); } -.project-panel { display: grid; grid-template-columns: auto minmax(260px, 1fr) auto; gap: 8px 12px; align-items: center; } -.project-panel label { font-weight: 700; } -.project-panel select { min-width: 0; border: 1px solid var(--border); border-radius: 6px; padding: 7px 9px; background: var(--bg); color: var(--text); } -.project-id { grid-column: 2 / -1; overflow-wrap: anywhere; color: var(--muted); } -.panel-head { display: flex; justify-content: space-between; align-items: center; } +.panel-head, .section-head { display: flex; justify-content: space-between; align-items: center; } +.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } +.metric { min-width: 0; background: var(--bg); border-left: 3px solid var(--border); padding: 9px 10px; display: grid; gap: 3px; } +.metric strong, .metric span { overflow-wrap: anywhere; } +.metric-label { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; } +.runner-head { margin-bottom: 10px; } +.runner-head label { font-weight: 700; } +.runner-head select, .project-search-row input, .collaboration-composer select, .collaboration-composer textarea { min-width: 0; border: 1px solid var(--border); border-radius: 6px; padding: 7px 9px; background: var(--bg); color: var(--text); } +.runner-head select { min-width: 220px; } +.project-search-row { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(0, 2fr); gap: 8px; align-items: center; margin-bottom: 8px; } +.project-id { overflow-wrap: anywhere; color: var(--muted); text-align: right; } +.project-list { display: grid; gap: 5px; max-height: 360px; overflow: auto; } +.project-row { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(0, 1fr); gap: 8px; align-items: center; border: 1px solid var(--border); border-radius: 6px; padding: 7px 9px; background: var(--bg); cursor: pointer; min-width: 0; } +.project-row.selected { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); } +.project-row-main, .project-row-facts { min-width: 0; } +.project-row-title { font-weight: 650; overflow-wrap: anywhere; } +.project-row-id { color: var(--muted); font-size: .74rem; overflow-wrap: anywhere; } +.project-row-facts { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; } .session-grid { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; gap: 14px; align-items: start; } -.session-list { display: grid; gap: 7px; list-style: none; margin: 0; padding: 0; max-height: 680px; overflow: auto; } +.session-list { display: grid; gap: 7px; list-style: none; margin: 0; padding: 0; max-height: 760px; overflow: auto; } .session-card { border: 1px solid var(--border); border-radius: 7px; padding: 9px; cursor: pointer; background: var(--bg); } .session-card.selected { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); } -.session-title { font-weight: 650; overflow-wrap: anywhere; margin-bottom: 5px; } +.session-title { font-weight: 650; overflow-wrap: anywhere; margin-bottom: 5px; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } .chips, .summary-facts { flex-wrap: wrap; } .chip { display: inline-block; border: 1px solid var(--border); border-radius: 999px; padding: 2px 7px; font-size: .7rem; color: var(--muted); } .tone-runtime { color: var(--accent); border-color: var(--accent); } @@ -76,7 +71,30 @@ h4 { margin-bottom: 5px; color: var(--muted); font-size: .72rem; text-transform: .tone-card-warn { border-left-color: var(--warn); } .tone-card-fail { border-left-color: var(--fail); } .tone-card-muted { border-left-color: var(--muted); } -.timeline-controls { justify-content: space-between; margin: 10px 0 6px; } +.detail-section { border-top: 1px solid var(--border); padding-top: 12px; margin-top: 12px; } +.collaboration-board { display: grid; gap: 7px; max-height: 440px; overflow: auto; margin-top: 7px; } +.message-card { background: var(--bg); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 6px; padding: 8px; min-width: 0; } +.message-card.resolved { border-left-color: var(--pass); } +.message-card.risk { border-left-color: var(--fail); } +.message-card.question, .message-card.todo, .message-card.guidance { border-left-color: var(--warn); } +.message-card.retained-reply { border-left-style: dashed; } +.message-head { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; } +.message-kind { font-weight: 700; } +.message-meta, .message-links, .message-resolution { color: var(--muted); font-size: .76rem; overflow-wrap: anywhere; } +.message-body { margin-top: 6px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 14rem; overflow: auto; } +.message-ack { margin-top: 5px; color: var(--warn); font-size: .76rem; } +.message-actions { display: flex; justify-content: flex-end; margin-top: 5px; } +.text-button { appearance: none; border: 0; background: transparent; color: var(--accent); padding: 2px 4px; cursor: pointer; font: inherit; } +.collaboration-composer { border: 1px solid var(--border); border-radius: 7px; padding: 8px; margin-top: 7px; background: var(--bg); } +.composer-fields { display: flex; gap: 8px; align-items: end; flex-wrap: wrap; } +.composer-fields label { display: grid; gap: 3px; color: var(--muted); font-size: .76rem; } +.composer-fields .ack-field { display: flex; align-items: center; gap: 5px; padding-bottom: 6px; } +.collaboration-composer textarea { width: 100%; resize: vertical; margin-top: 7px; } +.composer-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 6px; } +.reply-target { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin-top: 6px; } +.message-thread { margin-left: min(28px, 5vw); } +.retention-note { margin: 8px 0 0; } +.timeline-controls { justify-content: space-between; margin: 0 0 6px; } .timeline { display: grid; gap: 5px; list-style: none; margin: 0; padding: 0; max-height: 360px; overflow: auto; } .timeline-event { border-left: 3px solid var(--border); background: var(--bg); padding: 6px 8px; } .timeline-event.failed { border-left-color: var(--fail); } @@ -84,11 +102,14 @@ h4 { margin-bottom: 5px; color: var(--muted); font-size: .72rem; text-transform: .timeline-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; } .timeline-kind { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; } .timeline-body { margin-top: 4px; overflow-wrap: anywhere; } - +@media (max-width: 900px) { .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 760px) { - #runtime-page { width: min(100% - 20px, 1180px); padding-top: 14px; } + #runtime-page { width: min(100% - 20px, 1240px); padding-top: 14px; } .topbar { align-items: flex-start; } - .session-grid, .overview { grid-template-columns: 1fr; } - .project-panel { grid-template-columns: 1fr; } - .project-id { grid-column: auto; } + .session-grid, .overview, .metric-grid { grid-template-columns: 1fr; } + .project-search-row, .project-row { grid-template-columns: 1fr; } + .project-id { text-align: left; } + .project-row-facts { justify-content: flex-start; } + .runner-head { align-items: stretch; flex-direction: column; } + .runner-head select { width: 100%; min-width: 0; } } diff --git a/frontend/src/runtime.html b/frontend/src/runtime.html index 617d0f06..26be3d92 100644 --- a/frontend/src/runtime.html +++ b/frontend/src/runtime.html @@ -12,9 +12,10 @@

WebCodex Runtime Console

-

Hosted Workflow Session observability

+

Server, Runner, Project, Workflow Session, and collaboration observability

@@ -23,7 +24,7 @@

WebCodex Runtime Console

Connect to Runtime

-

Enter an existing runtime Bearer credential with project read access. It stays in page memory only.

+

Enter an existing runtime Bearer credential with project read access. Runtime-wide and collaboration views additionally require runtime read access. The credential stays in page memory only.

@@ -34,14 +35,48 @@

Connect to Runtime

diff --git a/frontend/src/runtime.ts b/frontend/src/runtime.ts index 0a3f1abf..c6677291 100644 --- a/frontend/src/runtime.ts +++ b/frontend/src/runtime.ts @@ -1,6 +1,7 @@ import { workflowSessionListOverviewFacts, workflowSessionOverviewPresentation, + workflowSessionLivenessPresentation, updateWorkflowSessionFollowFromScroll, workflowSessionScrollTopAfterRender, jumpWorkflowSessionToLatest, @@ -10,11 +11,16 @@ import { initialRuntimeConsoleState, runtimeDeviceIds, runtimeProjectsForDevice, + filterAndSortRuntimeProjects, preferredRuntimeProjectSelection, invalidateRuntimeCredential, beginRuntimeCredential, + refreshRuntimeOverview, + isCurrentRuntimeOverviewRequest, refreshRuntimeProjects, isCurrentRuntimeProjectsRequest, + refreshRuntimeRunner, + isCurrentRuntimeRunnerRequest, selectRuntimeProject, refreshRuntimeSessionList, isCurrentRuntimeSessionListRequest, @@ -23,17 +29,33 @@ import { clearRuntimeWorkflowSession, isCurrentRuntimeWorkflowSessionRequest, adoptRuntimeWorkflowSessionDetail, + runtimeCollaborationRequest, + isCurrentRuntimeCollaborationRequest, + adoptRuntimeCollaborationList, + adoptRuntimeCollaborationObservation, + setRuntimeCollaborationAvailable, + setRuntimeCollaborationPhase, + runtimeCollaborationNeedsRefreshRecovery, + runtimeCollaborationObservationAction, } from "./runtime_console_state.js"; const API_BASE = "/api/runtime-console/"; const REFRESH_MS = 8000; +const COLLABORATION_WAIT_SECS = 25; let token = ""; let timer = 0; +let overviewAbort: AbortController | null = null; let projectsAbort: AbortController | null = null; +let runnerAbort: AbortController | null = null; let sessionsAbort: AbortController | null = null; let detailAbort: AbortController | null = null; +let collaborationAbort: AbortController | null = null; let projectRows: any[] = []; +let runnerProjectRows: any[] = []; +let projectSearch = ""; +let collaborationReplyTo = ""; +let refreshInFlight = false; let projectRowsTruncated = false; let sessionRows: any[] = []; const state = initialRuntimeConsoleState(); @@ -44,45 +66,50 @@ function el(id: string): HTMLElement | null { function setText(id: string, value: unknown): void { const node = el(id); - if (node) { - node.textContent = value === null || value === undefined || value === "" ? "—" : String(value); - } + if (node) node.textContent = value === null || value === undefined || value === "" ? "—" : String(value); } function show(id: string, visible: boolean): void { const node = el(id); - if (node) { - node.hidden = !visible; - } + if (node) node.hidden = !visible; } function clearNode(node: any): void { - while (node && node.firstChild) { - node.removeChild(node.firstChild); - } + while (node && node.firstChild) node.removeChild(node.firstChild); } -function appendChip(parent: HTMLElement, text: string, extraClass = ""): void { +function appendChip(parent: HTMLElement, text: string, extraClass = ""): HTMLElement { const chip = document.createElement("span"); chip.className = "chip" + (extraClass ? " " + extraClass : ""); chip.textContent = text; parent.appendChild(chip); + return chip; } function abort(controller: AbortController | null): void { if (controller) controller.abort(); } +function abortCollaboration(): void { + abort(collaborationAbort); + collaborationAbort = null; +} + function abortProjectWork(): void { abort(sessionsAbort); abort(detailAbort); + abortCollaboration(); sessionsAbort = null; detailAbort = null; } function abortAll(): void { + abort(overviewAbort); abort(projectsAbort); + abort(runnerAbort); + overviewAbort = null; projectsAbort = null; + runnerAbort = null; abortProjectWork(); } @@ -90,19 +117,12 @@ async function api(path: string, payload: any, signal?: AbortSignal): Promise { + abort(overviewAbort); + const controller = new AbortController(); + overviewAbort = controller; + const response = await api("overview", {}, controller.signal); + if (overviewAbort === controller) overviewAbort = null; + if (!response || !isCurrentRuntimeOverviewRequest(state, request)) return false; + if (response.status === 401) { lock("Credential rejected."); return false; } + if (response.status === 403) { + show("runtime-overview-unavailable", true); + setText("runtime-overview-access", "runtime:read unavailable"); + return true; + } + if (!response.ok || !response.data) { + setText("runtime-overview-access", "refresh unavailable"); + return false; + } + show("runtime-overview-unavailable", false); + setText("runtime-overview-access", "runtime:read"); + const data = response.data; + setText("runtime-server-identity", [data.service, data.version].filter(Boolean).join(" · ")); + setText("runtime-server-build", data.build_git_commit ? "build " + data.build_git_commit + (data.build_git_dirty ? " · dirty" : "") : "build unavailable"); + setText("runtime-server-runners", countLabel(data.runner_count, "Runner")); + setText("runtime-server-alignment", countLabel(data.runners_online, "online") + " · " + countLabel(data.runners_stale, "stale") + " · " + countLabel(data.runners_unavailable, "unavailable")); + setText("runtime-server-projects", data.projects_available ? countLabel(data.visible_projects, "visible Project") + (data.projects_truncated ? " +" : "") : "project:read unavailable"); + setText("runtime-server-jobs", countLabel(data.active_jobs, "active Job") + (data.mixed_builds_present ? " · mixed builds" : "")); + setText("runtime-server-attention", attentionLabel(data.workflow_sessions)); + setText("runtime-server-sessions", countLabel(data.workflow_sessions?.active, "active Session") + " · " + countLabel(data.workflow_sessions?.running, "running call") + (data.workflow_sessions?.truncated ? " · bounded aggregate" : "")); + return true; +} + function projectLabel(project: any): string { const name = project && project.name ? String(project.name) : ""; const id = project && project.id ? String(project.id) : ""; const identity = name && name !== id ? name + " — " + id : id; - const status = project && project.connected - ? String(project.agent_status || "online") - : "offline"; + const status = project && project.connected ? String(project.agent_status || "online") : "offline"; return identity + " · " + status; } -async function fetchProjects(request: any, unlocking = false): Promise { +async function fetchProjects(request: any, unlocking = false): Promise { abort(projectsAbort); const controller = new AbortController(); projectsAbort = controller; const response = await api("projects", { limit: 100 }, controller.signal); if (projectsAbort === controller) projectsAbort = null; - if (!response || !isCurrentRuntimeProjectsRequest(state, request)) return; + if (!response || !isCurrentRuntimeProjectsRequest(state, request)) return false; if (response.status === 401 || response.status === 403) { - lock("Credential does not have Runtime Console access."); - return; + lock("Credential does not have Runtime Console project access."); + return false; } if (!response.ok || !response.data) { if (unlocking) lock("Runtime Console is unavailable."); else showError("Could not refresh projects."); - return; + return false; } projectRows = Array.isArray(response.data.projects) ? response.data.projects : []; projectRowsTruncated = !!response.data.truncated; @@ -189,35 +256,46 @@ async function fetchProjects(request: any, unlocking = false): Promise { const currentDevice = String(state.selectedDevice || ""); const currentProject = String(state.selectedProject || ""); - const selection = preferredRuntimeProjectSelection( - projectRows, - currentDevice, - currentProject - ); + const selection = preferredRuntimeProjectSelection(projectRows, currentDevice, currentProject); if (!selection.project) { if (currentDevice || currentProject) { abortProjectWork(); - selectRuntimeProject(state, "", ""); + selectRuntimeProject(state, selection.device || "", ""); } renderProjectSelectors(projectRows, projectRowsTruncated); clearSessionSurface(); setText("runtime-selected-project", "No project selected"); - return; + const runnerRequest = refreshRuntimeRunner(state); + if (runnerRequest) void fetchRunner(runnerRequest); + return true; } if (selection.device !== currentDevice || selection.project !== currentProject) { switchProject(selection.device, selection.project); } else { renderProjectSelectors(projectRows, projectRowsTruncated); + const runnerRequest = refreshRuntimeRunner(state); + if (runnerRequest) void fetchRunner(runnerRequest); const listRequest = refreshRuntimeSessionList(state); if (listRequest) void fetchSessions(listRequest); } + return true; +} + +function effectiveProjects(projects: any[]): any[] { + const aggregates = new Map(); + for (const row of runnerProjectRows) { + if (row && typeof row.id === "string") aggregates.set(row.id, row); + } + return (Array.isArray(projects) ? projects : []).map((project) => { + const aggregate = aggregates.get(String(project?.id || "")); + return aggregate ? { ...project, sessions: aggregate.sessions } : project; + }); } function renderProjectSelectors(projects: any[], truncated: boolean): void { const deviceSelect = el("runtime-device-select") as HTMLSelectElement | null; - const projectSelect = el("runtime-project-select") as HTMLSelectElement | null; - if (!deviceSelect || !projectSelect) return; - + const projectList = el("runtime-project-list"); + if (!deviceSelect || !projectList) return; const devices = runtimeDeviceIds(projects); clearNode(deviceSelect); for (const clientId of devices) { @@ -227,60 +305,108 @@ function renderProjectSelectors(projects: any[], truncated: boolean): void { deviceSelect.appendChild(option); } if (state.selectedDevice) deviceSelect.value = state.selectedDevice; - - const deviceProjects = runtimeProjectsForDevice(projects, String(state.selectedDevice || "")); - clearNode(projectSelect); - for (const project of deviceProjects) { - const option = document.createElement("option"); - option.value = project.id; - option.textContent = projectLabel(project); - projectSelect.appendChild(option); - } - if (state.selectedProject) projectSelect.value = state.selectedProject; - - setText( - "runtime-device-status", - devices.length - ? devices.length + " device" + (devices.length === 1 ? "" : "s") + " shown" + (truncated ? " · bounded project list" : "") - : "No authorized devices" - ); - setText( - "runtime-project-status", - state.selectedDevice - ? deviceProjects.length + " authorized project" + (deviceProjects.length === 1 ? "" : "s") + " on this device" + (truncated ? " · from bounded list" : "") - : "No authorized projects" + const rows = filterAndSortRuntimeProjects( + effectiveProjects(projects), + String(state.selectedDevice || ""), + projectSearch, ); + clearNode(projectList); + show("runtime-projects-empty", !!state.selectedDevice && rows.length === 0); + for (const project of rows) { + const row = document.createElement("div"); + row.className = "project-row" + (project.id === state.selectedProject ? " selected" : ""); + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", project.id === state.selectedProject ? "true" : "false"); + row.tabIndex = 0; + const main = document.createElement("div"); main.className = "project-row-main"; + const title = document.createElement("div"); title.className = "project-row-title"; title.textContent = project.name || project.id; + const id = document.createElement("div"); id.className = "project-row-id"; id.textContent = String(project.id || ""); + main.appendChild(title); main.appendChild(id); + const facts = document.createElement("div"); facts.className = "project-row-facts"; + appendChip(facts, project.connected ? String(project.agent_status || "online") : "offline"); + if (project.sessions) { + appendChip(facts, countLabel(project.sessions.retained_sessions, "retained Session")); + if (project.sessions.running_sessions) appendChip(facts, countLabel(project.sessions.running_sessions, "working"), "tone-runtime"); + const attention = attentionLabel(project.sessions.attention); + if (!attention.startsWith("No retained")) appendChip(facts, attention, "tone-warn"); + if (typeof project.sessions.latest_updated_at === "number") appendChip(facts, "updated " + updatedLabel(project.sessions.latest_updated_at)); + } + row.appendChild(main); row.appendChild(facts); + const select = (): void => switchProject(String(state.selectedDevice || ""), String(project.id || "")); + row.addEventListener("click", select); + row.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); select(); } }); + projectList.appendChild(row); + } + const deviceProjects = runtimeProjectsForDevice(projects, String(state.selectedDevice || "")); + setText("runtime-device-status", devices.length ? countLabel(devices.length, "authorized Runner") + (truncated ? " · bounded project list" : "") : "No authorized Runners"); + setText("runtime-project-status", state.selectedDevice ? countLabel(deviceProjects.length, "authorized Project") + " on this Runner" + (truncated ? " · bounded list" : "") : "No authorized Projects"); } function switchProject(device: string, project: string): void { abortProjectWork(); + if (state.selectedDevice !== device) { + abort(runnerAbort); + runnerAbort = null; + runnerProjectRows = []; + } + collaborationReplyTo = ""; clearSessionSurface(); const request = selectRuntimeProject(state, device, project); renderProjectSelectors(projectRows, projectRowsTruncated); setText("runtime-selected-project", project || "No project selected"); + const runnerRequest = refreshRuntimeRunner(state); + if (runnerRequest) void fetchRunner(runnerRequest); if (request) void fetchSessions(request); } -async function fetchSessions(request: any): Promise { - abort(sessionsAbort); +async function fetchRunner(request: any): Promise { + abort(runnerAbort); const controller = new AbortController(); - sessionsAbort = controller; - const response = await api( - "workflow-sessions", - { project: request.project, limit: 50 }, - controller.signal - ); - if (sessionsAbort === controller) sessionsAbort = null; - if (!response || !isCurrentRuntimeSessionListRequest(state, request)) return; + runnerAbort = controller; + const response = await api("runner", { client_id: request.device, project_limit: 24 }, controller.signal); + if (runnerAbort === controller) runnerAbort = null; + if (!response || !isCurrentRuntimeRunnerRequest(state, request)) return; if (response.status === 401) return lock("Credential rejected."); - if (response.status === 403 || response.status === 404) { - showError("Selected project is no longer available."); + if (response.status === 403) { + show("runtime-runner-unavailable", true); + setText("runtime-runner-access", "runtime:read unavailable"); + runnerProjectRows = []; + renderProjectSelectors(projectRows, projectRowsTruncated); return; } if (!response.ok || !response.data) { - showError("Could not refresh Workflow Sessions."); + show("runtime-runner-unavailable", true); + setText("runtime-runner-access", "Runner view unavailable"); return; } + show("runtime-runner-unavailable", false); + setText("runtime-runner-access", response.data.projects_truncated ? "bounded Project aggregate" : "runtime:read"); + runnerProjectRows = Array.isArray(response.data.projects) ? response.data.projects : []; + renderRunner(response.data); + renderProjectSelectors(projectRows, projectRowsTruncated); +} + +function renderRunner(data: any): void { + setText("runtime-runner-id", data.client_id); + setText("runtime-runner-health", (data.connected ? "connected" : "disconnected") + " · " + String(data.status || "unknown")); + setText("runtime-runner-version", data.version ? "v" + data.version : "version unavailable"); + setText("runtime-runner-build", data.build_git_commit ? String(data.build_git_commit) + (data.build_git_dirty ? " · dirty" : "") : "build unavailable"); + setText("runtime-runner-jobs", countLabel(data.active_jobs, "active Job")); + setText("runtime-runner-concurrency", countLabel(data.jobs_running, "running") + " · " + countLabel(data.jobs_queued, "queued") + (typeof data.job_concurrency_limit === "number" ? " · limit " + data.job_concurrency_limit : "")); + setText("runtime-runner-alignment", data.source_alignment || "unknown"); + setText("runtime-runner-project-count", data.projects_available ? countLabel(data.visible_project_count, "visible Project") : "project:read unavailable"); +} + +async function fetchSessions(request: any): Promise { + abort(sessionsAbort); + const controller = new AbortController(); + sessionsAbort = controller; + const response = await api("workflow-sessions", { project: request.project, limit: 50 }, controller.signal); + if (sessionsAbort === controller) sessionsAbort = null; + if (!response || !isCurrentRuntimeSessionListRequest(state, request)) return; + if (response.status === 401) return lock("Credential rejected."); + if (response.status === 403 || response.status === 404) { showError("Selected project is no longer available."); return; } + if (!response.ok || !response.data) { showError("Could not refresh Workflow Sessions."); return; } sessionRows = Array.isArray(response.data.sessions) ? response.data.sessions : []; renderSessionList(sessionRows, response.data); showError(""); @@ -289,6 +415,7 @@ async function fetchSessions(request: any): Promise { const detailRequest = refreshRuntimeWorkflowSession(state); if (detailRequest) void fetchSessionDetail(detailRequest); } else if (selected) { + abortCollaboration(); clearRuntimeWorkflowSession(state); hideDetail(); } @@ -305,36 +432,23 @@ function activityKindLabel(activity: any): string { if (kind === "Tested") return "Test"; if (kind === "Ran") return "Command"; } - if (kind === "Explored" && activity && typeof activity.group_count === "number") { - return "Explored ×" + activity.group_count; - } + if (kind === "Explored" && activity && typeof activity.group_count === "number") return "Explored ×" + activity.group_count; return kind; } function activityFacts(activity: any, includeTiming: boolean): string[] { const facts: string[] = []; if (activity && typeof activity.group_count === "number") { - if (Array.isArray(activity.group_kinds) && activity.group_kinds.length) { - facts.push(activity.group_kinds.map((value: any) => String(value)).join(" / ")); - } - if (Array.isArray(activity.group_tools) && activity.group_tools.length) { - facts.push(activity.group_tools.map((value: any) => String(value)).join(", ")); - } - } else if (activity && activity.tool) { - facts.push(String(activity.tool)); - } - if (activity && activity.kind === "Progress") { - facts.push("informational"); - } else if (activity && activity.job_handoff) { + if (Array.isArray(activity.group_kinds) && activity.group_kinds.length) facts.push(activity.group_kinds.map(String).join(" / ")); + if (Array.isArray(activity.group_tools) && activity.group_tools.length) facts.push(activity.group_tools.map(String).join(", ")); + } else if (activity && activity.tool) facts.push(String(activity.tool)); + if (activity && activity.kind === "Progress") facts.push("informational"); + else if (activity && activity.job_handoff) { facts.push("handed off"); if (activity.execution_state) facts.push("execution " + String(activity.execution_state)); - } else if (activity && activity.state) { - facts.push(String(activity.state)); - } + } else if (activity && activity.state) facts.push(String(activity.state)); if (activity && activity.job_id) facts.push("job " + String(activity.job_id)); - if (includeTiming && activity && typeof activity.started_at === "number") { - facts.push(new Date(activity.started_at * 1000).toLocaleTimeString()); - } + if (includeTiming && activity && typeof activity.started_at === "number") facts.push(new Date(activity.started_at * 1000).toLocaleTimeString()); return facts; } @@ -347,246 +461,413 @@ function activityDescription(activity: any): string { function appendPreview(parent: HTMLElement, label: string, activity: any): void { if (!activity) return; - const row = document.createElement("div"); - row.className = "activity-preview muted small"; - const prefix = document.createElement("span"); - prefix.className = "activity-preview-label"; - prefix.textContent = label; - const text = document.createElement("span"); - text.textContent = activityDescription(activity); - row.appendChild(prefix); - row.appendChild(text); - parent.appendChild(row); + const row = document.createElement("div"); row.className = "activity-preview muted small"; + const prefix = document.createElement("span"); prefix.className = "activity-preview-label"; prefix.textContent = label; + const text = document.createElement("span"); text.textContent = activityDescription(activity); + row.appendChild(prefix); row.appendChild(text); parent.appendChild(row); } function renderSessionList(sessions: any[], payload: any): void { const node = el("runtime-session-list"); if (!node) return; - clearNode(node); - show("runtime-sessions-empty", sessions.length === 0); + clearNode(node); show("runtime-sessions-empty", sessions.length === 0); const total = typeof payload.total === "number" ? payload.total : sessions.length; setText("runtime-sessions-count", total ? sessions.length + (payload.truncated ? " of " + total : "") : "0"); const selected = String(state.workflow.selectedSessionId || ""); for (const session of sessions) { const id = String(session && session.session_id || ""); if (!id) continue; - const item = document.createElement("li"); - item.className = "session-card" + (id === selected ? " selected" : ""); - const title = document.createElement("div"); - title.className = "session-title"; - title.textContent = session.title ? String(session.title) : id; - const meta = document.createElement("div"); - meta.className = "chips"; + const item = document.createElement("li"); item.className = "session-card" + (id === selected ? " selected" : ""); + const title = document.createElement("div"); title.className = "session-title"; title.textContent = session.title ? String(session.title) : id; + const meta = document.createElement("div"); meta.className = "chips"; appendChip(meta, String(session.lifecycle || "unknown")); - if (session.running_call) appendChip(meta, "running"); + const liveness = workflowSessionLivenessPresentation(session); + const livenessChip = appendChip(meta, liveness.label, liveness.state === "working" ? "tone-runtime" : liveness.state === "attention" ? "tone-warn" : ""); + livenessChip.title = liveness.tooltip; appendChip(meta, updatedLabel(session.updated_at)); - item.appendChild(title); - item.appendChild(meta); + item.appendChild(title); item.appendChild(meta); const facts = workflowSessionListOverviewFacts(session.overview); if (facts.length) { - const summary = document.createElement("div"); - summary.className = "summary-facts"; + const summary = document.createElement("div"); summary.className = "summary-facts"; for (const fact of facts) appendChip(summary, fact.text, "tone-" + fact.tone); item.appendChild(summary); } - appendPreview(item, "Now", session.current_activity); - appendPreview(item, "Last", session.last_activity); - item.addEventListener("click", () => selectSession(id)); - node.appendChild(item); + appendPreview(item, "Now", session.current_activity); appendPreview(item, "Last", session.last_activity); + item.addEventListener("click", () => selectSession(id)); node.appendChild(item); } } function selectSession(sessionId: string): void { - abort(detailAbort); - detailAbort = null; - hideDetail(); + abort(detailAbort); detailAbort = null; abortCollaboration(); hideDetail(); + setHumanJoinSendEnabled(false); const request = selectRuntimeWorkflowSession(state, sessionId); renderSessionList(sessionRows, { total: sessionRows.length, truncated: false }); if (request) void fetchSessionDetail(request); + const collaborationRequest = runtimeCollaborationRequest(state); + if (collaborationRequest) void startCollaboration(collaborationRequest); } async function fetchSessionDetail(request: any): Promise { abort(detailAbort); - const controller = new AbortController(); - detailAbort = controller; - const response = await api( - "workflow-session", - { project: request.project, session_id: request.sessionId, limit: 100 }, - controller.signal - ); + const controller = new AbortController(); detailAbort = controller; + const response = await api("workflow-session", { project: request.project, session_id: request.sessionId, limit: 100 }, controller.signal); if (detailAbort === controller) detailAbort = null; if (!response || !isCurrentRuntimeWorkflowSessionRequest(state, request)) return; if (response.status === 401) return lock("Credential rejected."); - if (response.status === 404) { - clearRuntimeWorkflowSession(state); - hideDetail(); - return; - } - if (!response.ok || !response.data) { - showError("Could not refresh Workflow Session detail."); - return; - } + if (response.status === 404) { abortCollaboration(); clearRuntimeWorkflowSession(state); hideDetail(); return; } + if (!response.ok || !response.data) { showError("Could not refresh Workflow Session detail."); return; } if (!adoptRuntimeWorkflowSessionDetail(state, request, response.data)) return; renderDetail(response.data); } function setTone(id: string, tone: string): void { - const node = el(id); - if (!node) return; - for (const name of ["pass", "warn", "fail", "muted"]) { - node.classList.toggle("tone-card-" + name, tone === name); - } + const node = el(id); if (!node) return; + for (const name of ["pass", "warn", "fail", "muted"]) node.classList.toggle("tone-card-" + name, tone === name); } function renderOverview(overview: any): void { const view = workflowSessionOverviewPresentation(overview); setText("runtime-overview-work", view.workText); - setText( - "runtime-overview-validation", - view.validationText + (typeof view.validationAt === "number" ? " · " + updatedLabel(view.validationAt) : "") - ); + setText("runtime-overview-validation", view.validationText + (typeof view.validationAt === "number" ? " · " + updatedLabel(view.validationAt) : "")); setTone("runtime-overview-validation-card", view.validationTone); - setText("runtime-overview-attention", view.attentionText); - setTone("runtime-overview-attention-card", view.attentionTone); - setText( - "runtime-overview-progress", - view.progressText + (typeof view.progressAt === "number" ? " · reported " + updatedLabel(view.progressAt) : "") - ); + setText("runtime-overview-attention", view.attentionText); setTone("runtime-overview-attention-card", view.attentionTone); + setText("runtime-overview-progress", view.progressText + (typeof view.progressAt === "number" ? " · reported " + updatedLabel(view.progressAt) : "")); } function syncFollowUi(): void { - show( - "runtime-jump-latest", - !!state.workflow.selectedSessionId && !shouldFollowWorkflowSessionLatest(state.workflow) - ); + show("runtime-jump-latest", !!state.workflow.selectedSessionId && !shouldFollowWorkflowSessionLatest(state.workflow)); } function renderDetail(detail: any): void { - show("runtime-session-detail-empty", false); - show("runtime-session-detail", true); - setText("runtime-session-title", detail.title); - setText("runtime-session-lifecycle", detail.lifecycle); + show("runtime-session-detail-empty", false); show("runtime-session-detail", true); + setText("runtime-session-title", detail.title); setText("runtime-session-lifecycle", detail.lifecycle); setText("runtime-session-mode", "mode " + String(detail.mode || "unknown")); - setText("runtime-session-running", detail.running_call ? "running call" : "no running call"); - setText("runtime-session-updated", "Updated " + updatedLabel(detail.updated_at)); - renderOverview(detail.overview); - + const liveness = workflowSessionLivenessPresentation(detail); + setText("runtime-session-running", liveness.label); + const livenessNode = el("runtime-session-running"); if (livenessNode) livenessNode.title = liveness.tooltip; + setText("runtime-session-updated", "Updated " + updatedLabel(detail.updated_at)); renderOverview(detail.overview); + renderCollaboration(); const activities = Array.isArray(detail.activity) ? detail.activity : []; - const node = el("runtime-timeline"); - const previousScrollTop = node ? node.scrollTop : 0; - clearNode(node); - show("runtime-timeline-empty", activities.length === 0); + const node = el("runtime-timeline"); const previousScrollTop = node ? node.scrollTop : 0; + clearNode(node); show("runtime-timeline-empty", activities.length === 0); if (!node) return syncFollowUi(); for (const activity of activities) { - const item = document.createElement("li"); - item.className = "timeline-event"; + const item = document.createElement("li"); item.className = "timeline-event"; if (activity && activity.kind === "Progress") item.classList.add("reported-progress"); - if (activity && ["failed", "timed_out"].includes(String(activity.state || ""))) { - item.classList.add("failed"); + if (activity && ["failed", "timed_out"].includes(String(activity.state || ""))) item.classList.add("failed"); + const head = document.createElement("div"); head.className = "timeline-head"; + const kind = document.createElement("span"); kind.className = "timeline-kind"; kind.textContent = activityKindLabel(activity); + const meta = document.createElement("span"); meta.className = "muted small"; meta.textContent = activityFacts(activity, true).join(" · "); + head.appendChild(kind); head.appendChild(meta); item.appendChild(head); + if (activity && activity.summary) { const body = document.createElement("div"); body.className = "timeline-body small"; body.textContent = String(activity.summary); item.appendChild(body); } + if (activity && Array.isArray(activity.paths) && activity.paths.length) { const paths = document.createElement("div"); paths.className = "muted small"; paths.textContent = activity.paths.map(String).join(" · "); item.appendChild(paths); } + node.appendChild(item); + } + node.scrollTop = workflowSessionScrollTopAfterRender(state.workflow, previousScrollTop, node.clientHeight, node.scrollHeight); syncFollowUi(); +} + +function collaborationPhaseLabel(): string { + switch (state.collaboration.phase) { + case "live": return "Live"; + case "reconnecting": return "Reconnecting"; + case "paused": return "Paused"; + default: return "Idle"; + } +} + +function setCollaborationReplyTarget(messageId: string): void { + collaborationReplyTo = messageId; + const reply = el("runtime-message-reply"); + if (reply) reply.hidden = !messageId; + setText("runtime-message-reply-text", messageId ? "Reply to " + messageId : ""); +} + +function renderCollaboration(statusText?: string): void { + const available = state.collaboration.available !== false; + show("runtime-collaboration-unavailable", !available); + show("runtime-collaboration-form", available); + const messages = available && Array.isArray(state.collaboration.messages) ? state.collaboration.messages : []; + show("runtime-collaboration-empty", available && messages.length === 0); + const status = available + ? "Collaboration: " + collaborationPhaseLabel() + " · " + countLabel(messages.length, "retained message") + (statusText ? " · " + statusText : "") + : "runtime:read unavailable"; + setText("runtime-collaboration-status", status); + const node = el("runtime-collaboration-board"); clearNode(node); + if (!node || !available) return; + const byId = new Map(); + const children = new Map(); + for (const message of messages) { + const id = String(message?.message_id || ""); if (id) byId.set(id, message); + } + for (const message of messages) { + const parent = typeof message?.reply_to === "string" ? message.reply_to : ""; + if (parent && byId.has(parent)) { + const list = children.get(parent) || []; list.push(message); children.set(parent, list); } - const head = document.createElement("div"); - head.className = "timeline-head"; - const kind = document.createElement("span"); - kind.className = "timeline-kind"; - kind.textContent = activityKindLabel(activity); - const meta = document.createElement("span"); - meta.className = "muted small"; - meta.textContent = activityFacts(activity, true).join(" · "); - head.appendChild(kind); - head.appendChild(meta); - item.appendChild(head); - if (activity && activity.summary) { - const body = document.createElement("div"); - body.className = "timeline-body small"; - body.textContent = String(activity.summary); - item.appendChild(body); + } + const visited = new Set(); + const appendMessage = (message: any, depth: number, parentUnavailable: boolean): void => { + const id = String(message?.message_id || ""); if (!id || visited.has(id)) return; visited.add(id); + const card = document.createElement("article"); + card.className = "message-card " + String(message?.kind || "note") + (String(message?.status || "") === "resolved" ? " resolved" : "") + (parentUnavailable ? " retained-reply" : ""); + if (depth > 0) card.classList.add("message-thread"); + const head = document.createElement("div"); head.className = "message-head"; + const kind = document.createElement("span"); kind.className = "message-kind"; kind.textContent = String(message?.kind || "message") + " · " + String(message?.priority || "normal") + " · " + String(message?.status || "unknown"); + const time = document.createElement("span"); time.className = "muted small"; time.textContent = updatedLabel(message?.created_at); + head.appendChild(kind); head.appendChild(time); card.appendChild(head); + const meta = document.createElement("div"); meta.className = "message-meta"; + const metaParts = [id]; if (message?.author_session_id) metaParts.push("author " + String(message.author_session_id)); + meta.textContent = metaParts.join(" · "); card.appendChild(meta); + if (parentUnavailable) { const unavailable = document.createElement("div"); unavailable.className = "message-links"; unavailable.textContent = "retained reply · parent unavailable"; card.appendChild(unavailable); } + else if (message?.reply_to) { const reply = document.createElement("div"); reply.className = "message-links"; reply.textContent = "reply to " + String(message.reply_to); card.appendChild(reply); } + const body = document.createElement("div"); body.className = "message-body"; body.textContent = String(message?.message || ""); card.appendChild(body); + if (message?.requires_ack) { + const ack = document.createElement("div"); ack.className = "message-ack"; + ack.textContent = typeof message?.first_ack_observed_at === "number" + ? "ACK required · First ACK observed " + updatedLabel(message.first_ack_observed_at) + : "ACK required"; + card.appendChild(ack); } - if (activity && Array.isArray(activity.paths) && activity.paths.length) { - const paths = document.createElement("div"); - paths.className = "muted small"; - paths.textContent = activity.paths.map((path: any) => String(path)).join(" · "); - item.appendChild(paths); + if (message?.resolved_at || message?.resolution || message?.resolved_by_message_id) { + const resolution = document.createElement("div"); resolution.className = "message-resolution"; + const parts: string[] = []; if (message.resolved_at) parts.push("resolved " + updatedLabel(message.resolved_at)); if (message.resolution) parts.push(String(message.resolution)); if (message.resolved_by_message_id) parts.push("by " + String(message.resolved_by_message_id)); + resolution.textContent = parts.join(" · "); card.appendChild(resolution); } - node.appendChild(item); + const actions = document.createElement("div"); actions.className = "message-actions"; + const replyButton = document.createElement("button"); replyButton.type = "button"; replyButton.className = "text-button"; replyButton.textContent = "Reply"; + replyButton.addEventListener("click", () => setCollaborationReplyTarget(id)); + actions.appendChild(replyButton); card.appendChild(actions); + node.appendChild(card); + for (const child of children.get(id) || []) appendMessage(child, depth + 1, false); + }; + for (const message of messages) { + const parent = typeof message?.reply_to === "string" ? message.reply_to : ""; + if (!parent || !byId.has(parent)) appendMessage(message, 0, !!parent); } - node.scrollTop = workflowSessionScrollTopAfterRender( - state.workflow, - previousScrollTop, - node.clientHeight, - node.scrollHeight - ); - syncFollowUi(); + for (const message of messages) appendMessage(message, 0, false); +} + +async function loadRetainedCollaboration(request: any, controller: AbortController): Promise { + // Establish the cursor before the retained snapshot. A mutation between these + // two reads is then present in the snapshot, the subsequent delta, or both; + // merge-by-id makes the overlap harmless. Listing first and baselining second + // would permanently skip a mutation that lands in that gap. + setRuntimeCollaborationPhase(state, request, "reconnecting"); + renderCollaboration("establishing retained baseline"); + const baseline = await api("workflow-session-observe", { project: request.project, session_id: request.sessionId, limit: 100 }, controller.signal); + if (!baseline || !isCurrentRuntimeCollaborationRequest(state, request)) return null; + if (baseline.status === 401) { lock("Credential rejected."); return null; } + if (baseline.status === 403) { setRuntimeCollaborationAvailable(state, request, false); setRuntimeCollaborationPhase(state, request, "paused"); renderCollaboration(); return null; } + if (baseline.status === 404) { setRuntimeCollaborationAvailable(state, request, false); setRuntimeCollaborationPhase(state, request, "paused"); renderCollaboration("Session unavailable"); return null; } + if (!baseline.ok || !baseline.data || typeof baseline.data.observation_token !== "string") { setRuntimeCollaborationPhase(state, request, "paused"); renderCollaboration("observation unavailable"); return null; } + + const response = await api("workflow-session-messages", { project: request.project, session_id: request.sessionId, limit: 100 }, controller.signal); + if (!response || !isCurrentRuntimeCollaborationRequest(state, request)) return null; + if (response.status === 401) { lock("Credential rejected."); return null; } + if (response.status === 403) { setRuntimeCollaborationAvailable(state, request, false); setRuntimeCollaborationPhase(state, request, "paused"); renderCollaboration(); return null; } + if (response.status === 404) { setRuntimeCollaborationAvailable(state, request, false); setRuntimeCollaborationPhase(state, request, "paused"); renderCollaboration("Session unavailable"); return null; } + if (!response.ok || !response.data) { setRuntimeCollaborationPhase(state, request, "paused"); renderCollaboration("retained snapshot failed"); return null; } + setRuntimeCollaborationAvailable(state, request, true); + if (!adoptRuntimeCollaborationList(state, request, Array.isArray(response.data.messages) ? response.data.messages : [])) return null; + adoptRuntimeCollaborationObservation(state, request, baseline.data); + setRuntimeCollaborationPhase(state, request, "live"); + setHumanJoinSendEnabled(true); + renderCollaboration("bounded long-poll"); + return baseline.data.observation_token; +} + +async function startCollaboration(request: any): Promise { + abortCollaboration(); + const controller = new AbortController(); collaborationAbort = controller; + let observationToken = await loadRetainedCollaboration(request, controller); + while (observationToken && collaborationAbort === controller && isCurrentRuntimeCollaborationRequest(state, request)) { + const response = await api("workflow-session-observe", { + project: request.project, + session_id: request.sessionId, + after_observation_token: observationToken, + wait_secs: COLLABORATION_WAIT_SECS, + limit: 100, + }, controller.signal); + if (!response || collaborationAbort !== controller || !isCurrentRuntimeCollaborationRequest(state, request)) break; + if (response.status === 401) { lock("Credential rejected."); break; } + if (response.status === 403) { setRuntimeCollaborationAvailable(state, request, false); setRuntimeCollaborationPhase(state, request, "paused"); renderCollaboration(); break; } + if (!response.ok || !response.data) { setRuntimeCollaborationPhase(state, request, "paused"); renderCollaboration("request failed"); break; } + const action = runtimeCollaborationObservationAction(response.data); + if (action === "reload") { + renderCollaboration("retention changed · reloading"); + observationToken = await loadRetainedCollaboration(request, controller); + continue; + } + if (!adoptRuntimeCollaborationObservation(state, request, response.data)) break; + observationToken = String(response.data.observation_token || observationToken); + setRuntimeCollaborationPhase(state, request, "live"); + renderCollaboration(action === "drain" ? "draining retained changes" : "bounded long-poll"); + if (action === "drain") { + let draining = true; + while (draining && observationToken && collaborationAbort === controller && isCurrentRuntimeCollaborationRequest(state, request)) { + const drain = await api("workflow-session-observe", { + project: request.project, + session_id: request.sessionId, + after_observation_token: observationToken, + limit: 100, + }, controller.signal); + if (!drain || collaborationAbort !== controller || !isCurrentRuntimeCollaborationRequest(state, request)) break; + if (!drain.ok || !drain.data) { setRuntimeCollaborationPhase(state, request, "paused"); renderCollaboration("delta drain failed"); observationToken = null; break; } + if (runtimeCollaborationObservationAction(drain.data) === "reload") { + observationToken = await loadRetainedCollaboration(request, controller); + draining = false; + continue; + } + adoptRuntimeCollaborationObservation(state, request, drain.data); + observationToken = String(drain.data.observation_token || observationToken); + draining = !!drain.data.has_more; + setRuntimeCollaborationPhase(state, request, "live"); + renderCollaboration(draining ? "draining retained changes" : "bounded long-poll"); + } + } + } + if (collaborationAbort === controller) collaborationAbort = null; } function jumpLatest(): void { jumpWorkflowSessionToLatest(state.workflow); - const node = el("runtime-timeline"); - if (node) node.scrollTop = node.scrollHeight; - syncFollowUi(); + const node = el("runtime-timeline"); if (node) node.scrollTop = node.scrollHeight; syncFollowUi(); +} + +function setHumanJoinSendEnabled(enabled: boolean): void { + const send = el("runtime-message-send") as HTMLButtonElement | null; + if (send) send.disabled = !enabled; +} + +function syncAckComposer(): void { + const kind = el("runtime-message-kind") as HTMLSelectElement | null; + const priority = el("runtime-message-priority") as HTMLSelectElement | null; + const checkbox = el("runtime-message-requires-ack") as HTMLInputElement | null; + const guidance = kind?.value === "guidance"; + show("runtime-message-ack-label", guidance); + if (!checkbox) return; + checkbox.disabled = !guidance || priority?.value !== "high"; + if (checkbox.disabled) checkbox.checked = false; + checkbox.title = guidance && priority?.value !== "high" ? "ACK requirement is available for High priority guidance." : ""; +} + +async function postHumanCollaborationMessage(event: Event): Promise { + event.preventDefault(); + const request = runtimeCollaborationRequest(state); + if (!request || state.collaboration.available === false) return; + const kind = el("runtime-message-kind") as HTMLSelectElement | null; + const priority = el("runtime-message-priority") as HTMLSelectElement | null; + const body = el("runtime-message-body") as HTMLTextAreaElement | null; + const checkbox = el("runtime-message-requires-ack") as HTMLInputElement | null; + const send = el("runtime-message-send") as HTMLButtonElement | null; + const message = body?.value.trim() || ""; + if (!message) { setText("runtime-message-send-status", "Enter a message."); return; } + if (send) send.disabled = true; + setText("runtime-message-send-status", "Sending…"); + const response = await api("workflow-session-post-message", { + project: request.project, + session_id: request.sessionId, + kind: kind?.value || "note", + priority: priority?.value || "normal", + message, + reply_to: collaborationReplyTo || null, + requires_ack: !!checkbox?.checked, + }); + if (!isCurrentRuntimeCollaborationRequest(state, request)) return; + if (response?.status === 0) { + abortCollaboration(); + setRuntimeCollaborationPhase(state, request, "paused"); + setText("runtime-message-send-status", "Send outcome unknown. Refresh and review retained messages before retrying."); + renderCollaboration("send outcome unknown · refresh before retry"); + return; + } + if (send) send.disabled = false; + if (response?.status === 401) { lock("Credential rejected."); return; } + if (!response?.ok || !response.data) { setText("runtime-message-send-status", "Send failed."); return; } + adoptRuntimeCollaborationObservation(state, request, { messages: [response.data] }); + if (body) body.value = ""; + setCollaborationReplyTarget(""); + setText("runtime-message-send-status", "Sent."); + renderCollaboration(); +} + +function setRefreshBusy(active: boolean): void { + refreshInFlight = active; + const button = el("runtime-refresh") as HTMLButtonElement | null; + if (button) { + button.disabled = active; + button.textContent = active ? "Refreshing…" : "Refresh"; + } } async function refreshAll(): Promise { - if (!token) return; - await fetchProjects(refreshRuntimeProjects(state)); + if (!token || refreshInFlight) return; + setRefreshBusy(true); + setText("runtime-refresh-status", "Refreshing…"); + const recoverCollaboration = runtimeCollaborationNeedsRefreshRecovery(state); + const overviewRequest = refreshRuntimeOverview(state); + const projectsRequest = refreshRuntimeProjects(state); + try { + const [overviewOk, projectsOk] = await Promise.all([ + fetchOverview(overviewRequest), + fetchProjects(projectsRequest), + ]); + if (!token) return; + if (overviewOk && projectsOk) { + setText("runtime-refresh-status", "Refreshed " + new Date().toLocaleTimeString()); + } else { + setText("runtime-refresh-status", "Refresh failed · showing previous data"); + } + if (recoverCollaboration && runtimeCollaborationNeedsRefreshRecovery(state)) { + const collaborationRequest = runtimeCollaborationRequest(state); + if (collaborationRequest) void startCollaboration(collaborationRequest); + } + } finally { + setRefreshBusy(false); + } } function startAuto(): void { stopAuto(); timer = window.setInterval(() => { - const request = refreshRuntimeSessionList(state); - if (request) void fetchSessions(request); + const request = refreshRuntimeSessionList(state); if (request) void fetchSessions(request); }, REFRESH_MS); } - -function stopAuto(): void { - if (timer) window.clearInterval(timer); - timer = 0; -} +function stopAuto(): void { if (timer) window.clearInterval(timer); timer = 0; } el("runtime-token-form")?.addEventListener("submit", (event) => { event.preventDefault(); const input = el("runtime-token-input") as HTMLInputElement | null; - const nextToken = input ? input.value.trim() : ""; - if (input) input.value = ""; - if (!nextToken) { - setText("runtime-token-error", "Enter a runtime Bearer credential."); - return; - } + const nextToken = input ? input.value.trim() : ""; if (input) input.value = ""; + if (!nextToken) { setText("runtime-token-error", "Enter a runtime Bearer credential."); return; } token = nextToken; const request = beginRuntimeCredential(state); + void fetchOverview(refreshRuntimeOverview(state)); void fetchProjects(request, true); }); el("runtime-device-select")?.addEventListener("change", () => { - const select = el("runtime-device-select") as HTMLSelectElement | null; - if (!select) return; - const projects = runtimeProjectsForDevice(projectRows, select.value); + const select = el("runtime-device-select") as HTMLSelectElement | null; if (!select) return; + const projects = filterAndSortRuntimeProjects(effectiveProjects(projectRows), select.value, ""); switchProject(select.value, projects.length ? String(projects[0].id) : ""); }); - -el("runtime-project-select")?.addEventListener("change", () => { - const select = el("runtime-project-select") as HTMLSelectElement | null; - if (select) switchProject(String(state.selectedDevice || ""), select.value); +el("runtime-project-search")?.addEventListener("input", () => { + const input = el("runtime-project-search") as HTMLInputElement | null; + projectSearch = input?.value || ""; + renderProjectSelectors(projectRows, projectRowsTruncated); }); - +el("runtime-message-kind")?.addEventListener("change", syncAckComposer); +el("runtime-message-priority")?.addEventListener("change", syncAckComposer); +el("runtime-message-reply-clear")?.addEventListener("click", () => setCollaborationReplyTarget("")); +el("runtime-collaboration-form")?.addEventListener("submit", (event) => void postHumanCollaborationMessage(event)); el("runtime-refresh")?.addEventListener("click", () => void refreshAll()); el("runtime-lock")?.addEventListener("click", () => lock()); el("runtime-jump-latest")?.addEventListener("click", jumpLatest); el("runtime-timeline")?.addEventListener("scroll", () => { - const node = el("runtime-timeline"); - if (!node) return; - updateWorkflowSessionFollowFromScroll( - state.workflow, - node.scrollTop, - node.clientHeight, - node.scrollHeight - ); - syncFollowUi(); -}); -window.addEventListener("pagehide", () => { - token = ""; - abortAll(); - stopAuto(); + const node = el("runtime-timeline"); if (!node) return; + updateWorkflowSessionFollowFromScroll(state.workflow, node.scrollTop, node.clientHeight, node.scrollHeight); syncFollowUi(); }); +syncAckComposer(); +window.addEventListener("pagehide", () => { token = ""; abortAll(); stopAuto(); }); lock(); diff --git a/frontend/src/runtime_console_state.ts b/frontend/src/runtime_console_state.ts index 2b14422b..b309eeb8 100644 --- a/frontend/src/runtime_console_state.ts +++ b/frontend/src/runtime_console_state.ts @@ -13,6 +13,43 @@ function compareText(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } +function emptyCollaborationState(): any { + return { + generation: 0, + sessionId: "", + messages: [], + observationToken: "", + available: true, + phase: "idle", + }; +} + +function messageCreatedAt(message: any): number { + return typeof message?.created_at === "number" ? message.created_at : 0; +} + +export function mergeRuntimeCollaborationMessages(current: any[], updates: any[]): any[] { + const byId = new Map(); + for (const message of Array.isArray(current) ? current : []) { + const id = typeof message?.message_id === "string" ? message.message_id : ""; + if (id) byId.set(id, message); + } + for (const message of Array.isArray(updates) ? updates : []) { + const id = typeof message?.message_id === "string" ? message.message_id : ""; + if (id) byId.set(id, message); + } + return Array.from(byId.values()).sort((left, right) => + messageCreatedAt(left) - messageCreatedAt(right) || + compareText(String(left?.message_id || ""), String(right?.message_id || "")) + ); +} + +export function runtimeCollaborationObservationAction(payload: any): "reload" | "drain" | "wait" { + if (payload?.history_lost) return "reload"; + if (payload?.has_more) return "drain"; + return "wait"; +} + export function runtimeDeviceIds(projects: any[]): string[] { const devices = new Set(); for (const project of Array.isArray(projects) ? projects : []) { @@ -33,6 +70,37 @@ export function runtimeProjectsForDevice(projects: any[], clientId: string): any }); } +function projectAttentionCount(project: any): number { + const attention = project?.sessions?.attention; + return ["open_guidance", "open_questions", "open_risks", "open_todos"] + .reduce((total, key) => total + (typeof attention?.[key] === "number" ? Math.max(0, attention[key]) : 0), 0); +} + +export function filterAndSortRuntimeProjects(projects: any[], clientId: string, query: string): any[] { + const needle = String(query || "").trim().toLocaleLowerCase(); + return runtimeProjectsForDevice(projects, clientId) + .filter((project) => { + if (!needle) return true; + return [project?.name, project?.id] + .filter((value) => typeof value === "string") + .some((value) => String(value).toLocaleLowerCase().includes(needle)); + }) + .sort((left, right) => { + const leftRunning = typeof left?.sessions?.running_sessions === "number" ? left.sessions.running_sessions : 0; + const rightRunning = typeof right?.sessions?.running_sessions === "number" ? right.sessions.running_sessions : 0; + if (!!rightRunning !== !!leftRunning) return rightRunning ? 1 : -1; + const leftAttention = projectAttentionCount(left); + const rightAttention = projectAttentionCount(right); + if (!!rightAttention !== !!leftAttention) return rightAttention ? 1 : -1; + const leftUpdated = typeof left?.sessions?.latest_updated_at === "number" ? left.sessions.latest_updated_at : 0; + const rightUpdated = typeof right?.sessions?.latest_updated_at === "number" ? right.sessions.latest_updated_at : 0; + if (leftUpdated !== rightUpdated) return rightUpdated - leftUpdated; + const leftName = typeof left?.name === "string" && left.name ? left.name : left.id; + const rightName = typeof right?.name === "string" && right.name ? right.name : right.id; + return compareText(String(leftName || ""), String(rightName || "")) || compareText(String(left?.id || ""), String(right?.id || "")); + }); +} + export function preferredRuntimeProjectSelection( projects: any[], selectedDevice: string, @@ -45,7 +113,6 @@ export function preferredRuntimeProjectSelection( ); if (retained) return { device: retained.client_id, project: retained.id }; } - const devices = runtimeDeviceIds(rows); const device = devices.includes(selectedDevice) ? selectedDevice : devices[0] || ""; const project = runtimeProjectsForDevice(rows, device)[0]; @@ -55,23 +122,34 @@ export function preferredRuntimeProjectSelection( export function initialRuntimeConsoleState(): any { return { credentialGeneration: 0, + overviewGeneration: 0, projectsGeneration: 0, + runnerGeneration: 0, selectedDevice: "", selectedProject: "", projectGeneration: 0, sessionListGeneration: 0, workflow: initialWorkflowSessionState(), + collaboration: emptyCollaborationState(), }; } export function invalidateRuntimeCredential(state: any): void { state.credentialGeneration += 1; + state.overviewGeneration += 1; state.projectsGeneration += 1; + state.runnerGeneration += 1; state.selectedDevice = ""; state.selectedProject = ""; state.projectGeneration += 1; state.sessionListGeneration += 1; clearWorkflowSessionSelection(state.workflow); + state.collaboration.generation += 1; + state.collaboration.sessionId = ""; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; + state.collaboration.available = true; + state.collaboration.phase = "idle"; } export function beginRuntimeCredential(state: any): any { @@ -79,6 +157,15 @@ export function beginRuntimeCredential(state: any): any { return refreshRuntimeProjects(state); } +export function refreshRuntimeOverview(state: any): any { + state.overviewGeneration += 1; + return { credentialGeneration: state.credentialGeneration, generation: state.overviewGeneration }; +} + +export function isCurrentRuntimeOverviewRequest(state: any, request: any): boolean { + return !!request && request.credentialGeneration === state.credentialGeneration && request.generation === state.overviewGeneration; +} + export function refreshRuntimeProjects(state: any): any { state.projectsGeneration += 1; return { @@ -95,19 +182,35 @@ export function isCurrentRuntimeProjectsRequest(state: any, request: any): boole request.generation === state.projectsGeneration; } +export function refreshRuntimeRunner(state: any): any { + if (!state.selectedDevice) return null; + state.runnerGeneration += 1; + return { credentialGeneration: state.credentialGeneration, device: state.selectedDevice, generation: state.runnerGeneration }; +} + +export function isCurrentRuntimeRunnerRequest(state: any, request: any): boolean { + return !!request && request.credentialGeneration === state.credentialGeneration && + request.device === state.selectedDevice && request.generation === state.runnerGeneration; +} + export function selectRuntimeProject(state: any, device: string, project: string): any { + if (state.selectedDevice !== device) state.runnerGeneration += 1; state.selectedDevice = device; state.selectedProject = project; state.projectGeneration += 1; state.sessionListGeneration += 1; clearWorkflowSessionSelection(state.workflow); + state.collaboration.generation += 1; + state.collaboration.sessionId = ""; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; + state.collaboration.available = true; + state.collaboration.phase = "idle"; return refreshRuntimeSessionList(state); } export function refreshRuntimeSessionList(state: any): any { - if (!state.selectedProject) { - return null; - } + if (!state.selectedProject) return null; state.sessionListGeneration += 1; return { credentialGeneration: state.credentialGeneration, @@ -118,17 +221,13 @@ export function refreshRuntimeSessionList(state: any): any { } export function isCurrentRuntimeSessionListRequest(state: any, request: any): boolean { - return !!request && - request.credentialGeneration === state.credentialGeneration && - request.project === state.selectedProject && - request.projectGeneration === state.projectGeneration && + return !!request && request.credentialGeneration === state.credentialGeneration && + request.project === state.selectedProject && request.projectGeneration === state.projectGeneration && request.generation === state.sessionListGeneration; } function wrapWorkflowRequest(state: any, request: any): any { - if (!request || !state.selectedProject) { - return null; - } + if (!request || !state.selectedProject) return null; return { credentialGeneration: state.credentialGeneration, project: state.selectedProject, @@ -139,6 +238,12 @@ function wrapWorkflowRequest(state: any, request: any): any { } export function selectRuntimeWorkflowSession(state: any, sessionId: string): any { + state.collaboration.generation += 1; + state.collaboration.sessionId = sessionId; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; + state.collaboration.available = true; + state.collaboration.phase = "idle"; return wrapWorkflowRequest(state, selectWorkflowSession(state.workflow, sessionId)); } @@ -148,23 +253,73 @@ export function refreshRuntimeWorkflowSession(state: any): any { export function clearRuntimeWorkflowSession(state: any): void { clearWorkflowSessionSelection(state.workflow); + state.collaboration.generation += 1; + state.collaboration.sessionId = ""; + state.collaboration.messages = []; + state.collaboration.observationToken = ""; +} + +export function runtimeCollaborationRequest(state: any): any { + if (!state.selectedProject || !state.collaboration.sessionId) return null; + return { + credentialGeneration: state.credentialGeneration, + project: state.selectedProject, + projectGeneration: state.projectGeneration, + sessionId: state.collaboration.sessionId, + generation: state.collaboration.generation, + }; +} + +export function isCurrentRuntimeCollaborationRequest(state: any, request: any): boolean { + return !!request && request.credentialGeneration === state.credentialGeneration && + request.project === state.selectedProject && request.projectGeneration === state.projectGeneration && + request.sessionId === state.collaboration.sessionId && request.generation === state.collaboration.generation; +} + +export function adoptRuntimeCollaborationList(state: any, request: any, messages: any[]): boolean { + if (!isCurrentRuntimeCollaborationRequest(state, request)) return false; + state.collaboration.messages = mergeRuntimeCollaborationMessages([], messages); + return true; +} + +export function adoptRuntimeCollaborationObservation(state: any, request: any, payload: any): boolean { + if (!isCurrentRuntimeCollaborationRequest(state, request)) return false; + state.collaboration.messages = mergeRuntimeCollaborationMessages( + state.collaboration.messages, + Array.isArray(payload?.messages) ? payload.messages : [] + ); + if (typeof payload?.observation_token === "string") state.collaboration.observationToken = payload.observation_token; + return true; +} + +export function setRuntimeCollaborationAvailable(state: any, request: any, available: boolean): boolean { + if (!isCurrentRuntimeCollaborationRequest(state, request)) return false; + state.collaboration.available = available; + return true; +} + +export function setRuntimeCollaborationPhase( + state: any, + request: any, + phase: "idle" | "reconnecting" | "live" | "paused" +): boolean { + if (!isCurrentRuntimeCollaborationRequest(state, request)) return false; + state.collaboration.phase = phase; + return true; +} + +export function runtimeCollaborationNeedsRefreshRecovery(state: any): boolean { + return state?.collaboration?.phase === "paused"; } export function isCurrentRuntimeWorkflowSessionRequest(state: any, request: any): boolean { - return !!request && - request.credentialGeneration === state.credentialGeneration && - request.project === state.selectedProject && - request.projectGeneration === state.projectGeneration && - isCurrentWorkflowSessionDetailRequest(state.workflow, { - sessionId: request.sessionId, - generation: request.generation, - }); + return !!request && request.credentialGeneration === state.credentialGeneration && + request.project === state.selectedProject && request.projectGeneration === state.projectGeneration && + isCurrentWorkflowSessionDetailRequest(state.workflow, { sessionId: request.sessionId, generation: request.generation }); } export function adoptRuntimeWorkflowSessionDetail(state: any, request: any, detail: any): boolean { - if (!isCurrentRuntimeWorkflowSessionRequest(state, request)) { - return false; - } + if (!isCurrentRuntimeWorkflowSessionRequest(state, request)) return false; return adoptWorkflowSessionDetail( state.workflow, { sessionId: request.sessionId, generation: request.generation }, diff --git a/frontend/src/workflow_session_state.ts b/frontend/src/workflow_session_state.ts index 39fcb007..0a3def6d 100644 --- a/frontend/src/workflow_session_state.ts +++ b/frontend/src/workflow_session_state.ts @@ -161,6 +161,47 @@ export function workflowSessionOverviewPresentation(overview: any): any { }; } +function hasPendingAttention(overview: any): boolean { + const attention = overview && typeof overview === "object" ? overview.attention : null; + return ["open_guidance", "open_questions", "open_risks", "open_todos"] + .some((key) => overviewCount(attention && attention[key]) > 0); +} + +function idleAgeLabel(ageSeconds: number): string { + if (ageSeconds < 60) return "<1m"; + const minutes = Math.floor(ageSeconds / 60); + if (minutes < 60) return minutes + "m"; + const hours = Math.floor(minutes / 60); + if (hours < 24) return hours + "h"; + return Math.floor(hours / 24) + "d"; +} + +export function workflowSessionLivenessPresentation(session: any, nowSeconds = Date.now() / 1000): any { + const runningCall = !!session?.running_call; + const runningJobs = typeof session?.running_jobs === "number" ? Math.max(0, session.running_jobs) : 0; + const tooltip = "WebCodex activity only; host/model state is unknown."; + if (runningCall || runningJobs > 0) { + return { state: "working", label: "working", tooltip }; + } + const updatedAt = typeof session?.updated_at === "number" ? session.updated_at : 0; + const ageSeconds = updatedAt > 0 ? Math.max(0, nowSeconds - updatedAt) : Number.POSITIVE_INFINITY; + if (ageSeconds <= 120) { + return { state: "recent", label: "recently active", tooltip }; + } + if (hasPendingAttention(session?.overview)) { + return { state: "attention", label: "idle · pending attention", tooltip }; + } + return { + state: "idle", + label: Number.isFinite(ageSeconds) ? "idle · " + idleAgeLabel(ageSeconds) : "idle", + tooltip, + }; +} + +export function workflowSessionIdleAttentionLabel(runningCall: boolean, overview: any): string { + return workflowSessionLivenessPresentation({ running_call: runningCall, overview, updated_at: 0 }, 0).label; +} + export function initialWorkflowSessionState(): any { return { selectedSessionId: "", diff --git a/frontend/test/build.test.mjs b/frontend/test/build.test.mjs index 8add563d..409ad114 100644 --- a/frontend/test/build.test.mjs +++ b/frontend/test/build.test.mjs @@ -71,7 +71,11 @@ async function assertRequiredAssets(outputDirectory) { const runtimeHtml = await readFile(resolve(outputDirectory, "runtime.html"), "utf8"); assert.match(runtimeHtml, /WebCodex Runtime Console/); assert.match(runtimeHtml, /runtime-device-select/); - assert.match(runtimeHtml, /runtime-project-select/); + assert.match(runtimeHtml, /runtime-project-list/); + assert.equal(runtimeHtml.includes("runtime-project-" + "select"), false); + assert.match(runtimeHtml, /runtime-project-search/); + assert.match(runtimeHtml, /runtime-collaboration-form/); + assert.match(runtimeHtml, /runtime-refresh-status/); assert.match(runtimeHtml, /runtime-token-form/); assert.match(runtimeHtml, /Jump to latest/); assert.match(runtimeHtml, /Reported progress/); @@ -80,6 +84,9 @@ async function assertRequiredAssets(outputDirectory) { assert.match(runtime, /\/api\/runtime-console\//); assert.match(runtime, /runtimeDeviceIds/); assert.match(runtime, /runtimeProjectsForDevice/); + assert.match(runtime, /filterAndSortRuntimeProjects/); + assert.match(runtime, /workflow-session-post-message/); + assert.match(runtime, /Refresh failed · showing previous data/); assert.match(runtime, /preferredRuntimeProjectSelection/); assert.match(runtime, /workflowSessionListOverviewFacts/); assert.match(runtime, /isCurrentRuntimeWorkflowSessionRequest/); diff --git a/frontend/test/runtime_console_state.test.mjs b/frontend/test/runtime_console_state.test.mjs index 6b8ff3bb..ff0512a1 100644 --- a/frontend/test/runtime_console_state.test.mjs +++ b/frontend/test/runtime_console_state.test.mjs @@ -1,19 +1,34 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import { initialRuntimeConsoleState, runtimeDeviceIds, runtimeProjectsForDevice, + filterAndSortRuntimeProjects, preferredRuntimeProjectSelection, beginRuntimeCredential, + refreshRuntimeOverview, + isCurrentRuntimeOverviewRequest, refreshRuntimeProjects, isCurrentRuntimeProjectsRequest, + refreshRuntimeRunner, + isCurrentRuntimeRunnerRequest, selectRuntimeProject, refreshRuntimeSessionList, isCurrentRuntimeSessionListRequest, selectRuntimeWorkflowSession, isCurrentRuntimeWorkflowSessionRequest, adoptRuntimeWorkflowSessionDetail, + runtimeCollaborationRequest, + isCurrentRuntimeCollaborationRequest, + adoptRuntimeCollaborationList, + adoptRuntimeCollaborationObservation, + setRuntimeCollaborationAvailable, + setRuntimeCollaborationPhase, + runtimeCollaborationNeedsRefreshRecovery, + mergeRuntimeCollaborationMessages, + runtimeCollaborationObservationAction, } from "../dist/runtime_console_state.js"; test("runtime credential and project generations fence stale project responses", () => { @@ -41,6 +56,27 @@ test("runtime credential and project generations fence stale project responses", assert.equal(state.workflow.snapshot, null); }); +test("server and Runner requests are fenced across credential and Runner changes", () => { + const state = initialRuntimeConsoleState(); + beginRuntimeCredential(state); + const overviewA = refreshRuntimeOverview(state); + const listA = selectRuntimeProject(state, "runner-a", "agent:runner-a:p"); + const runnerA = refreshRuntimeRunner(state); + assert.equal(isCurrentRuntimeOverviewRequest(state, overviewA), true); + assert.equal(isCurrentRuntimeRunnerRequest(state, runnerA), true); + assert.equal(isCurrentRuntimeSessionListRequest(state, listA), true); + + selectRuntimeProject(state, "runner-b", "agent:runner-b:p"); + const runnerB = refreshRuntimeRunner(state); + assert.equal(isCurrentRuntimeRunnerRequest(state, runnerA), false); + assert.equal(isCurrentRuntimeRunnerRequest(state, runnerB), true); + assert.equal(isCurrentRuntimeSessionListRequest(state, listA), false); + + beginRuntimeCredential(state); + assert.equal(isCurrentRuntimeOverviewRequest(state, overviewA), false); + assert.equal(isCurrentRuntimeRunnerRequest(state, runnerB), false); +}); + test("runtime device and project options use authoritative client ids with stable ordering", () => { const projects = [ { id: "opaque-b", client_id: "device-b", name: "Beta" }, @@ -48,20 +84,10 @@ test("runtime device and project options use authoritative client ids with stabl { id: "opaque-a2", client_id: "device-a", name: "Alpha" }, { id: "opaque-a1", client_id: "device-a", name: "Alpha" }, ]; - assert.deepEqual(runtimeDeviceIds(projects), ["device-a", "device-b"]); - assert.deepEqual( - runtimeProjectsForDevice(projects, "device-a").map((project) => project.id), - ["opaque-a1", "opaque-a2", "agent:not-device-a:project"] - ); - assert.deepEqual( - preferredRuntimeProjectSelection(projects, "device-b", "agent:not-device-a:project"), - { device: "device-a", project: "agent:not-device-a:project" } - ); - assert.deepEqual( - preferredRuntimeProjectSelection(projects, "device-a", "missing-project"), - { device: "device-a", project: "opaque-a1" } - ); + assert.deepEqual(runtimeProjectsForDevice(projects, "device-a").map((project) => project.id), ["opaque-a1", "opaque-a2", "agent:not-device-a:project"]); + assert.deepEqual(preferredRuntimeProjectSelection(projects, "device-b", "agent:not-device-a:project"), { device: "device-a", project: "agent:not-device-a:project" }); + assert.deepEqual(preferredRuntimeProjectSelection(projects, "device-a", "missing-project"), { device: "device-a", project: "opaque-a1" }); }); test("runtime refresh preserves an authorized selected device and project", () => { @@ -70,9 +96,28 @@ test("runtime refresh preserves an authorized selected device and project", () = { id: "project-1", client_id: "device-z", name: "First" }, { id: "project-3", client_id: "device-a", name: "Other" }, ]; + assert.deepEqual(preferredRuntimeProjectSelection(projects, "device-z", "project-2"), { device: "device-z", project: "project-2" }); +}); + +test("Project list filters and prioritizes running attention then recent activity", () => { + const projects = [ + { id: "agent:r:idle", client_id: "runner", name: "Idle", sessions: { running_sessions: 0, attention: {}, latest_updated_at: 100 } }, + { id: "agent:r:recent", client_id: "runner", name: "Recent", sessions: { running_sessions: 0, attention: {}, latest_updated_at: 400 } }, + { id: "agent:r:attention", client_id: "runner", name: "Needs review", sessions: { running_sessions: 0, attention: { open_guidance: 1 }, latest_updated_at: 50 } }, + { id: "agent:r:working", client_id: "runner", name: "Working", sessions: { running_sessions: 1, attention: {}, latest_updated_at: 10 } }, + { id: "agent:other:x", client_id: "other", name: "Other" }, + ]; + assert.deepEqual( + filterAndSortRuntimeProjects(projects, "runner", "").map((project) => project.id), + ["agent:r:working", "agent:r:attention", "agent:r:recent", "agent:r:idle"] + ); + assert.deepEqual( + filterAndSortRuntimeProjects(projects, "runner", "REVIEW").map((project) => project.id), + ["agent:r:attention"] + ); assert.deepEqual( - preferredRuntimeProjectSelection(projects, "device-z", "project-2"), - { device: "device-z", project: "project-2" } + filterAndSortRuntimeProjects(projects, "runner", "agent:r:recent").map((project) => project.id), + ["agent:r:recent"] ); }); @@ -88,7 +133,6 @@ test("runtime workflow detail identity includes project plus session id", () => selectRuntimeProject(state, "device-b", "agent:b:project"); assert.equal(state.workflow.snapshot, null); assert.equal(isCurrentRuntimeWorkflowSessionRequest(state, detailA), false); - const detailB = selectRuntimeWorkflowSession(state, "wc_sess_same"); assert.equal(detailB.project, "agent:b:project"); assert.equal(isCurrentRuntimeWorkflowSessionRequest(state, detailB), true); @@ -96,3 +140,125 @@ test("runtime workflow detail identity includes project plus session id", () => assert.equal(adoptRuntimeWorkflowSessionDetail(state, detailB, { title: "B" }), true); assert.equal(state.workflow.snapshot.title, "B"); }); + +test("session switch invalidates old collaboration responses", () => { + const state = initialRuntimeConsoleState(); + beginRuntimeCredential(state); + selectRuntimeProject(state, "runner", "agent:runner:project"); + selectRuntimeWorkflowSession(state, "wc_sess_a"); + const requestA = runtimeCollaborationRequest(state); + assert.equal(isCurrentRuntimeCollaborationRequest(state, requestA), true); + selectRuntimeWorkflowSession(state, "wc_sess_b"); + const requestB = runtimeCollaborationRequest(state); + assert.equal(isCurrentRuntimeCollaborationRequest(state, requestA), false); + assert.equal(isCurrentRuntimeCollaborationRequest(state, requestB), true); + assert.equal(adoptRuntimeCollaborationList(state, requestA, [{ message_id: "wc_msg_old" }]), false); +}); + +test("collaboration delta replaces message state by id and completion renders todo resolution plus answer", () => { + const state = initialRuntimeConsoleState(); + beginRuntimeCredential(state); + selectRuntimeProject(state, "runner", "agent:runner:project"); + selectRuntimeWorkflowSession(state, "wc_sess_a"); + const request = runtimeCollaborationRequest(state); + adoptRuntimeCollaborationList(state, request, [ + { message_id: "wc_msg_todo", kind: "todo", status: "open", created_at: 1, message: "do work" }, + ]); + assert.equal(adoptRuntimeCollaborationObservation(state, request, { + observation_token: "opaque-1", + messages: [ + { message_id: "wc_msg_todo", kind: "todo", status: "resolved", created_at: 1, message: "do work", resolved_by_message_id: "wc_msg_answer" }, + { message_id: "wc_msg_answer", kind: "answer", status: "open", created_at: 2, message: "done", reply_to: "wc_msg_todo", author_session_id: "wc_sess_worker" }, + ], + }), true); + assert.equal(state.collaboration.messages.length, 2); + assert.equal(state.collaboration.messages[0].status, "resolved"); + assert.equal(state.collaboration.messages[1].reply_to, "wc_msg_todo"); + assert.equal(state.collaboration.observationToken, "opaque-1"); +}); + +test("history loss reloads and has_more drains without duplicate message ids", () => { + assert.equal(runtimeCollaborationObservationAction({ history_lost: true, has_more: true }), "reload"); + assert.equal(runtimeCollaborationObservationAction({ history_lost: false, has_more: true }), "drain"); + assert.equal(runtimeCollaborationObservationAction({ wait_outcome: "timeout" }), "wait"); + const merged = mergeRuntimeCollaborationMessages( + [{ message_id: "a", created_at: 1, status: "open" }], + [{ message_id: "a", created_at: 1, status: "resolved" }, { message_id: "b", created_at: 2 }] + ); + assert.deepEqual(merged.map((message) => message.message_id), ["a", "b"]); + assert.equal(merged[0].status, "resolved"); +}); + +test("project-read-only degradation keeps project selection while collaboration is marked unavailable", () => { + const state = initialRuntimeConsoleState(); + beginRuntimeCredential(state); + selectRuntimeProject(state, "runner", "agent:runner:project"); + selectRuntimeWorkflowSession(state, "wc_sess_a"); + const request = runtimeCollaborationRequest(state); + assert.equal(setRuntimeCollaborationAvailable(state, request, false), true); + assert.equal(state.selectedProject, "agent:runner:project"); + assert.equal(state.workflow.selectedSessionId, "wc_sess_a"); + assert.equal(state.collaboration.available, false); +}); + +test("manual Refresh recovery is required only after collaboration is paused", () => { + const state = initialRuntimeConsoleState(); + beginRuntimeCredential(state); + selectRuntimeProject(state, "runner", "agent:runner:project"); + selectRuntimeWorkflowSession(state, "wc_sess_a"); + const requestA = runtimeCollaborationRequest(state); + assert.equal(setRuntimeCollaborationPhase(state, requestA, "live"), true); + assert.equal(runtimeCollaborationNeedsRefreshRecovery(state), false); + assert.equal(setRuntimeCollaborationPhase(state, requestA, "paused"), true); + assert.equal(runtimeCollaborationNeedsRefreshRecovery(state), true); + selectRuntimeWorkflowSession(state, "wc_sess_b"); + assert.equal(state.collaboration.phase, "idle"); + assert.equal(setRuntimeCollaborationPhase(state, requestA, "paused"), false); + assert.equal(runtimeCollaborationNeedsRefreshRecovery(state), false); +}); + +test("runtime collaboration rendering uses textContent and explicitly reloads on history loss", async () => { + const source = await readFile(new URL("../src/runtime.ts", import.meta.url), "utf8"); + const html = await readFile(new URL("../src/runtime.html", import.meta.url), "utf8"); + const css = await readFile(new URL("../src/runtime.css", import.meta.url), "utf8"); + assert.equal(html.includes("runtime-project-" + "select"), false); + assert.match(html, /runtime-project-list/); + assert.match(html, /runtime-project-search/); + assert.match(html, /runtime-collaboration-form/); + assert.match(html, /runtime-message-requires-ack/); + assert.match(css, /-webkit-line-clamp:\s*4/); + assert.equal(source.includes("innerHTML"), false); + assert.match(source, /body\.textContent = String\(message\?\.message \|\| ""\)/); + assert.match(source, /action === "reload"[\s\S]*loadRetainedCollaboration/); + assert.match(source, /action === "drain"/); + assert.match(source, /abortCollaboration\(\)/); + assert.match(source, /workflow-session-post-message/); + assert.match(source, /kind\?\.value === "guidance"/); + assert.match(source, /priority\?\.value !== "high"/); + assert.match(source, /First ACK observed/); + assert.doesNotMatch(source, /Delivered|Read by model|Currently acknowledged/); + assert.match(source, /Refresh failed · showing previous data/); + assert.match(source, /runtimeCollaborationNeedsRefreshRecovery/); + const fetchProjectsStart = source.indexOf("async function fetchProjects"); + const fetchProjectsEnd = source.indexOf("function effectiveProjects", fetchProjectsStart); + assert.doesNotMatch(source.slice(fetchProjectsStart, fetchProjectsEnd), /fetchOverview\(/); + const selectStart = source.indexOf("function selectSession"); + const selectEnd = source.indexOf("async function fetchSessionDetail", selectStart); + assert.match(source.slice(selectStart, selectEnd), /setHumanJoinSendEnabled\(false\)[\s\S]*startCollaboration/); + const postStart = source.indexOf("async function postHumanCollaborationMessage"); + const postEnd = source.indexOf("function setRefreshBusy", postStart); + const post = source.slice(postStart, postEnd); + assert.match(post, /if \(!isCurrentRuntimeCollaborationRequest\(state, request\)\) return;\s*if \(response\?\.status === 0\)[\s\S]*return;\s*\}[\s\S]*if \(send\) send\.disabled = false;/); + assert.match(post, /Send outcome unknown\. Refresh and review retained messages before retrying\./); + assert.match(post, /abortCollaboration\(\)[\s\S]*setRuntimeCollaborationPhase\(state, request, "paused"\)/); + const refreshStart = source.indexOf("async function refreshAll"); + const refreshEnd = source.indexOf("function startAuto", refreshStart); + assert.equal((source.slice(refreshStart, refreshEnd).match(/fetchOverview\(/g) || []).length, 1); + const bootstrapStart = source.indexOf("async function loadRetainedCollaboration"); + const bootstrapEnd = source.indexOf("async function startCollaboration", bootstrapStart); + const bootstrap = source.slice(bootstrapStart, bootstrapEnd); + const baselineAt = bootstrap.indexOf('api("workflow-session-observe"'); + const retainedListAt = bootstrap.indexOf('api("workflow-session-messages"'); + assert.ok(baselineAt >= 0 && retainedListAt > baselineAt, "live baseline must precede the retained snapshot to avoid a lost-update gap"); + assert.match(bootstrap, /setRuntimeCollaborationPhase\(state, request, "live"\);[\s\S]*setHumanJoinSendEnabled\(true\)/); +}); diff --git a/frontend/test/workflow_session_state.test.mjs b/frontend/test/workflow_session_state.test.mjs index 87e621bd..bec6c6b4 100644 --- a/frontend/test/workflow_session_state.test.mjs +++ b/frontend/test/workflow_session_state.test.mjs @@ -12,6 +12,8 @@ import { shouldFollowWorkflowSessionLatest, workflowSessionListOverviewFacts, workflowSessionOverviewPresentation, + workflowSessionIdleAttentionLabel, + workflowSessionLivenessPresentation, } from "../dist/workflow_session_state.js"; test("workflow session list overview stays compact and labels retained evidence", () => { @@ -76,6 +78,27 @@ test("workflow session detail overview separates runtime validation attention an assert.equal(unavailable.progressText, "No retained model-reported progress."); }); +test("Session liveness stays factual across working recent idle and attention states", () => { + const overview = { attention: { open_guidance: 0, open_questions: 1, open_risks: 0, open_todos: 1 } }; + const workingCall = workflowSessionLivenessPresentation({ running_call: true, running_jobs: 0, updated_at: 100, overview }, 1000); + const workingJob = workflowSessionLivenessPresentation({ running_call: false, running_jobs: 1, updated_at: 100, overview }, 1000); + const recent = workflowSessionLivenessPresentation({ running_call: false, running_jobs: 0, updated_at: 950, overview: { attention: {} } }, 1000); + const attention = workflowSessionLivenessPresentation({ running_call: false, running_jobs: 0, updated_at: 700, overview }, 1000); + const idle = workflowSessionLivenessPresentation({ running_call: false, running_jobs: 0, updated_at: 700, overview: { attention: {} } }, 1000); + assert.equal(workingCall.label, "working"); + assert.equal(workingJob.label, "working"); + assert.equal(recent.label, "recently active"); + assert.equal(attention.label, "idle · pending attention"); + assert.equal(idle.label, "idle · 5m"); + assert.equal(idle.tooltip, "WebCodex activity only; host/model state is unknown."); + for (const view of [workingCall, workingJob, recent, attention, idle]) { + assert.equal(/stalled|abandoned|model failed|host frozen/i.test(view.label), false); + } + assert.equal(workflowSessionIdleAttentionLabel(false, overview), "idle · pending attention"); + assert.equal(workflowSessionIdleAttentionLabel(true, overview), "working"); + assert.equal(workflowSessionIdleAttentionLabel(false, { attention: {} }), "idle"); +}); + test("stale same-session detail response cannot overwrite newer snapshot", () => { const state = initialWorkflowSessionState(); const older = selectWorkflowSession(state, "wc_sess_same"); diff --git a/src/auth/scopes.rs b/src/auth/scopes.rs index f81eed12..2e38e029 100644 --- a/src/auth/scopes.rs +++ b/src/auth/scopes.rs @@ -244,6 +244,13 @@ pub(crate) fn oauth_route_scope_policy_for_path_method( OAuthRouteScopePolicy::Require(SCOPE_JOB_RUN) } + ("POST", "/api/runtime-console/overview") + | ("POST", "/api/runtime-console/runner") + | ("POST", "/api/runtime-console/workflow-session-messages") + | ("POST", "/api/runtime-console/workflow-session-observe") + | ("POST", "/api/runtime-console/workflow-session-post-message") => { + OAuthRouteScopePolicy::Require(SCOPE_RUNTIME_READ) + } ("POST", "/api/runtime-console/projects") | ("POST", "/api/runtime-console/workflow-sessions") | ("POST", "/api/runtime-console/workflow-session") @@ -548,6 +555,23 @@ mod tests { for (method, path, scope) in [ ("GET", "/mcp", SCOPE_RUNTIME_READ), ("POST", "/api/runtime/status", SCOPE_RUNTIME_READ), + ("POST", "/api/runtime-console/overview", SCOPE_RUNTIME_READ), + ("POST", "/api/runtime-console/runner", SCOPE_RUNTIME_READ), + ( + "POST", + "/api/runtime-console/workflow-session-messages", + SCOPE_RUNTIME_READ, + ), + ( + "POST", + "/api/runtime-console/workflow-session-observe", + SCOPE_RUNTIME_READ, + ), + ( + "POST", + "/api/runtime-console/workflow-session-post-message", + SCOPE_RUNTIME_READ, + ), ("POST", "/api/tools/list", SCOPE_RUNTIME_READ), ("POST", "/api/connector/task/start", SCOPE_RUNTIME_READ), ("POST", "/api/connector/files/read", SCOPE_PROJECT_READ), @@ -596,11 +620,20 @@ mod tests { let shared = crate::auth::shared_key_context("scope-matrix"); let bootstrap = crate::auth::bootstrap_context(); - for (label, auth) in [("pat", &pat), ("oauth", &oauth), ("shared", &shared)] { - assert!( - enforce_route_scope(auth, "POST", "/api/runtime/status").is_ok(), - "{label} should honor runtime:read" - ); + for path in [ + "/api/runtime/status", + "/api/runtime-console/overview", + "/api/runtime-console/runner", + "/api/runtime-console/workflow-session-messages", + "/api/runtime-console/workflow-session-observe", + "/api/runtime-console/workflow-session-post-message", + ] { + for (label, auth) in [("pat", &pat), ("oauth", &oauth), ("shared", &shared)] { + assert!( + enforce_route_scope(auth, "POST", path).is_ok(), + "{label} should honor runtime:read on {path}" + ); + } } for (label, auth) in [("pat", &pat), ("oauth", &oauth)] { assert_eq!( diff --git a/src/mcp.rs b/src/mcp.rs index d7d55e54..cd4ce571 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -519,6 +519,18 @@ fn add_stateless_workflow_recorder_metadata(payload: &mut Value, model_surface: "description": "MCP wrapper metadata only. Optional explicit existing Workflow Session that records this tools/call and supplies trusted collaboration provenance. It is distinct from any concrete tool business session_id, grants no authority, and is removed before concrete tool parsing." }), ); + properties.insert( + crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD.to_string(), + json!({ + "type": "array", + "maxItems": crate::tool_runtime::sessions::MAX_TOOL_CALL_ACK_MESSAGE_IDS, + "items": { + "type": "string", + "pattern": "^wc_msg_[A-Za-z0-9_]+$" + }, + "description": "MCP wrapper metadata only. ACK means the current model context still remembers the referenced open Session message. Repeat ACK ids on subsequent calls while remembered. If omitted later, unresolved ACK-required guidance may be returned again. ACK does not resolve the message." + }), + ); } } @@ -3057,6 +3069,39 @@ async fn handle_mcp_request_with_lifecycle( return McpOutcome::BadRequest(rpc_error(id, -32602, message)); } }; + let ack_session_message_ids = if stateless_2026 { + match strip_stateless_ack_session_message_ids(&mut params.arguments) { + Ok(ids) => ids, + Err(message) => { + if let Some(lc) = lifecycle.as_deref() { + lc.dispatch_failed("invalid_arguments"); + lc.dispatch_finished(false, Some(false), "invalid_arguments"); + } + if let (Some(slot), Some(timer)) = ( + model_ergonomics_out.as_deref_mut(), + pre_kernel_model_ergonomics.take(), + ) { + *slot = Some( + timer + .finish() + .record_for_pre_result_failure("invalid_arguments"), + ); + } + return McpOutcome::BadRequest(rpc_error(id, -32602, message)); + } + } + } else { + Vec::new() + }; + if !ack_session_message_ids.is_empty() { + if let Some(arguments) = params.arguments.as_object_mut() { + arguments.insert( + crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD + .to_string(), + json!(ack_session_message_ids), + ); + } + } let as_image_requested = params.name == "read_project_artifact" && params.arguments.get("as_image").and_then(Value::as_bool) == Some(true); let outcome = runtime @@ -3468,6 +3513,58 @@ fn strip_reserved_session_id(arguments: &mut Value) -> Result, St Ok(canonical.or(legacy)) } +fn strip_stateless_ack_session_message_ids(arguments: &mut Value) -> Result, String> { + let Some(object) = arguments.as_object_mut() else { + return Ok(Vec::new()); + }; + let Some(value) = + object.remove(crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD) + else { + return Ok(Vec::new()); + }; + let Value::Array(values) = value else { + return Err(format!( + "field '{}' must be an array of wc_msg_* ids", + crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD + )); + }; + if values.len() > crate::tool_runtime::sessions::MAX_TOOL_CALL_ACK_MESSAGE_IDS { + return Err(format!( + "field '{}' accepts at most {} message ids", + crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD, + crate::tool_runtime::sessions::MAX_TOOL_CALL_ACK_MESSAGE_IDS + )); + } + let mut normalized = Vec::with_capacity(values.len()); + let mut seen = std::collections::HashSet::new(); + for value in values { + let Value::String(value) = value else { + return Err(format!( + "field '{}' must contain only wc_msg_* strings", + crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD + )); + }; + let value = value.trim(); + let valid = value.strip_prefix("wc_msg_").is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .as_bytes() + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') + }); + if !valid { + return Err(format!( + "field '{}' must contain only valid wc_msg_* ids", + crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD + )); + } + if seen.insert(value.to_string()) { + normalized.push(value.to_string()); + } + } + Ok(normalized) +} + fn scope_forbidden( auth: Option<&AuthContext>, required_scope: Option<&'static str>, diff --git a/src/mcp_tests/http_transport.rs b/src/mcp_tests/http_transport.rs index e95a82b6..3f9397cb 100644 --- a/src/mcp_tests/http_transport.rs +++ b/src/mcp_tests/http_transport.rs @@ -589,6 +589,148 @@ async fn http_mcp_accepts_chatgpt_2025_11_25_protocol_header() { assert!(body["result"].get("resultType").is_none()); } +#[tokio::test] +async fn http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution() { + let config = test_config(Some("secret")); + let (_tmp, db) = test_db(); + let runtime = Arc::new(test_runtime_with_surface(ModelSurface::FullOperatorRuntime)); + let service = Service::new(build_test_router(config, db, runtime.clone())); + + let (status, session_body) = stateless_2026_tool_call( + &service, + "secret", + 220, + "start_session", + json!({"title": "ACK dogfood"}), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{session_body}"); + let session_id = stateless_tool_output(&session_body)["session_id"] + .as_str() + .unwrap() + .to_string(); + + let (status, post_body) = stateless_2026_tool_call( + &service, + "secret", + 221, + "post_session_message", + json!({ + "session_id": session_id, + "kind": "guidance", + "priority": "high", + "requires_ack": true, + "message": "Keep the exact request-scoped ACK contract." + }), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{post_body}"); + let message_id = stateless_tool_output(&post_body)["message_id"] + .as_str() + .unwrap() + .to_string(); + + let first_args = with_mcp_recording_session(json!({}), &session_id); + let (status, first_body) = + stateless_2026_tool_call(&service, "secret", 222, "list_tools", first_args, None).await; + assert_eq!(status, StatusCode::OK, "{first_body}"); + let first = stateless_tool_output(&first_body); + assert_eq!( + first["session_attention"]["messages"][0]["message_id"], + message_id + ); + assert_eq!( + first["session_attention"]["messages"][0]["message"], + "Keep the exact request-scoped ACK contract." + ); + + let mut ack_args = with_mcp_recording_session(json!({}), &session_id); + ack_args.as_object_mut().unwrap().insert( + crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD.to_string(), + json!([message_id, message_id]), + ); + let (status, ack_body) = + stateless_2026_tool_call(&service, "secret", 223, "list_tools", ack_args, None).await; + assert_eq!(status, StatusCode::OK, "{ack_body}"); + let acknowledged = stateless_tool_output(&ack_body); + assert_eq!( + acknowledged["session_attention"]["ack"]["accepted_count"], + 1 + ); + assert_eq!(acknowledged["session_attention"]["ack"]["ignored_count"], 0); + assert!(acknowledged["session_attention"]["messages"] + .as_array() + .unwrap() + .is_empty()); + let retained = runtime + .sessions + .list_messages( + &session_id, + crate::tool_runtime::sessions::ListSessionMessagesFilter { + message_id: Some(message_id.clone()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + retained[0].status, + crate::tool_runtime::sessions::SessionMessageStatus::Open + ); + assert!(retained[0].first_ack_observed_at.is_some()); + + let forgotten_args = with_mcp_recording_session(json!({}), &session_id); + let (status, forgotten_body) = + stateless_2026_tool_call(&service, "secret", 224, "list_tools", forgotten_args, None).await; + assert_eq!(status, StatusCode::OK, "{forgotten_body}"); + assert_eq!( + stateless_tool_output(&forgotten_body)["session_attention"]["messages"][0]["message_id"], + message_id + ); + + let (status, resolve_body) = stateless_2026_tool_call( + &service, + "secret", + 225, + "resolve_session_message", + json!({"session_id": session_id, "message_id": message_id, "resolution": "handled"}), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{resolve_body}"); + assert_eq!( + stateless_tool_output(&resolve_body)["message"]["status"], + "resolved" + ); + + let after_resolve_args = with_mcp_recording_session(json!({}), &session_id); + let (status, after_resolve_body) = stateless_2026_tool_call( + &service, + "secret", + 226, + "list_tools", + after_resolve_args, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{after_resolve_body}"); + assert!(stateless_tool_output(&after_resolve_body) + .get("session_attention") + .is_none()); + + let audit = serde_json::to_string( + &runtime + .sessions + .summary(&session_id, Some(100)) + .unwrap() + .events, + ) + .unwrap(); + assert!(!audit.contains("ack_session_message_ids")); + assert!(!audit.contains("__webcodex_stateless_ack_session_message_ids")); +} + #[tokio::test] async fn http_mcp_2026_collaboration_completion_preserves_explicit_recorder_provenance() { let config = test_config(Some("secret")); diff --git a/src/mcp_tests/protocol.rs b/src/mcp_tests/protocol.rs index df78c532..49b48bac 100644 --- a/src/mcp_tests/protocol.rs +++ b/src/mcp_tests/protocol.rs @@ -195,6 +195,19 @@ async fn mcp_stateless_tools_list_uses_2026_result_shape() { value["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "webcodex" ); + let read_files = value["result"]["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "read_files") + .expect("read_files stateless schema"); + let ack = &read_files["inputSchema"]["properties"]["ack_session_message_ids"]; + assert_eq!(ack["type"], "array"); + assert_eq!(ack["maxItems"], 8); + assert_eq!(ack["items"]["pattern"], "^wc_msg_[A-Za-z0-9_]+$"); + let description = ack["description"].as_str().unwrap(); + assert!(description.contains("current model context still remembers")); + assert!(description.contains("ACK does not resolve")); } other => panic!("expected Ok for stateless tools/list, got {:?}", other), } @@ -215,6 +228,13 @@ async fn mcp_legacy_tools_list_omits_2026_only_result_fields() { assert!(value["result"].get("resultType").is_none()); assert!(value["result"].get("ttlMs").is_none()); assert!(value["result"].get("cacheScope").is_none()); + assert!(value["result"]["tools"] + .as_array() + .unwrap() + .iter() + .all(|tool| tool["inputSchema"]["properties"] + .get("ack_session_message_ids") + .is_none())); } other => panic!("expected Ok for legacy tools/list, got {:?}", other), } diff --git a/src/mcp_tests/tools.rs b/src/mcp_tests/tools.rs index 72bb36e9..a608cf9d 100644 --- a/src/mcp_tests/tools.rs +++ b/src/mcp_tests/tools.rs @@ -113,6 +113,19 @@ async fn mcp_tools_list_returns_same_names_as_runtime() { let description = recorder["description"].as_str().unwrap(); assert!(description.contains("wrapper metadata")); assert!(description.contains("distinct from any concrete tool business session_id")); + let ack = properties + .get(crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD) + .unwrap_or_else(|| panic!("stateless ACK metadata missing for {}", tool["name"])); + assert_eq!(ack["type"], "array"); + assert_eq!( + ack["maxItems"], + crate::tool_runtime::sessions::MAX_TOOL_CALL_ACK_MESSAGE_IDS + ); + assert_eq!(ack["items"]["pattern"], "^wc_msg_[A-Za-z0-9_]+$"); + let ack_description = ack["description"].as_str().unwrap(); + assert!(ack_description.contains("current model context still remembers")); + assert!(ack_description.contains("If omitted later")); + assert!(ack_description.contains("does not resolve")); assert!(!properties.contains_key(MCP_RESERVED_SESSION_ID_FIELD)); } // Exercise the real env adapter, not just the pure renderer: compact @@ -150,20 +163,65 @@ fn stateless_workflow_recorder_metadata_does_not_expand_connector_or_generic_too mcp_tools_list_payload_with_compact(ModelSurface::CanonicalConnector, false); add_stateless_workflow_recorder_metadata(&mut connector, ModelSurface::CanonicalConnector); for tool in connector["tools"].as_array().unwrap() { - assert!(!tool["inputSchema"]["properties"] - .as_object() - .unwrap() + let properties = tool["inputSchema"]["properties"].as_object().unwrap(); + assert!(!properties .contains_key(crate::tool_runtime::sessions::TOOL_CALL_RECORDING_SESSION_ID_FIELD)); + assert!(!properties + .contains_key(crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD)); } let generic = registered_tool_specs() .into_iter() .find(|tool| tool.name == "complete_session_message") .expect("generic complete_session_message spec"); - assert!(!generic.input_schema["properties"] - .as_object() - .unwrap() + let generic_properties = generic.input_schema["properties"].as_object().unwrap(); + assert!(!generic_properties .contains_key(crate::tool_runtime::sessions::TOOL_CALL_RECORDING_SESSION_ID_FIELD)); + assert!(!generic_properties + .contains_key(crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD)); +} + +#[test] +fn stateless_ack_wrapper_normalizes_and_is_removed_before_concrete_tool_parsing() { + let mut arguments = json!({ + crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD: [ + "wc_msg_beta", + "wc_msg_beta", + "wc_msg_alpha" + ] + }); + let normalized = strip_stateless_ack_session_message_ids(&mut arguments).unwrap(); + assert_eq!(normalized, vec!["wc_msg_beta", "wc_msg_alpha"]); + assert!(arguments + .get(crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD) + .is_none()); + + arguments[crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD] = + json!(normalized); + let recorder = + crate::tool_runtime::sessions::ToolCallRecorderMetadata::from_arguments(&arguments); + assert_eq!( + recorder.ack_session_message_ids, + vec!["wc_msg_beta", "wc_msg_alpha"] + ); + let concrete = crate::tool_runtime::sessions::strip_tool_call_expectation_metadata(arguments); + assert!(concrete + .get(crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD) + .is_none()); + crate::tool_runtime::ToolCall::from_tool_name("list_tools", concrete) + .expect("wrapper ACK metadata must be gone before concrete parsing"); + + let mut malformed = json!({ + crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD: ["not-a-message-id"] + }); + assert!(strip_stateless_ack_session_message_ids(&mut malformed).is_err()); + let mut oversized = json!({ + crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD: + (0..=crate::tool_runtime::sessions::MAX_TOOL_CALL_ACK_MESSAGE_IDS) + .map(|index| format!("wc_msg_{index}")) + .collect::>() + }); + assert!(strip_stateless_ack_session_message_ids(&mut oversized).is_err()); } #[test] @@ -949,6 +1007,86 @@ async fn mcp_tools_call_strips_reserved_session_id_before_dispatch() { ); } +#[tokio::test] +async fn stateless_mcp_ack_wrapper_is_removed_before_concrete_dispatch_and_is_request_scoped() { + let runtime = test_runtime(); + let session = runtime + .sessions + .start_session(None, Some("stateless ack wrapper".to_string())); + let guidance = runtime + .sessions + .post_message_with_ack( + crate::tool_runtime::sessions::PostSessionMessageInput { + session_id: session.session_id.clone(), + kind: crate::tool_runtime::sessions::SessionMessageKind::Guidance, + message: "Remember this guidance for the current context.".to_string(), + tags: Vec::new(), + reply_to: None, + priority: crate::tool_runtime::sessions::SessionMessagePriority::High, + }, + true, + ) + .unwrap(); + + let call = |ack: Option<&str>, id: i64| { + let mut arguments = json!({ + crate::tool_runtime::sessions::TOOL_CALL_RECORDING_SESSION_ID_FIELD: &session.session_id + }); + if let Some(message_id) = ack { + arguments[crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD] = + json!([message_id, message_id]); + } + rpc( + "tools/call", + Some(Value::from(id)), + mcp_2026_params(json!({"name": "list_projects", "arguments": arguments})), + ) + }; + + let acknowledged = + handle_mcp_request(&runtime, call(Some(&guidance.message_id), 321), None).await; + let acknowledged = match acknowledged { + McpOutcome::Ok(value) => value, + other => panic!("expected ACK call success, got {other:?}"), + }; + assert_eq!(acknowledged["result"]["structuredContent"]["success"], true); + assert_eq!( + acknowledged["result"]["structuredContent"]["output"]["session_attention"]["ack"] + ["accepted_count"], + 1 + ); + assert!( + acknowledged["result"]["structuredContent"]["output"]["session_attention"]["messages"] + .as_array() + .unwrap() + .is_empty() + ); + + let forgotten = handle_mcp_request(&runtime, call(None, 322), None).await; + let forgotten = match forgotten { + McpOutcome::Ok(value) => value, + other => panic!("expected forgotten-ACK call success, got {other:?}"), + }; + assert_eq!(forgotten["result"]["structuredContent"]["success"], true); + assert_eq!( + forgotten["result"]["structuredContent"]["output"]["session_attention"]["messages"][0] + ["message_id"], + guidance.message_id + ); + let summary = runtime + .sessions + .summary(&session.session_id, Some(20)) + .unwrap(); + let started = summary + .events + .iter() + .find(|event| event.kind == "tool_call_started") + .unwrap(); + let input = serde_json::to_string(&started.input_summary).unwrap(); + assert!(!input.contains("ack_session_message_ids")); + assert!(!input.contains("__webcodex_stateless_ack_session_message_ids")); +} + #[tokio::test] async fn mcp_tools_call_rejects_conflicting_recorder_metadata_before_dispatch() { let runtime = test_runtime(); diff --git a/src/openapi.rs b/src/openapi.rs index 57bef966..6ead43b4 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -215,9 +215,14 @@ const LEGACY_FORBIDDEN_PATHS: &[&str] = &[ "/openapi.json", // Browser console shells and their browser-only Runtime Console API are // intentionally NOT GPT Actions and must never appear in /openapi.json. + "/api/runtime-console/overview", + "/api/runtime-console/runner", "/api/runtime-console/projects", "/api/runtime-console/workflow-sessions", "/api/runtime-console/workflow-session", + "/api/runtime-console/workflow-session-messages", + "/api/runtime-console/workflow-session-observe", + "/api/runtime-console/workflow-session-post-message", "/runtime", "/runtime/app.js", "/runtime/styles.css", diff --git a/src/runtime_console_http.rs b/src/runtime_console_http.rs index 238d6ad8..2a6aa7cc 100644 --- a/src/runtime_console_http.rs +++ b/src/runtime_console_http.rs @@ -4,12 +4,16 @@ //! runtime project authorization and the existing Workflow Session console //! projection without creating a second store, parser, or observation authority. -use crate::auth::AuthContext; -use crate::tool_runtime::sessions::is_valid_session_id; +use crate::auth::{AuthContext, SCOPE_PROJECT_READ, SCOPE_RUNTIME_READ}; +use crate::tool_runtime::sessions::{ + aggregate_console_list, is_valid_session_id, SessionMessageKind, SessionMessagePriority, + WorkflowSessionConsoleAggregate, WorkflowSessionConsoleList, +}; use crate::tool_runtime::{ToolCall, ToolRuntime}; use salvo::prelude::*; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashMap; use std::sync::Arc; const DEFAULT_PROJECT_LIMIT: usize = 50; @@ -18,12 +22,25 @@ const MAX_PROJECT_ID_CHARS: usize = 512; const MAX_PROJECT_NAME_CHARS: usize = 160; const MAX_CLIENT_ID_CHARS: usize = 160; const MAX_STATUS_CHARS: usize = 64; +const DEFAULT_RUNNER_PROJECT_LIMIT: usize = 24; +const MAX_RUNNER_PROJECT_LIMIT: usize = 32; +const CONSOLE_AGGREGATE_SESSION_LIMIT: usize = 50; +const DEFAULT_MESSAGE_LIMIT: usize = 100; +const MAX_MESSAGE_LIMIT: usize = 100; +const MAX_OBSERVATION_TOKEN_CHARS: usize = 192; pub(crate) fn routes() -> Router { Router::with_path("runtime-console") + .push(Router::with_path("overview").post(overview)) + .push(Router::with_path("runner").post(runner)) .push(Router::with_path("projects").post(projects)) .push(Router::with_path("workflow-sessions").post(workflow_sessions)) .push(Router::with_path("workflow-session").post(workflow_session)) + .push(Router::with_path("workflow-session-messages").post(workflow_session_messages)) + .push(Router::with_path("workflow-session-observe").post(workflow_session_observe)) + .push( + Router::with_path("workflow-session-post-message").post(workflow_session_post_message), + ) } #[derive(Debug, Deserialize)] @@ -50,9 +67,163 @@ struct WorkflowSessionInput { limit: Option, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct OverviewInput {} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RunnerInput { + client_id: String, + #[serde(default)] + project_limit: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkflowSessionMessagesInput { + project: String, + session_id: String, + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkflowSessionObserveInput { + project: String, + session_id: String, + #[serde(default)] + after_observation_token: Option, + #[serde(default)] + wait_secs: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkflowSessionPostMessageInput { + project: String, + session_id: String, + kind: SessionMessageKind, + #[serde(default)] + priority: SessionMessagePriority, + message: String, + #[serde(default)] + reply_to: Option, + #[serde(default)] + requires_ack: bool, +} + +#[derive(Debug, Serialize)] +struct RuntimeConsoleOverview { + service: Option, + version: Option, + build_git_commit: Option, + build_git_dirty: Option, + runner_count: usize, + runners_online: usize, + runners_stale: usize, + runners_unavailable: usize, + source_mismatched_runners: usize, + mixed_builds_present: bool, + active_jobs: usize, + projects_available: bool, + visible_projects: usize, + projects_truncated: bool, + workflow_sessions: RuntimeConsoleWorkflowAggregate, +} + +#[derive(Debug, Default, Serialize)] +struct RuntimeConsoleWorkflowAggregate { + active: usize, + running: usize, + open_guidance: usize, + open_questions: usize, + open_risks: usize, + open_todos: usize, + projects_scanned: usize, + projects_total: usize, + truncated: bool, +} + +#[derive(Debug, Serialize)] +struct RuntimeConsoleRunner { + client_id: String, + connected: bool, + status: Option, + version: Option, + build_git_commit: Option, + build_git_dirty: Option, + source_alignment: Option, + active_jobs: usize, + job_concurrency_limit: Option, + jobs_running: usize, + jobs_queued: usize, + projects_available: bool, + visible_project_count: usize, + projects_returned: usize, + projects_truncated: bool, + projects: Vec, +} + +#[derive(Debug, Serialize)] +struct RuntimeConsoleRunnerProject { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + agent_status: Option, + sessions: WorkflowSessionConsoleAggregate, +} + +#[derive(Debug, Serialize)] +struct RuntimeConsoleMessages { + session_id: String, + messages: Vec, +} + +#[derive(Debug, Serialize)] +struct RuntimeConsoleObservation { + session_id: String, + messages: Vec, + observation_token: String, + changed: bool, + wait_outcome: String, + waited_ms: u64, + history_lost: bool, + has_more: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct RuntimeConsoleMessage { + message_id: String, + kind: String, + status: String, + priority: String, + created_at: i64, + message: String, + requires_ack: bool, + #[serde(skip_serializing_if = "Option::is_none")] + first_ack_observed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + author_session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resolved_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resolution: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resolved_by_message_id: Option, +} + #[derive(Debug, Serialize)] struct RuntimeConsoleProjects { projects: Vec, + total: usize, truncated: bool, } @@ -145,17 +316,151 @@ async fn prepared( Ok((runtime, auth)) } -async fn visible_project_values( +fn require_runtime_read(auth: &AuthContext) -> Result<(), RuntimeConsoleError> { + if auth.has_scope(SCOPE_RUNTIME_READ) { + Ok(()) + } else { + Err(RuntimeConsoleError::Request { + status: 403, + message: "Runtime read access required", + }) + } +} + +fn require_project_read(auth: &AuthContext) -> Result<(), RuntimeConsoleError> { + if project_read_available(auth) { + Ok(()) + } else { + Err(RuntimeConsoleError::Request { + status: 403, + message: "Project read access required", + }) + } +} + +fn project_read_available(auth: &AuthContext) -> bool { + auth.has_scope(SCOPE_PROJECT_READ) +} + +fn safe_usize(value: Option<&Value>) -> usize { + value + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0) +} + +fn safe_bool(value: Option<&Value>) -> bool { + value.and_then(Value::as_bool).unwrap_or(false) +} + +fn safe_string(value: Option<&Value>, max_chars: usize) -> Option { + value.and_then(|value| bounded_text(value, max_chars)) +} + +fn message_from_value(value: &Value) -> Option { + let message_id = safe_string(value.get("message_id"), 160)?; + let kind = safe_string(value.get("kind"), 32)?; + let status = safe_string(value.get("status"), 32)?; + let priority = safe_string(value.get("priority"), 32)?; + let created_at = value.get("created_at")?.as_i64()?; + let message = value.get("message")?.as_str()?.to_string(); + Some(RuntimeConsoleMessage { + message_id, + kind, + status, + priority, + created_at, + message, + requires_ack: safe_bool(value.get("requires_ack")), + first_ack_observed_at: value.get("first_ack_observed_at").and_then(Value::as_i64), + author_session_id: safe_string(value.get("author_session_id"), 160), + reply_to: safe_string(value.get("reply_to"), 160), + resolved_at: value.get("resolved_at").and_then(Value::as_i64), + resolution: value + .get("resolution") + .and_then(Value::as_str) + .map(str::to_string), + resolved_by_message_id: safe_string(value.get("resolved_by_message_id"), 160), + }) +} + +fn messages_from_result( + result: &crate::tool_runtime::ToolResult, +) -> Result, RuntimeConsoleError> { + if !result.success { + return Err(RuntimeConsoleError::NotFound); + } + let values = result + .output + .get("messages") + .and_then(Value::as_array) + .ok_or(RuntimeConsoleError::Internal)?; + if values.len() > MAX_MESSAGE_LIMIT { + return Err(RuntimeConsoleError::Internal); + } + values + .iter() + .map(|value| message_from_value(value).ok_or(RuntimeConsoleError::Internal)) + .collect() +} + +async fn authorize_runtime_session_project( + runtime: &ToolRuntime, + auth: &AuthContext, + project: &str, + session_id: &str, + tool_name: &str, +) -> Result<(), RuntimeConsoleError> { + if !valid_project_id(project) || !is_valid_session_id(session_id) { + return Err(RuntimeConsoleError::Invalid); + } + let resolved = runtime + .authorize_session_target(session_id, tool_name, Some(auth)) + .await + .map_err(|_| RuntimeConsoleError::NotFound)?; + if resolved.as_ref().map(|value| value.resolved_id.as_str()) == Some(project) { + Ok(()) + } else { + Err(RuntimeConsoleError::NotFound) + } +} + +fn add_console_aggregate( + target: &mut RuntimeConsoleWorkflowAggregate, + aggregate: &WorkflowSessionConsoleAggregate, +) { + target.active = target.active.saturating_add(aggregate.active_sessions); + target.running = target.running.saturating_add(aggregate.running_sessions); + target.open_guidance = target + .open_guidance + .saturating_add(aggregate.attention.open_guidance); + target.open_questions = target + .open_questions + .saturating_add(aggregate.attention.open_questions); + target.open_risks = target + .open_risks + .saturating_add(aggregate.attention.open_risks); + target.open_todos = target + .open_todos + .saturating_add(aggregate.attention.open_todos); + target.truncated |= aggregate.sessions_truncated; +} + +async fn listed_projects_for_auth( runtime: &ToolRuntime, auth: &AuthContext, -) -> Result, RuntimeConsoleError> { + client_id: Option, + project: Option, + limit: usize, +) -> Result<(Vec, usize, bool), RuntimeConsoleError> { + require_project_read(auth)?; let result = runtime .dispatch_with_auth( ToolCall::ListProjects { - client_id: None, - project: None, + client_id, + project, query: None, - limit: None, + limit: Some(limit), summary_only: false, }, Some(auth), @@ -164,12 +469,25 @@ async fn visible_project_values( if !result.success { return Err(RuntimeConsoleError::Internal); } - result + let values = result .output .get("projects") .and_then(Value::as_array) .cloned() - .ok_or(RuntimeConsoleError::Internal) + .ok_or(RuntimeConsoleError::Internal)?; + let total = safe_usize( + result + .output + .get("matched_count") + .or_else(|| result.output.get("count")), + ) + .max(values.len()); + let truncated = result + .output + .get("truncated") + .and_then(Value::as_bool) + .unwrap_or(total > values.len()); + Ok((values, total, truncated)) } fn project_selector_row(value: &Value) -> Option { @@ -199,18 +517,28 @@ async fn projects_for_auth( auth: &AuthContext, limit: Option, ) -> Result { - let visible = visible_project_values(runtime, auth).await?; let limit = limit .unwrap_or(DEFAULT_PROJECT_LIMIT) .clamp(1, MAX_PROJECT_LIMIT); + projects_for_client_auth(runtime, auth, None, limit).await +} + +async fn projects_for_client_auth( + runtime: &ToolRuntime, + auth: &AuthContext, + client_id: Option<&str>, + limit: usize, +) -> Result { + let (visible, total, source_truncated) = + listed_projects_for_auth(runtime, auth, client_id.map(str::to_string), None, limit).await?; let project_rows = visible .iter() .filter_map(project_selector_row) - .take(limit) .collect::>(); - let truncated = visible.len() > project_rows.len(); + let truncated = source_truncated || project_rows.len() < total; Ok(RuntimeConsoleProjects { projects: project_rows, + total, truncated, }) } @@ -223,7 +551,8 @@ async fn authorize_exact_project( if !valid_project_id(project) { return Err(RuntimeConsoleError::Invalid); } - let visible = visible_project_values(runtime, auth).await?; + let (visible, _, _) = + listed_projects_for_auth(runtime, auth, None, Some(project.to_string()), 1).await?; if visible .iter() .any(|value| value.get("id").and_then(Value::as_str) == Some(project)) @@ -234,14 +563,103 @@ async fn authorize_exact_project( } } +#[derive(Debug, Default)] +struct RunningJobSnapshot { + counts: HashMap<(String, String), usize>, + truncated: bool, +} + +impl RunningJobSnapshot { + fn count(&self, project: &str, session_id: &str) -> usize { + self.counts + .get(&(project.to_string(), session_id.to_string())) + .copied() + .unwrap_or(0) + } +} + +async fn running_jobs_for_auth( + runtime: &ToolRuntime, + auth: &AuthContext, + project: Option<&str>, +) -> Result { + if !auth.has_scope(SCOPE_RUNTIME_READ) { + return Ok(RunningJobSnapshot::default()); + } + let result = runtime + .list_jobs_for_auth_with_filters( + Some(100), + Some("running".to_string()), + project.map(str::to_string), + None, + Some(auth), + ) + .await; + if !result.success { + return Err(RuntimeConsoleError::Internal); + } + let mut snapshot = RunningJobSnapshot { + truncated: safe_bool(result.output.get("truncated")), + ..Default::default() + }; + for job in result + .output + .get("jobs") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(project) = safe_string(job.get("project"), MAX_PROJECT_ID_CHARS) else { + continue; + }; + let Some(session_id) = safe_string(job.get("session_id"), 160) else { + continue; + }; + if !valid_project_id(&project) || !is_valid_session_id(&session_id) { + continue; + } + snapshot + .counts + .entry((project, session_id)) + .and_modify(|count| *count = count.saturating_add(1)) + .or_insert(1); + } + Ok(snapshot) +} + +fn apply_running_jobs_to_list( + list: &mut WorkflowSessionConsoleList, + project: &str, + jobs: &RunningJobSnapshot, +) { + for session in &mut list.sessions { + session.running_jobs = jobs.count(project, &session.session_id); + session.running_jobs_complete = !jobs.truncated; + } +} + async fn workflow_sessions_for_auth( runtime: &ToolRuntime, auth: &AuthContext, project: &str, limit: Option, -) -> Result { +) -> Result { authorize_exact_project(runtime, auth, project).await?; - Ok(runtime.workflow_sessions_console_list(project, limit)) + let mut list = runtime.workflow_sessions_console_list(project, limit); + if auth.has_scope(SCOPE_RUNTIME_READ) { + let session_ids = list + .sessions + .iter() + .map(|session| session.session_id.clone()) + .collect::>(); + runtime + .materialize_validation_job_terminals_for_sessions(project, &session_ids, Some(auth)) + .await; + list = runtime.workflow_sessions_console_list(project, limit); + let jobs = running_jobs_for_auth(runtime, auth, Some(project)).await?; + apply_running_jobs_to_list(&mut list, project, &jobs); + } + Ok(list) } async fn workflow_session_for_auth( @@ -254,63 +672,515 @@ async fn workflow_session_for_auth( if !is_valid_session_id(session_id) { return Err(RuntimeConsoleError::Invalid); } - authorize_exact_project(runtime, auth, project).await?; - runtime - .workflow_session_console_detail(project, session_id, limit) - .ok_or(RuntimeConsoleError::NotFound) + authorize_exact_project(runtime, auth, project).await?; + if auth.has_scope(SCOPE_RUNTIME_READ) { + runtime + .materialize_validation_job_terminals_for_sessions( + project, + &[session_id.to_string()], + Some(auth), + ) + .await; + } + let mut detail = runtime + .workflow_session_console_detail(project, session_id, limit) + .ok_or(RuntimeConsoleError::NotFound)?; + if auth.has_scope(SCOPE_RUNTIME_READ) { + let jobs = running_jobs_for_auth(runtime, auth, Some(project)).await?; + detail.running_jobs = jobs.count(project, session_id); + detail.running_jobs_complete = !jobs.truncated; + } + Ok(detail) +} + +async fn runtime_status_value( + runtime: &ToolRuntime, + auth: &AuthContext, + client_id: Option, +) -> Result { + let result = runtime + .dispatch_with_auth( + ToolCall::RuntimeStatus { + compact: true, + summary_only: true, + client_id, + }, + Some(auth), + ) + .await; + result + .success + .then_some(result.output) + .ok_or(RuntimeConsoleError::Internal) +} + +async fn list_agents_value( + runtime: &ToolRuntime, + auth: &AuthContext, + client_id: Option, +) -> Result { + let result = runtime + .dispatch_with_auth( + ToolCall::ListAgents { + client_id, + client_ids: None, + include_projects: Some(false), + summary_only: true, + }, + Some(auth), + ) + .await; + result + .success + .then_some(result.output) + .ok_or(RuntimeConsoleError::Internal) +} + +async fn overview_for_auth( + runtime: &ToolRuntime, + auth: &AuthContext, +) -> Result { + require_runtime_read(auth)?; + let status = runtime_status_value(runtime, auth, None).await?; + let agents = list_agents_value(runtime, auth, None).await?; + let summary = agents.get("summary").unwrap_or(&Value::Null); + let build = status.get("build").unwrap_or(&Value::Null); + let status_clients = status + .get("agents") + .and_then(|value| value.get("clients")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let source_mismatched_runners = status_clients + .iter() + .filter(|client| { + client + .get("source_alignment") + .and_then(|value| value.get("status")) + .and_then(Value::as_str) + == Some("different") + }) + .count(); + let mixed_builds_present = status_clients.iter().any(|client| { + client + .get("version_matches_server") + .and_then(Value::as_bool) + == Some(false) + || client + .get("source_alignment") + .and_then(|value| value.get("status")) + .and_then(Value::as_str) + == Some("different") + }); + let project_access = project_read_available(auth); + let visible = if project_access { + Some(projects_for_auth(runtime, auth, Some(MAX_PROJECT_LIMIT)).await?) + } else { + None + }; + let mut workflow_aggregate = RuntimeConsoleWorkflowAggregate::default(); + if let Some(visible) = visible.as_ref() { + workflow_aggregate.projects_total = visible.total; + for project in visible.projects.iter().take(DEFAULT_RUNNER_PROJECT_LIMIT) { + let list = runtime + .workflow_sessions_console_list(&project.id, Some(CONSOLE_AGGREGATE_SESSION_LIMIT)); + let aggregate = aggregate_console_list(&list); + add_console_aggregate(&mut workflow_aggregate, &aggregate); + workflow_aggregate.projects_scanned += 1; + } + workflow_aggregate.truncated |= visible.truncated + || workflow_aggregate.projects_scanned < workflow_aggregate.projects_total; + } + let runner_count = safe_usize(summary.get("count")).max(status_clients.len()); + let online = safe_usize(summary.get("online")); + let stale = safe_usize(summary.get("stale")); + let unavailable = runner_count.saturating_sub(online.saturating_add(stale)); + Ok(RuntimeConsoleOverview { + service: safe_string(status.get("service"), 80), + version: safe_string(status.get("version"), 80), + build_git_commit: safe_string(build.get("git_commit"), 80), + build_git_dirty: build.get("git_dirty").and_then(Value::as_bool), + runner_count, + runners_online: online, + runners_stale: stale, + runners_unavailable: unavailable, + source_mismatched_runners, + mixed_builds_present, + active_jobs: safe_usize( + status + .get("jobs") + .and_then(|value| value.get("active_count")), + ), + projects_available: project_access, + visible_projects: visible.as_ref().map_or(0, |value| value.total), + projects_truncated: visible.as_ref().is_some_and(|value| value.truncated), + workflow_sessions: workflow_aggregate, + }) +} + +async fn runner_for_auth( + runtime: &ToolRuntime, + auth: &AuthContext, + client_id: &str, + project_limit: Option, +) -> Result { + require_runtime_read(auth)?; + if client_id.is_empty() + || client_id.chars().count() > MAX_CLIENT_ID_CHARS + || client_id.chars().any(char::is_control) + { + return Err(RuntimeConsoleError::Invalid); + } + let agents = list_agents_value(runtime, auth, Some(client_id.to_string())).await?; + let agent = agents + .get("agents") + .and_then(Value::as_array) + .and_then(|values| values.first()) + .ok_or(RuntimeConsoleError::NotFound)?; + let status = runtime_status_value(runtime, auth, Some(client_id.to_string())).await?; + let focus = status.get("focus").unwrap_or(&Value::Null); + let build = agent.get("build").unwrap_or(&Value::Null); + let concurrency = agent.get("job_concurrency").unwrap_or(&Value::Null); + let project_access = project_read_available(auth); + let project_limit = project_limit + .unwrap_or(DEFAULT_RUNNER_PROJECT_LIMIT) + .clamp(1, MAX_RUNNER_PROJECT_LIMIT); + let visible_projects = if project_access { + Some(projects_for_client_auth(runtime, auth, Some(client_id), MAX_PROJECT_LIMIT).await?) + } else { + None + }; + let visible_project_count = visible_projects.as_ref().map_or(0, |visible| visible.total); + let visible_projects_truncated = visible_projects + .as_ref() + .is_some_and(|visible| visible.truncated); + let running_jobs = running_jobs_for_auth(runtime, auth, None).await?; + let mut project_summaries = Vec::new(); + for project in visible_projects + .map(|visible| visible.projects) + .unwrap_or_default() + .into_iter() + .take(project_limit) + { + let mut list = runtime + .workflow_sessions_console_list(&project.id, Some(CONSOLE_AGGREGATE_SESSION_LIMIT)); + apply_running_jobs_to_list(&mut list, &project.id, &running_jobs); + project_summaries.push(RuntimeConsoleRunnerProject { + id: project.id, + name: project.name, + connected: project.connected, + agent_status: project.agent_status, + sessions: aggregate_console_list(&list), + }); + } + let projects_returned = project_summaries.len(); + Ok(RuntimeConsoleRunner { + client_id: client_id.to_string(), + connected: agent + .get("connected") + .and_then(Value::as_bool) + .unwrap_or(false), + status: safe_string(agent.get("status"), MAX_STATUS_CHARS), + version: safe_string(build.get("version"), 80), + build_git_commit: safe_string(build.get("git_commit"), 80), + build_git_dirty: build.get("git_dirty").and_then(Value::as_bool), + source_alignment: safe_string( + focus + .get("source_alignment") + .and_then(|value| value.get("status")), + MAX_STATUS_CHARS, + ), + active_jobs: safe_usize(agent.get("active_jobs")), + job_concurrency_limit: concurrency.get("limit").and_then(Value::as_u64), + jobs_running: safe_usize(concurrency.get("running")), + jobs_queued: safe_usize(concurrency.get("queued")), + projects_available: project_access, + visible_project_count, + projects_returned, + projects_truncated: visible_projects_truncated || projects_returned < visible_project_count, + projects: project_summaries, + }) +} + +async fn session_messages_for_auth( + runtime: &ToolRuntime, + auth: &AuthContext, + input: WorkflowSessionMessagesInput, +) -> Result { + require_runtime_read(auth)?; + authorize_runtime_session_project( + runtime, + auth, + &input.project, + &input.session_id, + "list_session_messages", + ) + .await?; + let limit = input + .limit + .unwrap_or(DEFAULT_MESSAGE_LIMIT) + .clamp(1, MAX_MESSAGE_LIMIT); + let result = runtime + .dispatch_with_auth( + ToolCall::ListSessionMessages { + session_id: input.session_id.clone(), + kind: None, + status: None, + message_id: None, + reply_to: None, + limit: Some(limit), + }, + Some(auth), + ) + .await; + Ok(RuntimeConsoleMessages { + session_id: input.session_id, + messages: messages_from_result(&result)?, + }) +} + +async fn session_observe_for_auth( + runtime: &ToolRuntime, + auth: &AuthContext, + input: WorkflowSessionObserveInput, +) -> Result { + require_runtime_read(auth)?; + authorize_runtime_session_project( + runtime, + auth, + &input.project, + &input.session_id, + "observe_session_messages", + ) + .await?; + if input + .after_observation_token + .as_ref() + .is_some_and(|token| token.chars().count() > MAX_OBSERVATION_TOKEN_CHARS) + || input + .wait_secs + .is_some_and(|wait| !(1..=60).contains(&wait)) + || (input.wait_secs.is_some() && input.after_observation_token.is_none()) + { + return Err(RuntimeConsoleError::Invalid); + } + let limit = input + .limit + .unwrap_or(DEFAULT_MESSAGE_LIMIT) + .clamp(1, MAX_MESSAGE_LIMIT); + let result = runtime + .dispatch_with_auth( + ToolCall::ObserveSessionMessages { + session_id: input.session_id.clone(), + after_observation_token: input.after_observation_token, + wait_secs: input.wait_secs, + limit: Some(limit), + }, + Some(auth), + ) + .await; + let messages = messages_from_result(&result)?; + let observation_token = result + .output + .get("observation_token") + .and_then(Value::as_str) + .filter(|token| token.chars().count() <= MAX_OBSERVATION_TOKEN_CHARS) + .ok_or(RuntimeConsoleError::Internal)? + .to_string(); + Ok(RuntimeConsoleObservation { + session_id: input.session_id, + messages, + observation_token, + changed: safe_bool(result.output.get("changed")), + wait_outcome: safe_string(result.output.get("wait_outcome"), 32) + .ok_or(RuntimeConsoleError::Internal)?, + waited_ms: result + .output + .get("waited_ms") + .and_then(Value::as_u64) + .unwrap_or(0), + history_lost: safe_bool(result.output.get("history_lost")), + has_more: safe_bool(result.output.get("has_more")), + }) +} + +async fn session_post_message_for_auth( + runtime: &ToolRuntime, + auth: &AuthContext, + input: WorkflowSessionPostMessageInput, +) -> Result { + require_runtime_read(auth)?; + if !matches!( + input.kind, + SessionMessageKind::Note + | SessionMessageKind::Guidance + | SessionMessageKind::Question + | SessionMessageKind::Todo + ) { + return Err(RuntimeConsoleError::Invalid); + } + authorize_runtime_session_project( + runtime, + auth, + &input.project, + &input.session_id, + "post_session_message", + ) + .await?; + let result = runtime + .dispatch_with_auth( + ToolCall::PostSessionMessage { + session_id: input.session_id, + kind: input.kind, + message: input.message, + tags: Vec::new(), + reply_to: input.reply_to, + priority: input.priority, + requires_ack: input.requires_ack, + }, + Some(auth), + ) + .await; + if !result.success { + return Err(RuntimeConsoleError::Invalid); + } + result + .output + .get("message") + .and_then(message_from_value) + .ok_or(RuntimeConsoleError::Internal) +} + +#[handler] +async fn overview(req: &mut Request, depot: &mut Depot, res: &mut Response) { + let (runtime, auth) = match prepared(req, depot).await { + Ok(value) => value, + Err(error) => return render_error(res, error), + }; + if req.parse_json::().await.is_err() { + return render_error(res, RuntimeConsoleError::Invalid); + } + match overview_for_auth(&runtime, &auth).await { + Ok(output) => res.render(Json(output)), + Err(error) => render_error(res, error), + } +} + +#[handler] +async fn runner(req: &mut Request, depot: &mut Depot, res: &mut Response) { + let (runtime, auth) = match prepared(req, depot).await { + Ok(value) => value, + Err(error) => return render_error(res, error), + }; + let input = match req.parse_json::().await { + Ok(input) => input, + Err(_) => return render_error(res, RuntimeConsoleError::Invalid), + }; + match runner_for_auth(&runtime, &auth, &input.client_id, input.project_limit).await { + Ok(output) => res.render(Json(output)), + Err(error) => render_error(res, error), + } +} + +#[handler] +async fn projects(req: &mut Request, depot: &mut Depot, res: &mut Response) { + let (runtime, auth) = match prepared(req, depot).await { + Ok(value) => value, + Err(error) => return render_error(res, error), + }; + let input = match req.parse_json::().await { + Ok(input) => input, + Err(_) => return render_error(res, RuntimeConsoleError::Invalid), + }; + match projects_for_auth(&runtime, &auth, input.limit).await { + Ok(output) => res.render(Json(output)), + Err(error) => render_error(res, error), + } +} + +#[handler] +async fn workflow_sessions(req: &mut Request, depot: &mut Depot, res: &mut Response) { + let (runtime, auth) = match prepared(req, depot).await { + Ok(value) => value, + Err(error) => return render_error(res, error), + }; + let input = match req.parse_json::().await { + Ok(input) => input, + Err(_) => return render_error(res, RuntimeConsoleError::Invalid), + }; + match workflow_sessions_for_auth(&runtime, &auth, &input.project, input.limit).await { + Ok(output) => res.render(Json(output)), + Err(error) => render_error(res, error), + } +} + +#[handler] +async fn workflow_session(req: &mut Request, depot: &mut Depot, res: &mut Response) { + let (runtime, auth) = match prepared(req, depot).await { + Ok(value) => value, + Err(error) => return render_error(res, error), + }; + let input = match req.parse_json::().await { + Ok(input) => input, + Err(_) => return render_error(res, RuntimeConsoleError::Invalid), + }; + match workflow_session_for_auth( + &runtime, + &auth, + &input.project, + &input.session_id, + input.limit, + ) + .await + { + Ok(output) => res.render(Json(output)), + Err(error) => render_error(res, error), + } } #[handler] -async fn projects(req: &mut Request, depot: &mut Depot, res: &mut Response) { +async fn workflow_session_messages(req: &mut Request, depot: &mut Depot, res: &mut Response) { let (runtime, auth) = match prepared(req, depot).await { Ok(value) => value, Err(error) => return render_error(res, error), }; - let input = match req.parse_json::().await { + let input = match req.parse_json::().await { Ok(input) => input, Err(_) => return render_error(res, RuntimeConsoleError::Invalid), }; - match projects_for_auth(&runtime, &auth, input.limit).await { + match session_messages_for_auth(&runtime, &auth, input).await { Ok(output) => res.render(Json(output)), Err(error) => render_error(res, error), } } #[handler] -async fn workflow_sessions(req: &mut Request, depot: &mut Depot, res: &mut Response) { +async fn workflow_session_observe(req: &mut Request, depot: &mut Depot, res: &mut Response) { let (runtime, auth) = match prepared(req, depot).await { Ok(value) => value, Err(error) => return render_error(res, error), }; - let input = match req.parse_json::().await { + let input = match req.parse_json::().await { Ok(input) => input, Err(_) => return render_error(res, RuntimeConsoleError::Invalid), }; - match workflow_sessions_for_auth(&runtime, &auth, &input.project, input.limit).await { + match session_observe_for_auth(&runtime, &auth, input).await { Ok(output) => res.render(Json(output)), Err(error) => render_error(res, error), } } #[handler] -async fn workflow_session(req: &mut Request, depot: &mut Depot, res: &mut Response) { +async fn workflow_session_post_message(req: &mut Request, depot: &mut Depot, res: &mut Response) { let (runtime, auth) = match prepared(req, depot).await { Ok(value) => value, Err(error) => return render_error(res, error), }; - let input = match req.parse_json::().await { + let input = match req.parse_json::().await { Ok(input) => input, Err(_) => return render_error(res, RuntimeConsoleError::Invalid), }; - match workflow_session_for_auth( - &runtime, - &auth, - &input.project, - &input.session_id, - input.limit, - ) - .await - { + match session_post_message_for_auth(&runtime, &auth, input).await { Ok(output) => res.render(Json(output)), Err(error) => render_error(res, error), } @@ -319,13 +1189,15 @@ async fn workflow_session(req: &mut Request, depot: &mut Depot, res: &mut Respon #[cfg(test)] mod tests { use super::*; + use crate::auth::AuthKind; use crate::shell_protocol::{ ShellAgentProjectSummary, ShellClientCapabilities, ShellClientRegisterRequest, }; use crate::tool_runtime::sessions::{ - PostSessionMessageInput, SessionMessageKind, SessionMessagePriority, + CompleteSessionMessageInput, PostSessionMessageInput, SessionCreateOptions, SessionGuards, + SessionMessageKind, SessionMessagePriority, }; - use crate::tool_runtime::RuntimeInfo; + use crate::tool_runtime::{RuntimeInfo, SessionMode}; use salvo::test::{ResponseExt, TestClient}; use salvo::Service; @@ -387,6 +1259,35 @@ mod tests { )) } + fn scoped_oauth(scopes: &[&str]) -> AuthContext { + let mut auth = AuthContext::new(AuthKind::OAuth2Token); + auth.user_id = Some("runtime-console-test-user".to_string()); + auth.username = Some("runtime-console-test-user".to_string()); + auth.scopes = scopes.iter().map(|scope| (*scope).to_string()).collect(); + auth + } + + fn start_authorized_session( + runtime: &ToolRuntime, + project: &str, + auth: &AuthContext, + ) -> crate::tool_runtime::sessions::SessionSummary { + let fingerprint = crate::tool_runtime::workflow_session_authority_fingerprint(Some(auth)) + .expect("stable test authority"); + runtime + .sessions + .start_session_with_options( + SessionCreateOptions::new( + Some(project.to_string()), + Some("runtime console collaboration".to_string()), + SessionMode::Normal, + SessionGuards::default(), + ) + .with_owner_authority_fingerprint(Some(fingerprint)), + ) + .unwrap() + } + fn hosted_service(runtime: Arc) -> (tempfile::TempDir, Service) { let config = crate::test_support::test_config(None); let (tmp, db) = crate::test_support::test_db(); @@ -593,10 +1494,18 @@ mod tests { .await .unwrap(); let direct_list = runtime.workflow_sessions_console_list(project_id, Some(20)); - assert_eq!( - serde_json::to_value(hosted_list).unwrap(), - serde_json::to_value(direct_list).unwrap() - ); + let mut hosted_list_value = serde_json::to_value(&hosted_list).unwrap(); + let mut direct_list_value = serde_json::to_value(&direct_list).unwrap(); + for value in [&mut hosted_list_value, &mut direct_list_value] { + for session in value["sessions"].as_array_mut().unwrap() { + let session = session.as_object_mut().unwrap(); + session.remove("running_jobs"); + session.remove("running_jobs_complete"); + } + } + assert_eq!(hosted_list_value, direct_list_value); + assert_eq!(hosted_list.sessions[0].running_jobs, 0); + assert!(hosted_list.sessions[0].running_jobs_complete); let hosted_detail = workflow_session_for_auth(&runtime, &auth, project_id, &session.session_id, Some(20)) @@ -605,12 +1514,498 @@ mod tests { let direct_detail = runtime .workflow_session_console_detail(project_id, &session.session_id, Some(20)) .unwrap(); - assert_eq!( - serde_json::to_value(&hosted_detail).unwrap(), - serde_json::to_value(&direct_detail).unwrap() - ); + let mut hosted_detail_value = serde_json::to_value(&hosted_detail).unwrap(); + let mut direct_detail_value = serde_json::to_value(&direct_detail).unwrap(); + for value in [&mut hosted_detail_value, &mut direct_detail_value] { + let detail = value.as_object_mut().unwrap(); + detail.remove("running_jobs"); + detail.remove("running_jobs_complete"); + } + assert_eq!(hosted_detail_value, direct_detail_value); + assert_eq!(hosted_detail.running_jobs, 0); + assert!(hosted_detail.running_jobs_complete); let serialized = serde_json::to_string(&hosted_detail).unwrap(); assert!(!serialized.contains("/root/private/source.rs")); assert!(serialized.contains("[private path]")); } + + #[tokio::test] + async fn project_read_routes_survive_without_runtime_read_but_runtime_views_fail_closed() { + let runtime = test_runtime(); + let auth = scoped_oauth(&[SCOPE_PROJECT_READ]); + register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth)).await; + + let project_view = projects_for_auth(&runtime, &auth, Some(20)).await.unwrap(); + assert_eq!(project_view.projects.len(), 1); + assert_eq!(project_view.projects[0].id, "agent:client-a:proj-a"); + assert_eq!( + overview_for_auth(&runtime, &auth).await.unwrap_err(), + RuntimeConsoleError::Request { + status: 403, + message: "Runtime read access required", + } + ); + assert_eq!( + runner_for_auth(&runtime, &auth, "client-a", Some(20)) + .await + .unwrap_err(), + RuntimeConsoleError::Request { + status: 403, + message: "Runtime read access required", + } + ); + } + + #[tokio::test] + async fn server_and_runner_overviews_stay_within_caller_authorization_and_safe_projection() { + let runtime = test_runtime(); + let auth_a = crate::auth::shared_key_context("runtime-console-overview-a"); + let auth_b = crate::auth::shared_key_context("runtime-console-overview-b"); + register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth_a)).await; + register_project(&runtime, "client-b", "proj-b", "/private/b", Some(&auth_b)).await; + + let overview_view = overview_for_auth(&runtime, &auth_a).await.unwrap(); + assert_eq!(overview_view.runner_count, 1); + assert_eq!(overview_view.visible_projects, 1); + assert!(!overview_view.projects_truncated); + + let runner_view = runner_for_auth(&runtime, &auth_a, "client-a", Some(20)) + .await + .unwrap(); + assert_eq!(runner_view.client_id, "client-a"); + assert_eq!(runner_view.visible_project_count, 1); + assert_eq!(runner_view.projects.len(), 1); + assert_eq!(runner_view.projects[0].id, "agent:client-a:proj-a"); + assert_eq!( + runner_for_auth(&runtime, &auth_a, "client-b", Some(20)) + .await + .unwrap_err(), + RuntimeConsoleError::NotFound + ); + + let serialized = format!( + "{}{}", + serde_json::to_string(&overview_view).unwrap(), + serde_json::to_string(&runner_view).unwrap() + ); + for private in [ + "/private/a", + "/private/b", + "private-host-client-a", + "private-host-client-b", + "private-shell-profile", + "private-hook", + "private description", + ] { + assert!( + !serialized.contains(private), + "leaked {private}: {serialized}" + ); + } + assert!(!serialized.contains("agent:client-b:proj-b")); + } + + #[tokio::test] + async fn collaboration_routes_require_runtime_read_before_session_lookup() { + let runtime = test_runtime(); + let auth = scoped_oauth(&[SCOPE_PROJECT_READ]); + let error = session_messages_for_auth( + &runtime, + &auth, + WorkflowSessionMessagesInput { + project: "agent:missing:project".to_string(), + session_id: "wc_sess_missing".to_string(), + limit: Some(20), + }, + ) + .await + .unwrap_err(); + assert_eq!( + error, + RuntimeConsoleError::Request { + status: 403, + message: "Runtime read access required", + } + ); + } + + #[tokio::test] + async fn collaboration_message_projection_reuses_authority_fence_and_hides_completion_identity() + { + let runtime = test_runtime(); + let auth_a = crate::auth::shared_key_context("runtime-console-group-a"); + let auth_b = crate::auth::shared_key_context("runtime-console-group-b"); + let project_id = "agent:client-a:proj-a"; + register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth_a)).await; + let session = start_authorized_session(&runtime, project_id, &auth_a); + let todo = runtime + .sessions + .post_message(PostSessionMessageInput { + session_id: session.session_id.clone(), + kind: SessionMessageKind::Todo, + message: "safe todo body".to_string(), + tags: vec!["private-tag".to_string()], + reply_to: None, + priority: SessionMessagePriority::High, + }) + .unwrap(); + runtime + .sessions + .complete_message(CompleteSessionMessageInput { + session_id: session.session_id.clone(), + message_id: todo.message_id, + answer: "done".to_string(), + tags: vec!["answer-tag".to_string()], + priority: SessionMessagePriority::Normal, + completion_id: "a".repeat(64), + author_session_id: Some("wc_sess_worker".to_string()), + }) + .unwrap(); + + let board = session_messages_for_auth( + &runtime, + &auth_a, + WorkflowSessionMessagesInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + limit: Some(100), + }, + ) + .await + .unwrap(); + assert_eq!(board.messages.len(), 2); + let serialized = serde_json::to_string(&board).unwrap(); + assert!(serialized.contains("safe todo body")); + assert!(serialized.contains("done")); + assert!(!serialized.contains(&"a".repeat(64))); + assert!(!serialized.contains("private-tag")); + assert!(!serialized.contains("answer-tag")); + assert!(!serialized.contains("completion_id")); + assert!(!serialized.contains("observation_revision")); + + assert_eq!( + session_messages_for_auth( + &runtime, + &auth_a, + WorkflowSessionMessagesInput { + project: "agent:client-a:wrong".to_string(), + session_id: session.session_id.clone(), + limit: Some(20), + }, + ) + .await + .unwrap_err(), + RuntimeConsoleError::NotFound + ); + assert_eq!( + session_messages_for_auth( + &runtime, + &auth_b, + WorkflowSessionMessagesInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + limit: Some(20), + }, + ) + .await + .unwrap_err(), + RuntimeConsoleError::NotFound + ); + } + + #[tokio::test] + async fn human_join_reuses_formal_session_authority_and_ack_validation() { + let runtime = test_runtime(); + let auth_a = crate::auth::shared_key_context("runtime-console-human-a"); + let auth_b = crate::auth::shared_key_context("runtime-console-human-b"); + let project_id = "agent:client-a:proj-a"; + register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth_a)).await; + let session = start_authorized_session(&runtime, project_id, &auth_a); + + let posted = session_post_message_for_auth( + &runtime, + &auth_a, + WorkflowSessionPostMessageInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + kind: SessionMessageKind::Guidance, + priority: SessionMessagePriority::High, + message: "Please preserve the exact authority fence.".to_string(), + reply_to: None, + requires_ack: true, + }, + ) + .await + .unwrap(); + assert_eq!(posted.kind, "guidance"); + assert_eq!(posted.priority, "high"); + assert!(posted.requires_ack); + assert!(posted.first_ack_observed_at.is_none()); + + assert_eq!( + session_post_message_for_auth( + &runtime, + &auth_a, + WorkflowSessionPostMessageInput { + project: "agent:client-a:wrong".to_string(), + session_id: session.session_id.clone(), + kind: SessionMessageKind::Note, + priority: SessionMessagePriority::Normal, + message: "wrong project".to_string(), + reply_to: None, + requires_ack: false, + }, + ) + .await + .unwrap_err(), + RuntimeConsoleError::NotFound + ); + assert_eq!( + session_post_message_for_auth( + &runtime, + &auth_b, + WorkflowSessionPostMessageInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + kind: SessionMessageKind::Note, + priority: SessionMessagePriority::Normal, + message: "foreign authority".to_string(), + reply_to: None, + requires_ack: false, + }, + ) + .await + .unwrap_err(), + RuntimeConsoleError::NotFound + ); + assert_eq!( + session_post_message_for_auth( + &runtime, + &auth_a, + WorkflowSessionPostMessageInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + kind: SessionMessageKind::Note, + priority: SessionMessagePriority::High, + message: "invalid ack mode".to_string(), + reply_to: None, + requires_ack: true, + }, + ) + .await + .unwrap_err(), + RuntimeConsoleError::Invalid + ); + assert_eq!( + session_post_message_for_auth( + &runtime, + &auth_a, + WorkflowSessionPostMessageInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + kind: SessionMessageKind::Progress, + priority: SessionMessagePriority::Normal, + message: "progress is not a Human Join kind".to_string(), + reply_to: None, + requires_ack: false, + }, + ) + .await + .unwrap_err(), + RuntimeConsoleError::Invalid + ); + assert_eq!( + session_post_message_for_auth( + &runtime, + &auth_a, + WorkflowSessionPostMessageInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + kind: SessionMessageKind::Guidance, + priority: SessionMessagePriority::High, + message: "x".repeat(8001), + reply_to: None, + requires_ack: true, + }, + ) + .await + .unwrap_err(), + RuntimeConsoleError::Invalid + ); + let openapi = crate::openapi::build_openapi_spec(); + assert!(openapi["paths"] + .get("/api/runtime-console/workflow-session-post-message") + .is_none()); + } + + #[tokio::test] + async fn collaboration_observation_route_preserves_baseline_update_timeout_and_paging_semantics( + ) { + let runtime = test_runtime(); + let auth = crate::auth::shared_key_context("runtime-console-observe"); + let project_id = "agent:client-a:proj-a"; + register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth)).await; + let session = start_authorized_session(&runtime, project_id, &auth); + + let baseline = session_observe_for_auth( + &runtime, + &auth, + WorkflowSessionObserveInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + after_observation_token: None, + wait_secs: None, + limit: Some(100), + }, + ) + .await + .unwrap(); + assert!(!baseline.changed); + assert!(baseline.messages.is_empty()); + assert!(!baseline.history_lost); + assert!(!baseline.has_more); + + runtime + .sessions + .post_message(PostSessionMessageInput { + session_id: session.session_id.clone(), + kind: SessionMessageKind::Question, + message: "first update".to_string(), + tags: Vec::new(), + reply_to: None, + priority: SessionMessagePriority::Normal, + }) + .unwrap(); + let updated = session_observe_for_auth( + &runtime, + &auth, + WorkflowSessionObserveInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + after_observation_token: Some(baseline.observation_token), + wait_secs: None, + limit: Some(100), + }, + ) + .await + .unwrap(); + assert!(updated.changed); + assert_eq!(updated.messages.len(), 1); + assert_eq!(updated.messages[0].message, "first update"); + + let timed_out = session_observe_for_auth( + &runtime, + &auth, + WorkflowSessionObserveInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + after_observation_token: Some(updated.observation_token.clone()), + wait_secs: Some(1), + limit: Some(100), + }, + ) + .await + .unwrap(); + assert_eq!(timed_out.wait_outcome, "timeout"); + assert!(!timed_out.changed); + + for body in ["page one", "page two"] { + runtime + .sessions + .post_message(PostSessionMessageInput { + session_id: session.session_id.clone(), + kind: SessionMessageKind::Guidance, + message: body.to_string(), + tags: Vec::new(), + reply_to: None, + priority: SessionMessagePriority::Normal, + }) + .unwrap(); + } + let page_one = session_observe_for_auth( + &runtime, + &auth, + WorkflowSessionObserveInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + after_observation_token: Some(updated.observation_token), + wait_secs: None, + limit: Some(1), + }, + ) + .await + .unwrap(); + assert!(page_one.has_more); + assert_eq!(page_one.messages.len(), 1); + let page_two = session_observe_for_auth( + &runtime, + &auth, + WorkflowSessionObserveInput { + project: project_id.to_string(), + session_id: session.session_id, + after_observation_token: Some(page_one.observation_token), + wait_secs: None, + limit: Some(100), + }, + ) + .await + .unwrap(); + assert!(!page_two.has_more); + assert_eq!(page_two.messages.len(), 1); + } + + #[tokio::test] + async fn collaboration_observation_route_surfaces_history_loss_from_authoritative_retention() { + let runtime = test_runtime(); + let auth = crate::auth::shared_key_context("runtime-console-history-loss"); + let project_id = "agent:client-a:proj-a"; + register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth)).await; + let session = start_authorized_session(&runtime, project_id, &auth); + let baseline = session_observe_for_auth( + &runtime, + &auth, + WorkflowSessionObserveInput { + project: project_id.to_string(), + session_id: session.session_id.clone(), + after_observation_token: None, + wait_secs: None, + limit: Some(100), + }, + ) + .await + .unwrap(); + + let retention_limit = runtime.sessions.status().max_messages_per_session; + for index in 0..=retention_limit { + runtime + .sessions + .post_message(PostSessionMessageInput { + session_id: session.session_id.clone(), + kind: SessionMessageKind::Note, + message: format!("retention filler {index}"), + tags: Vec::new(), + reply_to: None, + priority: SessionMessagePriority::Normal, + }) + .unwrap(); + } + + let observed = session_observe_for_auth( + &runtime, + &auth, + WorkflowSessionObserveInput { + project: project_id.to_string(), + session_id: session.session_id, + after_observation_token: Some(baseline.observation_token), + wait_secs: None, + limit: Some(100), + }, + ) + .await + .unwrap(); + assert!(observed.changed); + assert!(observed.history_lost); + assert!(observed.has_more); + assert_eq!(observed.messages.len(), 100); + } } diff --git a/src/shell_client/reconciliation_tests.rs b/src/shell_client/reconciliation_tests.rs index a31665d2..fe8fc04c 100644 --- a/src/shell_client/reconciliation_tests.rs +++ b/src/shell_client/reconciliation_tests.rs @@ -344,6 +344,7 @@ async fn reconciliation_rejects_cross_product_first_class_go_test_metadata() { effective_timeout_secs: 1800, sync_wait_secs: 10, adapter: "go_test".to_string(), + validation_target_id: None, }); let inventory = ShellJobInventory { active_complete: true, diff --git a/src/tool_runtime/cargo.rs b/src/tool_runtime/cargo.rs index e02bed99..aac69d85 100644 --- a/src/tool_runtime/cargo.rs +++ b/src/tool_runtime/cargo.rs @@ -637,6 +637,21 @@ impl ToolRuntime { }; let adapter = validation_adapter_for_tool(tool_name) .expect("structured validation profile must register the read-only tool"); + let validation_target_id = super::tool_audit::structured_validation_target_identity( + tool_name, + &json!({ + "cwd": cwd.as_deref(), + "check": request.check, + "filter": request.filter.as_deref(), + "all_targets": request.all_targets, + "all_features": request.all_features, + "no_default_features": request.no_default_features, + "features": request.features.as_deref(), + "package": request.package.as_deref(), + "no_run": request.no_run, + "packages": request.go_packages.as_ref(), + }), + ); let options = ValidationCommandOptions { check: request.check, filter: request.filter, @@ -853,6 +868,7 @@ impl ToolRuntime { timeout_secs, sync_wait_secs, session_id, + validation_target_id, ssh_resource.as_deref(), request.sandbox.as_deref(), request.auth, @@ -909,6 +925,8 @@ impl ToolRuntime { purpose, timeout_secs, sync_wait_secs, + session_id, + validation_target_id, request.sandbox.as_deref(), ) .await @@ -933,6 +951,7 @@ impl ToolRuntime { timeout_secs: u64, sync_wait_secs: u64, session_id: Option, + validation_target_id: Option, ssh_resource: Option<&str>, sandbox: Option<&str>, auth: Option<&AuthContext>, @@ -1012,6 +1031,7 @@ impl ToolRuntime { effective_timeout_secs: timeout_secs, sync_wait_secs, adapter: adapter.tool_identity().to_string(), + validation_target_id: validation_target_id.clone(), }), visibility: crate::shell_client::ShellJobVisibility::HiddenUntilHandoff, sandbox: sandbox.map(str::to_string), @@ -1066,11 +1086,13 @@ impl ToolRuntime { purpose: ExecutionPurpose, timeout_secs: u64, sync_wait_secs: u64, + session_id: Option, + validation_target_id: Option, sandbox: Option<&str>, ) -> ToolResult { if local_validation_should_handoff(timeout_secs, sync_wait_secs, sandbox) { return self - .run_readonly_validation_local_job( + .run_readonly_validation_local_job_with_context( _tool_name, project, config, @@ -1081,6 +1103,8 @@ impl ToolRuntime { purpose, timeout_secs, sync_wait_secs, + session_id, + validation_target_id, ) .await; } @@ -1125,6 +1149,7 @@ impl ToolRuntime { .await } + #[cfg_attr(not(test), allow(dead_code))] #[allow(clippy::too_many_arguments)] pub(crate) async fn run_readonly_validation_local_job( &self, @@ -1138,6 +1163,39 @@ impl ToolRuntime { purpose: ExecutionPurpose, timeout_secs: u64, sync_wait_secs: u64, + ) -> ToolResult { + self.run_readonly_validation_local_job_with_context( + tool_name, + project, + config, + cwd, + command, + adapter, + options, + purpose, + timeout_secs, + sync_wait_secs, + None, + None, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn run_readonly_validation_local_job_with_context( + &self, + tool_name: &str, + project: &str, + config: &crate::projects::ProjectConfig, + cwd: Option<&str>, + command: &str, + adapter: &'static dyn ValidationAdapter, + options: ValidationCommandOptions, + purpose: ExecutionPurpose, + timeout_secs: u64, + sync_wait_secs: u64, + session_id: Option, + validation_target_id: Option, ) -> ToolResult { let cwd_path = match resolve_local_cwd(config, cwd) { Ok(path) => path, @@ -1248,6 +1306,8 @@ impl ToolRuntime { "effective_timeout_secs": timeout_secs, "sync_wait_secs": sync_wait_secs, "validation_adapter": adapter.tool_identity(), + "session_id": session_id, + "validation_target_id": validation_target_id, "visibility": "hidden_until_handoff", }); if let Err(error) = std::fs::write( diff --git a/src/tool_runtime/jobs.rs b/src/tool_runtime/jobs.rs index d04b0eef..3cb0659b 100644 --- a/src/tool_runtime/jobs.rs +++ b/src/tool_runtime/jobs.rs @@ -465,6 +465,7 @@ pub(crate) fn local_job_summary_value( "kind": kind, "status": status, "project": record.project, + "session_id": meta.get("session_id").cloned().unwrap_or(Value::Null), "executor": "local", "created_at": created_at, "started_at": started_at, @@ -517,6 +518,7 @@ pub(crate) fn local_job_status( let mut output = json!({ "job_id": job_id, "project": record.project, + "session_id": meta.get("session_id").cloned().unwrap_or(Value::Null), "status": status, "exit_code": exit_code, "created_at": created_at, @@ -532,7 +534,7 @@ pub(crate) fn local_job_status( let (validation_tool, validation_kind) = local_validation_identity(&meta); let stdout = record.read_log_lines("stdout.log", None, Some(MAX_LOCAL_LOG_LINES)); let stderr = record.read_log_lines("stderr.log", None, Some(MAX_LOCAL_LOG_LINES)); - if let Some(validation) = validation_job_projection( + if let Some(mut validation) = validation_job_projection( validation_tool, validation_kind, &status, @@ -541,6 +543,9 @@ pub(crate) fn local_job_status( &stderr.0, stdout.3 || stderr.3, ) { + if let Some(target_id) = meta.get("validation_target_id").and_then(Value::as_str) { + validation["validation_target_id"] = json!(target_id); + } output["validation"] = validation; } add_job_lifecycle_fields(&mut output, &status, None, None); @@ -805,7 +810,7 @@ pub(crate) async fn local_job_log( &analysis_stderr.text, ); let (validation_tool, validation_kind) = local_validation_identity(&meta); - let validation = validation_job_projection( + let mut validation = validation_job_projection( validation_tool, validation_kind, &final_status, @@ -814,8 +819,15 @@ pub(crate) async fn local_job_log( &analysis_stderr.text, analysis_stdout.truncated || analysis_stderr.truncated, ); + if let (Some(validation), Some(target_id)) = ( + validation.as_mut(), + meta.get("validation_target_id").and_then(Value::as_str), + ) { + validation["validation_target_id"] = json!(target_id); + } let mut output = json!({ "job_id": job_id, "status": final_status, "exit_code": final_exit_code, + "session_id": meta.get("session_id").cloned().unwrap_or(Value::Null), "stdout_tail": stdout.text, "stderr_tail": stderr.text, "stdout_lines": stdout.total_lines, "stderr_lines": stderr.total_lines, "stdout_returned_lines": stdout.returned_lines, @@ -1792,7 +1804,7 @@ impl ToolRuntime { } Err(_) => (String::new(), String::new(), true), }; - if let Some(validation) = validation_job_projection( + if let Some(mut validation) = validation_job_projection( tool, kind, &status, @@ -1801,6 +1813,11 @@ impl ToolRuntime { &stderr, truncated, ) { + if let Some(target_id) = validation_metadata + .and_then(|metadata| metadata.validation_target_id.as_deref()) + { + validation["validation_target_id"] = json!(target_id); + } output["validation"] = validation; } } @@ -1926,7 +1943,7 @@ impl ToolRuntime { .validation .as_ref() .map(|metadata| metadata.kind.as_str()); - let validation = validation_job_projection( + let mut validation = validation_job_projection( validation_tool, validation_kind, &job.status, @@ -1935,6 +1952,14 @@ impl ToolRuntime { &wait.analysis_stderr, wait.analysis_truncated, ); + if let (Some(validation), Some(target_id)) = ( + validation.as_mut(), + job.validation + .as_ref() + .and_then(|metadata| metadata.validation_target_id.as_deref()), + ) { + validation["validation_target_id"] = json!(target_id); + } ToolResult::ok(json!({ "job_id": job.job_id, "status": job.status, @@ -2118,6 +2143,70 @@ impl ToolRuntime { })) } + pub(crate) async fn validation_job_candidates_for_sessions( + &self, + project: &str, + session_ids: &[String], + auth: Option<&AuthContext>, + ) -> std::collections::HashMap> { + let requested = session_ids + .iter() + .map(String::as_str) + .collect::>(); + let mut grouped = std::collections::HashMap::>::new(); + if requested.is_empty() { + return grouped; + } + for job in self.shell_clients.list_all_jobs_for_auth(auth).await.iter() { + let Some(session_id) = job.session_id.as_deref() else { + continue; + }; + if job.project_id.as_deref() == Some(project) + && requested.contains(session_id) + && job.validation.is_some() + { + grouped + .entry(session_id.to_string()) + .or_default() + .push(agent_job_summary_value(job)); + } + } + if local_jobs_visible_to_auth(auth) { + let local_jobs_map = self.local_jobs.lock().await; + for (job_id, record) in local_jobs_map + .iter() + .filter(|(_, record)| record.is_public() && record.project == project) + { + let Some(session_id) = local_job_session_id(record) else { + continue; + }; + if !requested.contains(session_id.as_str()) { + continue; + } + if let Some(summary) = local_job_summary_value(job_id, record, &None) { + if summary.get("validation").is_some_and(Value::is_object) { + grouped.entry(session_id).or_default().push(summary); + } + } + } + } + for summaries in grouped.values_mut() { + summaries.sort_by(|a, b| { + b["created_at"] + .as_i64() + .unwrap_or(0) + .cmp(&a["created_at"].as_i64().unwrap_or(0)) + .then_with(|| { + a["job_id"] + .as_str() + .unwrap_or_default() + .cmp(b["job_id"].as_str().unwrap_or_default()) + }) + }); + } + grouped + } + /// `job_tail`: bounded stdout/stderr tails for a job. Reuses the bounded /// `job_log` path with a tail-focused default so the console never reads /// full logs by default. diff --git a/src/tool_runtime/kernel.rs b/src/tool_runtime/kernel.rs index 9b113c3e..6a6ddd85 100644 --- a/src/tool_runtime/kernel.rs +++ b/src/tool_runtime/kernel.rs @@ -282,6 +282,12 @@ impl ToolRuntime { session_id, event_id, ); + session_context::add_session_attention( + &mut result, + &self.sessions, + session_id, + &recorder_metadata.ack_session_message_ids, + ); return ToolCallOutcome { success: false, result: Some(result), @@ -322,6 +328,12 @@ impl ToolRuntime { session_id, event_id, ); + session_context::add_session_attention( + &mut result, + &self.sessions, + session_id, + &recorder_metadata.ack_session_message_ids, + ); } return ToolCallOutcome { success: false, @@ -377,6 +389,12 @@ impl ToolRuntime { session_id, event_id, ); + session_context::add_session_attention( + &mut result, + &self.sessions, + session_id, + &recorder_metadata.ack_session_message_ids, + ); return ToolCallOutcome { success: false, result: Some(result), @@ -417,6 +435,12 @@ impl ToolRuntime { session_id, event_id, ); + session_context::add_session_attention( + &mut result, + &self.sessions, + session_id, + &recorder_metadata.ack_session_message_ids, + ); return ToolCallOutcome { success: false, result: Some(result), @@ -547,7 +571,7 @@ impl ToolRuntime { context.transport.into(), context.session_id.is_none(), allow_cross_project_session, - recorder_metadata, + recorder_metadata.clone(), inherited_sandbox, context.window, ) @@ -618,6 +642,12 @@ impl ToolRuntime { session_id, outer_event_id, ); + session_context::add_session_attention( + &mut result, + &self.sessions, + session_id, + &recorder_metadata.ack_session_message_ids, + ); } ToolCallOutcome { success: result.success, diff --git a/src/tool_runtime/mod.rs b/src/tool_runtime/mod.rs index 4866b043..57029565 100644 --- a/src/tool_runtime/mod.rs +++ b/src/tool_runtime/mod.rs @@ -89,6 +89,8 @@ pub(crate) use local_jobs::ACTIVE_JOB_STATUSES; pub(crate) use local_jobs::{LocalJobKiller, LocalJobRecord, SystemJobKiller, TerminateOutcome}; pub use runtime::ToolRuntime; pub use runtime_info::RuntimeInfo; +#[cfg(test)] +pub(crate) use session_context::workflow_session_authority_fingerprint; pub use tool_call::{ ObserveJobsItem, ReadFilesItem, SearchProjectTextsQuery, SearchResultMode, ToolCall, }; diff --git a/src/tool_runtime/registry/input_schemas/sessions.rs b/src/tool_runtime/registry/input_schemas/sessions.rs index 3f39efab..b47b6267 100644 --- a/src/tool_runtime/registry/input_schemas/sessions.rs +++ b/src/tool_runtime/registry/input_schemas/sessions.rs @@ -120,7 +120,12 @@ pub(crate) fn post_session_message_input_schema() -> Value { "anyOf": [{ "type": "string" }, { "type": "null" }], "description": "Optional message id in the same session." }, - "priority": session_message_priority_schema("Optional priority; defaults to normal.") + "priority": session_message_priority_schema("Optional priority; defaults to normal."), + "requires_ack": { + "type": "boolean", + "default": false, + "description": "Optional acknowledgement requirement. In this version only high-priority guidance may require acknowledgement. ACK is context-scoped and never resolves or gates work." + } }, "required": ["session_id", "kind", "message"], "additionalProperties": false, diff --git a/src/tool_runtime/registry/tool_specs/sessions.rs b/src/tool_runtime/registry/tool_specs/sessions.rs index 9a13283b..9420e37f 100644 --- a/src/tool_runtime/registry/tool_specs/sessions.rs +++ b/src/tool_runtime/registry/tool_specs/sessions.rs @@ -33,7 +33,7 @@ pub(super) fn tool_specs() -> Vec { ), tool_spec( "post_session_message", - "Create an ordinary bounded collaboration message such as todo, question, progress, guidance, risk, or decision. Use complete_session_message instead when a worker finishes an exact todo and must atomically answer+resolve it.", + "Post a bounded collaboration message (todo, question, progress, guidance, risk, or decision). High-priority guidance may require request-scoped ACK; ACK neither resolves nor gates work. Use complete_session_message to atomically answer and resolve a finished todo.", post_session_message_input_schema(), ), tool_spec( diff --git a/src/tool_runtime/runtime.rs b/src/tool_runtime/runtime.rs index 5859c60e..f9b1c998 100644 --- a/src/tool_runtime/runtime.rs +++ b/src/tool_runtime/runtime.rs @@ -10,9 +10,13 @@ use crate::shell_client::ShellClientRegistry; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::path::PathBuf; +#[cfg(test)] +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; +#[cfg(test)] +use tokio::sync::Semaphore; use uuid::Uuid; fn new_git_diff_hunks_continuation_mac_key() -> Arc<[u8; 32]> { @@ -23,6 +27,82 @@ fn new_git_diff_hunks_continuation_mac_key() -> Arc<[u8; 32]> { Arc::new(hasher.finalize().into()) } +#[cfg(test)] +pub(crate) struct ValidationTerminalReconciliationTestHook { + reconciliation_attempted: Semaphore, + pause_next_after_snapshot: AtomicBool, + snapshot_acquired: Semaphore, + resume_snapshot: Semaphore, + snapshot_acquisition_count: AtomicUsize, +} + +#[cfg(test)] +impl Default for ValidationTerminalReconciliationTestHook { + fn default() -> Self { + Self { + pause_next_after_snapshot: AtomicBool::new(false), + reconciliation_attempted: Semaphore::new(0), + snapshot_acquired: Semaphore::new(0), + resume_snapshot: Semaphore::new(0), + snapshot_acquisition_count: AtomicUsize::new(0), + } + } +} + +#[cfg(test)] +impl ValidationTerminalReconciliationTestHook { + pub(crate) fn before_reconciliation_lock(&self) { + self.reconciliation_attempted.add_permits(1); + } + + pub(crate) async fn wait_for_reconciliation_attempt(&self) { + let permit = self + .reconciliation_attempted + .acquire() + .await + .expect("validation terminal reconciliation attempt semaphore closed"); + permit.forget(); + } + + pub(crate) fn pause_next_snapshot(&self) { + assert!( + !self.pause_next_after_snapshot.swap(true, Ordering::SeqCst), + "validation terminal snapshot pause already armed" + ); + } + + pub(crate) async fn after_snapshot_acquired(&self) { + self.snapshot_acquisition_count + .fetch_add(1, Ordering::SeqCst); + self.snapshot_acquired.add_permits(1); + if self.pause_next_after_snapshot.swap(false, Ordering::SeqCst) { + let permit = self + .resume_snapshot + .acquire() + .await + .expect("validation terminal snapshot resume semaphore closed"); + permit.forget(); + } + } + + pub(crate) async fn wait_for_snapshot_acquired(&self) { + let permit = self + .snapshot_acquired + .acquire() + .await + .expect("validation terminal snapshot semaphore closed"); + permit.forget(); + } + + pub(crate) fn resume_snapshot(&self) { + self.resume_snapshot.add_permits(1); + } + + pub(crate) fn snapshot_acquisition_count(&self) -> usize { + self.snapshot_acquisition_count.load(Ordering::SeqCst) + } +} + #[derive(Clone)] pub struct ToolRuntime { pub shell_clients: Arc, @@ -44,6 +124,15 @@ pub struct ToolRuntime { /// before it promotes to a Job. Defaults to `SYNC_VALIDATION_WAIT_SECS`; /// tests shrink it so the handoff path can be exercised without sleeping. pub(crate) validation_sync_wait: Duration, + /// Orders authoritative terminal-Job snapshot acquisition through Session + /// marker/evidence materialization. Marker eviction interprets absence from + /// that snapshot as retention exit, so a later snapshot must never commit + /// before an earlier snapshot has finished using its eviction authority. + /// Cloned runtimes share this mutex; restart drops all in-flight snapshots. + pub(crate) validation_terminal_reconciliation: Arc>, + #[cfg(test)] + pub(crate) validation_terminal_reconciliation_test_hook: + Arc, /// Internal synchronous grace for typed process/script Jobs. It controls /// only when the existing execution is exposed, never its total timeout. pub(crate) structured_execution_sync_wait: Duration, @@ -84,6 +173,11 @@ impl ToolRuntime { search_project_texts_deadline: super::search_project_texts::DEFAULT_SEARCH_PROJECT_TEXTS_DEADLINE, validation_sync_wait: Duration::from_secs(super::helpers::SYNC_VALIDATION_WAIT_SECS), + validation_terminal_reconciliation: Arc::new(Mutex::new(())), + #[cfg(test)] + validation_terminal_reconciliation_test_hook: Arc::new( + ValidationTerminalReconciliationTestHook::default(), + ), structured_execution_sync_wait: Duration::from_secs( super::structured_execution::STRUCTURED_EXECUTION_SYNC_WAIT_SECS, ), diff --git a/src/tool_runtime/session_context.rs b/src/tool_runtime/session_context.rs index c07f9784..2e4433e4 100644 --- a/src/tool_runtime/session_context.rs +++ b/src/tool_runtime/session_context.rs @@ -10,6 +10,8 @@ use sha2::{Digest, Sha256}; pub(crate) const SESSION_PROJECT_MISMATCH_KIND: &str = "session_project_mismatch"; pub(crate) const ALLOW_CROSS_PROJECT_SESSION_FIELD: &str = "allow_cross_project_session"; +const SESSION_ATTENTION_MAX_MESSAGES: usize = 3; +const SESSION_ATTENTION_MAX_BODY_BYTES: usize = 3072; #[derive(Debug, Clone)] pub(crate) struct SessionProjectMismatch { @@ -388,6 +390,75 @@ pub(crate) fn add_session_telemetry_hint( result.output = Value::Object(output); } +pub(crate) fn add_session_attention( + result: &mut ToolResult, + sessions: &sessions::SessionStore, + session_id: &str, + ack_message_ids: &[String], +) { + let ack = sessions.observe_message_acks(session_id, ack_message_ids); + let attention = sessions.ack_required_guidance(session_id, &ack.accepted_ids); + let unsuppressed_count = attention.messages.len(); + let mut remaining_bytes = SESSION_ATTENTION_MAX_BODY_BYTES; + let mut messages = Vec::new(); + for message in attention + .messages + .into_iter() + .take(SESSION_ATTENTION_MAX_MESSAGES) + { + if remaining_bytes == 0 { + break; + } + let (body, truncated) = bound_utf8_bytes(&message.message, remaining_bytes); + remaining_bytes = remaining_bytes.saturating_sub(body.len()); + messages.push(json!({ + "message_id": message.message_id, + "kind": message.kind.as_str(), + "priority": message.priority, + "created_at": message.created_at, + "message": body, + "message_truncated": truncated, + })); + } + if attention.total_open_requires_ack == 0 && ack_message_ids.is_empty() { + return; + } + let omitted_count = unsuppressed_count.saturating_sub(messages.len()); + let mut output = match std::mem::take(&mut result.output) { + Value::Object(map) => map, + other => { + let mut map = serde_json::Map::new(); + map.insert("value".to_string(), other); + map + } + }; + output.insert( + "session_attention".to_string(), + json!({ + "requires_ack": attention.total_open_requires_ack > 0, + "messages": messages, + "omitted_count": omitted_count, + "truncated": omitted_count > 0, + "ack": { + "accepted_count": ack.accepted_count, + "ignored_count": ack.ignored_count, + } + }), + ); + result.output = Value::Object(output); +} + +fn bound_utf8_bytes(value: &str, max_bytes: usize) -> (String, bool) { + if value.len() <= max_bytes { + return (value.to_string(), false); + } + let mut end = max_bytes.min(value.len()); + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + (value[..end].to_string(), true) +} + pub(crate) fn is_current_session_eligible(call: &ToolCall) -> bool { call.project().is_some() && runtime_tool_allows_current_session_fallback(call.tool_name()) } diff --git a/src/tool_runtime/session_tools.rs b/src/tool_runtime/session_tools.rs index 7143ea86..d71b3e5e 100644 --- a/src/tool_runtime/session_tools.rs +++ b/src/tool_runtime/session_tools.rs @@ -76,9 +76,17 @@ impl ToolRuntime { tags, reply_to, priority, + requires_ack, } => { self.post_session_message_tool( - session_id, kind, message, tags, reply_to, priority, auth, + session_id, + kind, + message, + tags, + reply_to, + priority, + requires_ack, + auth, ) .await } @@ -530,6 +538,7 @@ impl ToolRuntime { tags: Vec, reply_to: Option, priority: sessions::SessionMessagePriority, + requires_ack: bool, auth: Option<&AuthContext>, ) -> ToolResult { if let Err(result) = self @@ -538,16 +547,17 @@ impl ToolRuntime { { return result; } - match self - .sessions - .post_message(sessions::PostSessionMessageInput { + match self.sessions.post_message_with_ack( + sessions::PostSessionMessageInput { session_id: session_id.clone(), kind, message, tags, reply_to, priority, - }) { + }, + requires_ack, + ) { Ok(message) => ToolResult::ok(json!({ "success": true, "session_id": session_id, diff --git a/src/tool_runtime/sessions/console.rs b/src/tool_runtime/sessions/console.rs index 3ced2bb9..5bb589a1 100644 --- a/src/tool_runtime/sessions/console.rs +++ b/src/tool_runtime/sessions/console.rs @@ -42,6 +42,8 @@ pub(crate) struct WorkflowSessionConsoleListItem { pub(crate) mode: String, pub(crate) updated_at: i64, pub(crate) running_call: bool, + pub(crate) running_jobs: usize, + pub(crate) running_jobs_complete: bool, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) current_activity: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -91,6 +93,18 @@ pub(crate) struct WorkflowSessionConsoleAttentionOverview { pub(crate) open_todos: usize, } +#[derive(Debug, Clone, Serialize)] +pub(crate) struct WorkflowSessionConsoleAggregate { + pub(crate) retained_sessions: usize, + pub(crate) returned_sessions: usize, + pub(crate) sessions_truncated: bool, + pub(crate) active_sessions: usize, + pub(crate) running_sessions: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) latest_updated_at: Option, + pub(crate) attention: WorkflowSessionConsoleAttentionOverview, +} + #[derive(Debug, Clone, Serialize)] pub(crate) struct WorkflowSessionConsoleReportedProgress { pub(crate) reported_at: i64, @@ -123,6 +137,8 @@ pub(crate) struct WorkflowSessionConsoleDetail { pub(crate) created_at: i64, pub(crate) updated_at: i64, pub(crate) running_call: bool, + pub(crate) running_jobs: usize, + pub(crate) running_jobs_complete: bool, pub(crate) overview: WorkflowSessionConsoleOverview, pub(crate) activity: Vec, pub(crate) activity_total: usize, @@ -219,6 +235,8 @@ pub(super) fn build_list_item( mode: record.mode.as_str().to_string(), updated_at: record.updated_at, running_call, + running_jobs: 0, + running_jobs_complete: false, current_activity: current.map(|interaction| activity_preview(interaction, project)), last_activity: last.map(|interaction| activity_preview(interaction, project)), overview, @@ -290,6 +308,8 @@ pub(super) fn build_detail( created_at: record.created_at, updated_at: record.updated_at, running_call, + running_jobs: 0, + running_jobs_complete: false, overview, activity, activity_total, @@ -429,6 +449,54 @@ fn build_overview( } } +pub(crate) fn aggregate_console_list( + list: &WorkflowSessionConsoleList, +) -> WorkflowSessionConsoleAggregate { + let mut attention = WorkflowSessionConsoleAttentionOverview { + open_guidance: 0, + open_questions: 0, + open_risks: 0, + open_todos: 0, + }; + let mut active_sessions = 0usize; + let mut running_sessions = 0usize; + let mut latest_updated_at = None; + for session in &list.sessions { + if session.lifecycle == "active" { + active_sessions = active_sessions.saturating_add(1); + } + if session.running_call || session.running_jobs > 0 { + running_sessions = running_sessions.saturating_add(1); + } + latest_updated_at = Some( + latest_updated_at.map_or(session.updated_at, |current: i64| { + current.max(session.updated_at) + }), + ); + attention.open_guidance = attention + .open_guidance + .saturating_add(session.overview.attention.open_guidance); + attention.open_questions = attention + .open_questions + .saturating_add(session.overview.attention.open_questions); + attention.open_risks = attention + .open_risks + .saturating_add(session.overview.attention.open_risks); + attention.open_todos = attention + .open_todos + .saturating_add(session.overview.attention.open_todos); + } + WorkflowSessionConsoleAggregate { + retained_sessions: list.total, + returned_sessions: list.returned, + sessions_truncated: list.truncated, + active_sessions, + running_sessions, + latest_updated_at, + attention, + } +} + fn event_history_truncated(record: &SessionRecord) -> bool { record.events_observed.max(record.events.len() as u64) > record.events.len() as u64 } @@ -1108,4 +1176,62 @@ mod tests { vec!["Searched", "Read", "Progress", "Progress later"] ); } + + #[test] + fn aggregate_console_list_preserves_bounds_and_attention_counts() { + let overview = WorkflowSessionConsoleOverview { + work: WorkflowSessionConsoleWorkOverview { + exploration: 0, + edits: 0, + reviews: 0, + validations: 0, + runs: 0, + history_complete: true, + history_truncated: false, + }, + validation: WorkflowSessionConsoleValidationOverview { + state: "none".to_string(), + latest_kind: None, + latest_at: None, + unresolved_failure_count: 0, + tests_run_count: None, + history_complete: true, + history_truncated: false, + }, + attention: WorkflowSessionConsoleAttentionOverview { + open_guidance: 1, + open_questions: 2, + open_risks: 3, + open_todos: 4, + }, + reported_progress: None, + }; + let list = WorkflowSessionConsoleList { + sessions: vec![WorkflowSessionConsoleListItem { + session_id: "wc_sess_test".to_string(), + title: "test".to_string(), + lifecycle: "active".to_string(), + mode: "normal".to_string(), + updated_at: 42, + running_call: true, + running_jobs: 0, + running_jobs_complete: true, + current_activity: None, + last_activity: None, + overview, + }], + total: 9, + returned: 1, + truncated: true, + }; + let aggregate = aggregate_console_list(&list); + assert_eq!(aggregate.retained_sessions, 9); + assert_eq!(aggregate.returned_sessions, 1); + assert!(aggregate.sessions_truncated); + assert_eq!(aggregate.active_sessions, 1); + assert_eq!(aggregate.running_sessions, 1); + assert_eq!(aggregate.latest_updated_at, Some(42)); + assert_eq!(aggregate.attention.open_todos, 4); + assert_eq!(aggregate.attention.open_questions, 2); + } } diff --git a/src/tool_runtime/sessions/events.rs b/src/tool_runtime/sessions/events.rs index 2a2a9147..d76c46f7 100644 --- a/src/tool_runtime/sessions/events.rs +++ b/src/tool_runtime/sessions/events.rs @@ -15,11 +15,11 @@ use serde_json::{json, Value}; use super::model::{ PersistentShellEventEvidence, SessionEvent, ToolCallExpectation, ToolCallRecorderMetadata, MAX_OBSERVED_PATHS_PER_EVENT, MAX_VALIDATION_EXCERPT_CHARS, SESSION_ID_PREFIX, - TOOL_ASSERTION_NAME_FIELD, TOOL_CALL_EXPECTATION_METADATA_FIELDS, - TOOL_EXPECTATION_RESULT_MATCHED, TOOL_EXPECTATION_RESULT_MISMATCH, - TOOL_EXPECTATION_RESULT_NONE, TOOL_EXPECTATION_RESULT_UNEXPECTED_FAILURE, - TOOL_EXPECTATION_RESULT_UNEXPECTED_SUCCESS, TOOL_EXPECTED_FAILURE_FIELD, - TOOL_EXPECTED_FAILURE_KIND_FIELD, + TOOL_ASSERTION_NAME_FIELD, TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD, + TOOL_CALL_EXPECTATION_METADATA_FIELDS, TOOL_EXPECTATION_RESULT_MATCHED, + TOOL_EXPECTATION_RESULT_MISMATCH, TOOL_EXPECTATION_RESULT_NONE, + TOOL_EXPECTATION_RESULT_UNEXPECTED_FAILURE, TOOL_EXPECTATION_RESULT_UNEXPECTED_SUCCESS, + TOOL_EXPECTED_FAILURE_FIELD, TOOL_EXPECTED_FAILURE_KIND_FIELD, }; use super::util::redact_and_bound_value; use super::util::{bound_summary_string, validation_excerpt}; @@ -28,6 +28,18 @@ impl ToolCallRecorderMetadata { pub(crate) fn from_arguments(arguments: &Value) -> Self { Self { expectation: tool_call_expectation_from_arguments(arguments), + ack_session_message_ids: arguments + .as_object() + .and_then(|obj| obj.get(TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD)) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), } } } @@ -86,6 +98,7 @@ pub(crate) fn strip_tool_call_expectation_metadata(arguments: Value) -> Value { for &key in TOOL_CALL_EXPECTATION_METADATA_FIELDS { obj.remove(key); } + obj.remove(TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD); Value::Object(obj) } diff --git a/src/tool_runtime/sessions/messages.rs b/src/tool_runtime/sessions/messages.rs index f2453117..a255d96e 100644 --- a/src/tool_runtime/sessions/messages.rs +++ b/src/tool_runtime/sessions/messages.rs @@ -4,9 +4,11 @@ use super::model::{ CompleteSessionMessageInput, CompleteSessionMessageOutcome, ListSessionMessagesFilter, - PostSessionMessageInput, SessionDiscussionSummary, SessionInboxHint, SessionMessage, - SessionMessageError, SessionMessageObservationError, SessionMessageObservationOutcome, - DEFAULT_MESSAGE_LIST_LIMIT, MAX_MESSAGE_LIST_LIMIT, MAX_SESSION_MESSAGE_OBSERVATION_TOKEN_LEN, + PostSessionMessageInput, SessionAckObservation, SessionAttentionSnapshot, + SessionDiscussionSummary, SessionInboxHint, SessionMessage, SessionMessageError, + SessionMessageKind, SessionMessageObservationError, SessionMessageObservationOutcome, + SessionMessagePriority, SessionMessageStatus, DEFAULT_MESSAGE_LIST_LIMIT, + MAX_MESSAGE_LIST_LIMIT, MAX_SESSION_MESSAGE_OBSERVATION_TOKEN_LEN, }; use super::query::{build_discussion_summary, build_inbox_hint}; use super::store::SessionStore; @@ -14,13 +16,22 @@ use base64::{engine::general_purpose, Engine as _}; use sha2::{Digest, Sha256}; impl SessionStore { + #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn post_message( &self, input: PostSessionMessageInput, + ) -> Result { + self.post_message_with_ack(input, false) + } + + pub(crate) fn post_message_with_ack( + &self, + input: PostSessionMessageInput, + requires_ack: bool, ) -> Result { let (message, changed) = { let mut inner = self.inner.lock().expect("session store mutex poisoned"); - inner.post_message(input)? + inner.post_message(input, requires_ack)? }; self.persist_after_mutation(); if changed { @@ -64,6 +75,61 @@ impl SessionStore { .ok_or(SessionMessageError::UnknownSession) } + pub(crate) fn observe_message_acks( + &self, + session_id: &str, + message_ids: &[String], + ) -> SessionAckObservation { + if message_ids.is_empty() { + return SessionAckObservation::default(); + } + let outcome = { + let mut inner = self.inner.lock().expect("session store mutex poisoned"); + inner.observe_message_acks(session_id, message_ids) + }; + if outcome.first_observed_count > 0 { + self.persist_after_mutation(); + self.notify_message_observation(); + } + outcome + } + + pub(crate) fn ack_required_guidance( + &self, + session_id: &str, + suppressed_ids: &[String], + ) -> SessionAttentionSnapshot { + let suppressed = suppressed_ids + .iter() + .map(String::as_str) + .collect::>(); + self.with_record_for_query(session_id, |record, _| { + let mut open = record + .messages + .iter() + .filter(|message| { + message.status == SessionMessageStatus::Open + && message.kind == SessionMessageKind::Guidance + && message.priority == SessionMessagePriority::High + && message.requires_ack + }) + .map(|message| message.as_ref().clone()) + .collect::>(); + open.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.message_id.cmp(&right.message_id)) + }); + let total_open_requires_ack = open.len(); + open.retain(|message| !suppressed.contains(message.message_id.as_str())); + SessionAttentionSnapshot { + messages: open, + total_open_requires_ack, + } + }) + .unwrap_or_default() + } + pub(crate) fn resolve_message( &self, session_id: &str, diff --git a/src/tool_runtime/sessions/mod.rs b/src/tool_runtime/sessions/mod.rs index f28c7bde..0242d27f 100644 --- a/src/tool_runtime/sessions/mod.rs +++ b/src/tool_runtime/sessions/mod.rs @@ -26,7 +26,10 @@ mod tests; // Re-exports keep `crate::tool_runtime::sessions::{...}` stable for callers. // Only symbols referenced outside this module are re-exported here; internal // helpers stay `pub(super)` / module-private. -pub(crate) use console::{WorkflowSessionConsoleDetail, WorkflowSessionConsoleList}; +pub(crate) use console::{ + aggregate_console_list, WorkflowSessionConsoleAggregate, WorkflowSessionConsoleDetail, + WorkflowSessionConsoleList, +}; pub(crate) use events::{ exploration_tool_kind, is_valid_session_id, normalize_observed_project_path, strip_tool_call_expectation_metadata, tool_failure_summary_from_events, @@ -42,7 +45,8 @@ pub(crate) use model::{ SessionMessageObservationError, SessionMessagePriority, SessionMessageStatus, SessionSummary, SessionTransport, ToolCallRecorderMetadata, DEFAULT_MAX_EVENTS_PER_SESSION, DEFAULT_MAX_SESSIONS, MAX_CODING_INSTRUCTION_CHARS, MAX_MESSAGE_COMPLETION_KEY_CHARS, - MAX_MESSAGE_LIST_LIMIT, TOOL_CALL_RECORDING_SESSION_ID_FIELD, + MAX_MESSAGE_LIST_LIMIT, MAX_TOOL_CALL_ACK_MESSAGE_IDS, TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD, + TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD, TOOL_CALL_RECORDING_SESSION_ID_FIELD, TOOL_EXPECTATION_RESULT_UNEXPECTED_FAILURE, }; pub(crate) use store::SessionStore; diff --git a/src/tool_runtime/sessions/model.rs b/src/tool_runtime/sessions/model.rs index bf34c482..0cc18140 100644 --- a/src/tool_runtime/sessions/model.rs +++ b/src/tool_runtime/sessions/model.rs @@ -16,6 +16,13 @@ pub(super) const EVENT_ID_PREFIX: &str = "evt_"; pub(super) const CALL_ID_PREFIX: &str = "wc_call_"; pub(crate) const DEFAULT_MAX_SESSIONS: usize = 100; pub(crate) const DEFAULT_MAX_EVENTS_PER_SESSION: usize = 200; +/// Exact terminal-validation Job identities retained per Workflow Session. This +/// matches the Runner's authoritative terminal Job inventory bound: while a +/// terminal Job can still be a reconciliation candidate, one of these bounded +/// identities can represent it without turning the Session ledger into an +/// unbounded Job-id history. +pub(super) const MAX_MATERIALIZED_VALIDATION_JOB_IDS: usize = + crate::shell_protocol::JOB_INVENTORY_MAX_TERMINAL_JOBS; /// Maximum project-relative exploration paths retained on one ledger event. /// This covers the largest currently supported structured search/LSP result /// while keeping every event independently bounded. @@ -49,6 +56,10 @@ pub(crate) const TOOL_EXPECTATION_RESULT_UNEXPECTED_FAILURE: &str = "unexpected_ pub(crate) const TOOL_EXPECTATION_RESULT_MISMATCH: &str = "expectation_mismatch"; pub(crate) const TOOL_EXPECTATION_RESULT_UNEXPECTED_SUCCESS: &str = "unexpected_success"; pub(crate) const TOOL_CALL_RECORDING_SESSION_ID_FIELD: &str = "recording_session_id"; +pub(crate) const TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD: &str = "ack_session_message_ids"; +pub(crate) const TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD: &str = + "__webcodex_stateless_ack_session_message_ids"; +pub(crate) const MAX_TOOL_CALL_ACK_MESSAGE_IDS: usize = 8; pub(crate) const TOOL_EXPECTED_FAILURE_FIELD: &str = "expected_failure"; pub(crate) const TOOL_EXPECTED_FAILURE_KIND_FIELD: &str = "expected_failure_kind"; pub(crate) const TOOL_ASSERTION_NAME_FIELD: &str = "assertion_name"; @@ -314,6 +325,10 @@ pub(super) struct SessionRecord { /// than are retained now". The persisted counterpart carries the additive /// serde default; the in-memory record is always constructed explicitly. pub(super) events_observed: u64, + /// Bounded durable exact identities for terminal structured-validation Jobs + /// already synthesized into this Session. Independent of the retained event + /// deque so event FIFO eviction cannot resurrect an authoritative Job. + pub(super) materialized_validation_job_ids: VecDeque, pub(super) messages: VecDeque>, /// Durable Session-local monotonic message-state revision. This is never /// exposed as a public cursor; callers receive an opaque Session-bound token. @@ -680,6 +695,10 @@ pub(super) struct PersistedSessionRecord { /// persisted events" for legacy compatibility. #[serde(default)] pub(super) events_observed: u64, + /// Additive ledger-v1 field. Exact identities are sanitized and bounded on + /// restore; old ledgers deserialize to an empty set. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(super) materialized_validation_job_ids: Vec, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] @@ -749,6 +768,7 @@ pub(crate) struct ToolCallExpectation { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct ToolCallRecorderMetadata { pub(crate) expectation: ToolCallExpectation, + pub(crate) ack_session_message_ids: Vec, } #[derive(Debug, Clone, Copy)] @@ -957,6 +977,10 @@ pub(crate) struct SessionMessage { pub(crate) tags: Vec, pub(crate) reply_to: Option, #[serde(default)] + pub(crate) requires_ack: bool, + #[serde(default)] + pub(crate) first_ack_observed_at: Option, + #[serde(default)] pub(crate) author_session_id: Option, pub(crate) resolved_at: Option, pub(crate) resolution: Option, @@ -976,6 +1000,20 @@ pub(crate) struct PostSessionMessageInput { pub(crate) priority: SessionMessagePriority, } +#[derive(Debug, Clone, Default)] +pub(crate) struct SessionAckObservation { + pub(crate) accepted_ids: Vec, + pub(crate) accepted_count: usize, + pub(crate) ignored_count: usize, + pub(crate) first_observed_count: usize, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct SessionAttentionSnapshot { + pub(crate) messages: Vec, + pub(crate) total_open_requires_ack: usize, +} + #[derive(Debug, Clone)] pub(crate) struct CompleteSessionMessageInput { pub(crate) session_id: String, diff --git a/src/tool_runtime/sessions/persistence.rs b/src/tool_runtime/sessions/persistence.rs index 8ba73358..645451c7 100644 --- a/src/tool_runtime/sessions/persistence.rs +++ b/src/tool_runtime/sessions/persistence.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use serde::Deserialize; +use super::super::helpers::is_safe_job_id; use super::super::project_instructions::ProjectInstructionsSummarySnapshot; use super::events::{ exploration_tool_kind, is_valid_session_id, sanitize_failure_expectation_result, @@ -18,8 +19,8 @@ use super::model::{ ColdSessionRecord, DurableCurrentBinding, PersistedCurrentBindings, PersistedSessionLedger, PersistedSessionRecord, SessionEvent, SessionGuards, SessionLifecycle, SessionMessage, SessionRecord, StoredSession, DEFAULT_MAX_MESSAGES_PER_SESSION, EVENT_ID_PREFIX, - MAX_CODING_INSTRUCTION_CHARS, MAX_INPUT_ARRAY_ITEMS, MAX_MESSAGE_CHARS, - MAX_MESSAGE_RESOLUTION_CHARS, MESSAGE_ID_PREFIX, SESSION_LEDGER_VERSION, + MAX_CODING_INSTRUCTION_CHARS, MAX_INPUT_ARRAY_ITEMS, MAX_MATERIALIZED_VALIDATION_JOB_IDS, + MAX_MESSAGE_CHARS, MAX_MESSAGE_RESOLUTION_CHARS, MESSAGE_ID_PREFIX, SESSION_LEDGER_VERSION, }; use super::query::{is_valid_completion_id, validate_message_tags}; use super::util::{ @@ -69,6 +70,11 @@ impl PersistedSessionRecord { events, messages, events_observed: record.events_observed, + materialized_validation_job_ids: record + .materialized_validation_job_ids + .iter() + .cloned() + .collect(), message_observation_revision: record.message_observation_revision, message_observation_floor: record.message_observation_floor, message_observation_revisions: record.message_observation_revisions.clone(), @@ -87,6 +93,10 @@ impl PersistedSessionRecord { && self.created_at == record.created_at && self.updated_at == record.updated_at && self.events_observed == record.events_observed + && self + .materialized_validation_job_ids + .iter() + .eq(record.materialized_validation_job_ids.iter()) && self.message_observation_revision == record.message_observation_revision && self.message_observation_floor == record.message_observation_floor && self.message_observation_revisions == record.message_observation_revisions @@ -224,6 +234,8 @@ impl PersistedSessionRecord { ); observation_floor = 0; } + let materialized_validation_job_ids = + sanitize_materialized_validation_job_ids(self.materialized_validation_job_ids); // On restore, `events_observed` is at least the count of events we just // retained, so a freshly-restored legacy ledger does not falsely report // eviction. A live ledger that exceeded the cap has the true cumulative @@ -251,6 +263,7 @@ impl PersistedSessionRecord { updated_at: self.updated_at.max(self.created_at), events, events_observed: self.events_observed.max(retained_events), + materialized_validation_job_ids, messages, project_instructions: None, message_observation_revision: current_observation_revision, @@ -260,6 +273,28 @@ impl PersistedSessionRecord { } } +fn sanitize_materialized_validation_job_ids(values: Vec) -> VecDeque { + // Canonical writers keep this exact set bounded to the authoritative Runner + // terminal inventory. Restore is deliberately tolerant of legacy/corrupt + // semantic entries: malformed or duplicate ids never become suppression + // authority, and an oversized list keeps only the newest valid identities. + let mut seen = HashSet::new(); + let mut newest = values + .into_iter() + .rev() + .filter_map(|value| { + let trimmed = value.trim(); + if trimmed != value || !is_safe_job_id(trimmed) || !seen.insert(value.clone()) { + return None; + } + Some(value) + }) + .take(MAX_MATERIALIZED_VALIDATION_JOB_IDS) + .collect::>(); + newest.reverse(); + newest.into() +} + // Preserve the distinction between a genuinely absent legacy field and a // present-but-malformed value without retaining attacker-controlled material. // The marker is internal ledger state, is never a valid canonical fingerprint, @@ -630,6 +665,15 @@ pub(super) fn sanitize_persisted_message( } message.message = bound_chars(message.message.trim(), MAX_MESSAGE_CHARS); message.tags = validate_message_tags(message.tags).unwrap_or_default(); + if message.requires_ack + && (message.kind != super::model::SessionMessageKind::Guidance + || message.priority != super::model::SessionMessagePriority::High) + { + message.requires_ack = false; + } + message.first_ack_observed_at = message + .first_ack_observed_at + .filter(|value| *value > 0 && message.requires_ack); message.reply_to = message.reply_to.and_then(|reply_to| { let reply_to = reply_to.trim().to_string(); if reply_to.starts_with(MESSAGE_ID_PREFIX) { diff --git a/src/tool_runtime/sessions/store.rs b/src/tool_runtime/sessions/store.rs index d3ec129a..7f86da8f 100644 --- a/src/tool_runtime/sessions/store.rs +++ b/src/tool_runtime/sessions/store.rs @@ -37,8 +37,8 @@ use super::model::{ StoredSession, ToolCallRecorderMetadata, ToolCallStart, ToolEffectEventEvidence, CALL_ID_PREFIX, DEFAULT_MAX_EVENTS_PER_SESSION, DEFAULT_MAX_MESSAGES_PER_SESSION, DEFAULT_MAX_SESSIONS, DEFAULT_SUMMARY_LIMIT, DURABLE_CURRENT_BINDINGS_PER_SESSION, - EVENT_ID_PREFIX, MAX_CODING_INSTRUCTION_CHARS, MAX_SUMMARY_LIMIT, MESSAGE_ID_PREFIX, - SESSION_ID_PREFIX, SESSION_LEDGER_VERSION, + EVENT_ID_PREFIX, MAX_CODING_INSTRUCTION_CHARS, MAX_MATERIALIZED_VALIDATION_JOB_IDS, + MAX_SUMMARY_LIMIT, MESSAGE_ID_PREFIX, SESSION_ID_PREFIX, SESSION_LEDGER_VERSION, }; use super::persistence::{ cold_session_from_persisted, load_persisted_ledger, materialize_cold_session, @@ -554,6 +554,7 @@ impl SessionStore { messages: VecDeque::new(), events: VecDeque::new(), events_observed: 0, + materialized_validation_job_ids: VecDeque::new(), message_observation_revision: 0, message_observation_floor: 0, message_observation_revisions: Default::default(), @@ -843,6 +844,7 @@ impl SessionStore { messages: VecDeque::new(), events: VecDeque::from([Arc::new(event)]), events_observed: 1, + materialized_validation_job_ids: VecDeque::new(), message_observation_revision: 0, message_observation_floor: 0, message_observation_revisions: Default::default(), @@ -1543,6 +1545,220 @@ impl SessionStore { Some(event_id) } + #[allow(clippy::too_many_arguments)] + pub(crate) fn record_validation_job_terminal( + &self, + session_id: &str, + job_id: &str, + retained_terminal_job_ids: &[&str], + tool_name: &str, + project: Option, + validation_target_id: &str, + job_status: &str, + exit_code: Option, + started_at: Option, + finished_at: Option, + duration_ms: Option, + validation_output_summary: Option, + ) -> bool { + let session_id = session_id.trim(); + let job_id = job_id.trim(); + let valid_target = validation_target_id + .strip_prefix("target:") + .is_some_and(|suffix| { + suffix.len() == 24 && suffix.as_bytes().iter().all(u8::is_ascii_hexdigit) + }); + let Some(timestamp) = finished_at else { + // Reconciliation must never substitute wall-clock read time for + // authoritative execution activity. + return false; + }; + if !is_valid_session_id(session_id) + || !super::super::helpers::is_safe_job_id(job_id) + || retained_terminal_job_ids.len() > MAX_MATERIALIZED_VALIDATION_JOB_IDS + || retained_terminal_job_ids.iter().any(|candidate| { + *candidate != candidate.trim() || !super::super::helpers::is_safe_job_id(candidate) + }) + || !retained_terminal_job_ids + .iter() + .any(|candidate| *candidate == job_id) + || !matches!( + tool_name, + "cargo_fmt" | "cargo_check" | "cargo_test" | "go_test" + ) + || !valid_target + || !matches!( + job_status, + "completed" | "failed" | "timeout" | "timed_out" | "stopped" | "cancelled" | "lost" + ) + { + return false; + } + let succeeded = job_status == "completed" && exit_code == Some(0); + let failure_kind = (!succeeded).then(|| match job_status { + "timeout" | "timed_out" => "timeout".to_string(), + "stopped" | "cancelled" => "cancelled".to_string(), + "lost" => "execution_lost".to_string(), + _ => "command_exit_nonzero".to_string(), + }); + let classification = SessionToolClassification::for_tool(tool_name); + let event = SessionEvent { + event_id: format!("{EVENT_ID_PREFIX}{}", uuid::Uuid::new_v4().simple()), + call_id: None, + session_id: session_id.to_string(), + kind: "validation_job_terminal".to_string(), + timestamp, + transport: "job_terminal".to_string(), + tool_name: tool_name.to_string(), + project: project.clone(), + resolved_project: project.clone(), + risk_class: classification.risk_class.to_string(), + read_like: classification.read_like, + write_like: classification.write_like, + shell_like: classification.shell_like, + git_like: classification.git_like, + change_summary_like: classification.change_summary_like, + diff_review_like: false, + started_at, + finished_at: Some(timestamp), + duration_ms, + status: Some(if succeeded { "succeeded" } else { "failed" }.to_string()), + exit_code, + failure_kind, + error_kind: None, + expected_failure: None, + expected_failure_kind: None, + assertion_name: None, + actual_failure_kind: None, + failure_expectation_result: None, + warning_kind: None, + session_project: None, + request_project: None, + allow_cross_project_session_required: None, + allow_cross_project_session: None, + error_message_summary: None, + changed_paths: Vec::new(), + observed_paths: Vec::new(), + job_id: Some(job_id.to_string()), + persistent_shell: None, + effect_evidence: None, + input_summary: Some(serde_json::json!({ + "validation_target_id": validation_target_id, + })), + validation_output_summary, + permission: None, + instruction: None, + requested_mode: None, + previous_mode: None, + requested_guards: None, + previous_guards: None, + capability_changed: None, + context_refreshed: None, + execution_context: None, + previous_execution_context: None, + execution_context_changed: None, + }; + + let (cold, hot_closed, recorded) = { + let mut inner = self.inner.lock().expect("session store mutex poisoned"); + let max_events = inner.max_events_per_session; + let Some(stored) = inner.sessions.get_mut(session_id) else { + return false; + }; + match stored { + StoredSession::Hot(record) => { + let recorded = Self::append_validation_job_terminal_to_record( + record, + job_id, + retained_terminal_job_ids, + project.as_deref(), + &event, + timestamp, + max_events, + ); + let hot_closed = recorded && !record.lifecycle.allows_mutation(); + (None, hot_closed, recorded) + } + StoredSession::Cold(record) => (Some(record.clone()), false, false), + } + }; + let recorded = match cold { + Some(cold) => { + self.rewrite_cold_record(session_id, cold, false, |record, max_events| { + Self::append_validation_job_terminal_to_record( + record, + job_id, + retained_terminal_job_ids, + project.as_deref(), + &event, + timestamp, + max_events, + ) + }) + } + None => recorded, + }; + if !recorded { + return false; + } + if hot_closed { + self.coldify_closed_session(session_id); + } + self.persist_after_mutation(); + true + } + + fn append_validation_job_terminal_to_record( + record: &mut SessionRecord, + job_id: &str, + retained_terminal_job_ids: &[&str], + project: Option<&str>, + event: &SessionEvent, + timestamp: i64, + max_events: usize, + ) -> bool { + if record.project.as_deref() != project + || record + .materialized_validation_job_ids + .iter() + .any(|materialized| materialized == job_id) + { + return false; + } + + // The Runner can retain at most 64 authoritative terminal Jobs. Keep an + // exact marker for every Job still present in this reconciliation snapshot; + // if stale markers fill the bound, discard one that the authoritative + // snapshot can no longer name before inserting the new identity. + while record.materialized_validation_job_ids.len() >= MAX_MATERIALIZED_VALIDATION_JOB_IDS { + let Some(stale_index) = + record + .materialized_validation_job_ids + .iter() + .position(|materialized| { + !retained_terminal_job_ids + .iter() + .any(|candidate| *candidate == materialized.as_str()) + }) + else { + // A complete valid terminal snapshot cannot name more than the + // bound. Fail closed rather than evicting a still-retained Job. + return false; + }; + record.materialized_validation_job_ids.remove(stale_index); + } + record + .materialized_validation_job_ids + .push_back(job_id.to_string()); + record.updated_at = record.updated_at.max(timestamp); + record.events.push_back(Arc::new(event.clone())); + record.events_observed = record.events_observed.saturating_add(1); + while record.events.len() > max_events { + record.events.pop_front(); + } + true + } + /// Sole entry for appending a session ledger event. fn push_event(&self, event: SessionEvent) { let session_id = event.session_id.clone(); @@ -2359,6 +2575,7 @@ impl SessionStoreInner { pub(super) fn post_message( &mut self, input: PostSessionMessageInput, + requires_ack: bool, ) -> Result<(SessionMessage, bool), SessionMessageError> { self.touch(&input.session_id); let Some(stored) = self.sessions.get_mut(&input.session_id) else { @@ -2371,6 +2588,14 @@ impl SessionStoreInner { let record = stored .hot_mut() .expect("active session message mutation must stay hot"); + if requires_ack + && (input.kind != super::model::SessionMessageKind::Guidance + || input.priority != super::model::SessionMessagePriority::High) + { + return Err(SessionMessageError::InvalidInput( + "requires_ack is only valid for high-priority guidance".to_string(), + )); + } let message = validate_message_text(input.message)?; let tags = validate_message_tags(input.tags)?; if let Some(reply_to) = input.reply_to.as_deref() { @@ -2393,6 +2618,8 @@ impl SessionStoreInner { message, tags, reply_to: input.reply_to, + requires_ack, + first_ack_observed_at: None, author_session_id: None, resolved_at: None, resolution: None, @@ -2413,6 +2640,61 @@ impl SessionStoreInner { Ok((message, true)) } + pub(super) fn observe_message_acks( + &mut self, + session_id: &str, + message_ids: &[String], + ) -> super::model::SessionAckObservation { + let Some(stored) = self.sessions.get_mut(session_id) else { + return super::model::SessionAckObservation { + ignored_count: message_ids.len(), + ..Default::default() + }; + }; + let Some(record) = stored.hot_mut() else { + return super::model::SessionAckObservation { + ignored_count: message_ids.len(), + ..Default::default() + }; + }; + let mut outcome = super::model::SessionAckObservation::default(); + let mut seen = std::collections::HashSet::new(); + let now = now_ts(); + for message_id in message_ids { + if !seen.insert(message_id.as_str()) { + continue; + } + let Some(index) = record.messages.iter().position(|message| { + message.message_id == *message_id + && message.status == SessionMessageStatus::Open + && message.kind == super::model::SessionMessageKind::Guidance + && message.priority == super::model::SessionMessagePriority::High + && message.requires_ack + }) else { + outcome.ignored_count += 1; + continue; + }; + outcome.accepted_count += 1; + outcome.accepted_ids.push(message_id.clone()); + if record.messages[index].first_ack_observed_at.is_none() { + let Ok(revision) = Self::next_message_observation_revision(record) else { + outcome.accepted_ids.pop(); + outcome.accepted_count = outcome.accepted_count.saturating_sub(1); + outcome.ignored_count += 1; + continue; + }; + let message = Arc::make_mut(&mut record.messages[index]); + message.first_ack_observed_at = Some(now); + record + .message_observation_revisions + .insert(message.message_id.clone(), revision); + record.updated_at = now; + outcome.first_observed_count += 1; + } + } + outcome + } + /// Resolve an open message. Already-resolved messages stay resolved /// (status is not reopened); an optional new resolution text may update. pub(super) fn resolve_message( @@ -2576,6 +2858,8 @@ impl SessionStoreInner { message: answer_text, tags, reply_to: Some(input.message_id.clone()), + requires_ack: false, + first_ack_observed_at: None, author_session_id: input.author_session_id, resolved_at: None, resolution: None, diff --git a/src/tool_runtime/tests/collaboration.rs b/src/tool_runtime/tests/collaboration.rs index 01c708af..9ac19292 100644 --- a/src/tool_runtime/tests/collaboration.rs +++ b/src/tool_runtime/tests/collaboration.rs @@ -102,6 +102,297 @@ fn start_authorized_project_session( .unwrap() } +fn start_authorized_unscoped_session( + runtime: &ToolRuntime, + title: &str, + auth: &AuthContext, +) -> sessions::SessionSummary { + let fingerprint = + super::super::session_context::workflow_session_authority_fingerprint(Some(auth)) + .expect("test authority must have a stable identity"); + runtime + .sessions + .start_session_with_options( + sessions::SessionCreateOptions::new( + None, + Some(title.to_string()), + super::super::SessionMode::Normal, + sessions::SessionGuards::default(), + ) + .with_owner_authority_fingerprint(Some(fingerprint)), + ) + .unwrap() +} + +#[tokio::test] +async fn request_scoped_ack_suppresses_only_current_response_and_records_first_observation_once() { + let runtime = test_runtime(); + let auth = auth_context(None, true); + let session = start_authorized_unscoped_session(&runtime, "ack recorder", &auth); + let foreign = start_authorized_unscoped_session(&runtime, "foreign ack", &auth); + let guidance = runtime + .sessions + .post_message_with_ack( + PostSessionMessageInput { + session_id: session.session_id.clone(), + kind: SessionMessageKind::Guidance, + message: "Keep the compatibility fence intact.".to_string(), + tags: Vec::new(), + reply_to: None, + priority: SessionMessagePriority::High, + }, + true, + ) + .unwrap(); + let foreign_guidance = runtime + .sessions + .post_message_with_ack( + PostSessionMessageInput { + session_id: foreign.session_id.clone(), + kind: SessionMessageKind::Guidance, + message: "foreign secret guidance".to_string(), + tags: Vec::new(), + reply_to: None, + priority: SessionMessagePriority::High, + }, + true, + ) + .unwrap(); + + let first = call_with_recorder( + &runtime, + "list_tools", + json!({}), + Some(&session.session_id), + &auth, + None, + ) + .await; + assert!(first.success, "{:?}", first.error); + assert_eq!( + first.output["session_attention"]["messages"][0]["message_id"], + guidance.message_id + ); + assert_eq!( + first.output["session_attention"]["messages"][0]["message"], + "Keep the compatibility fence intact." + ); + + let acknowledged = call_with_recorder( + &runtime, + "list_tools", + json!({ + sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD: [guidance.message_id] + }), + Some(&session.session_id), + &auth, + None, + ) + .await; + assert!(acknowledged.success, "{:?}", acknowledged.error); + assert_eq!( + acknowledged.output["session_attention"]["ack"]["accepted_count"], + 1 + ); + assert!(acknowledged.output["session_attention"]["messages"] + .as_array() + .unwrap() + .is_empty()); + let stored = runtime + .sessions + .list_messages( + &session.session_id, + sessions::ListSessionMessagesFilter { + message_id: Some(guidance.message_id.clone()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(stored[0].status, sessions::SessionMessageStatus::Open); + let first_ack_at = stored[0] + .first_ack_observed_at + .expect("first ACK timestamp"); + let after_first_ack = runtime + .sessions + .observe_messages(&session.session_id, None, None, None) + .await + .unwrap(); + + let repeated = call_with_recorder( + &runtime, + "list_tools", + json!({ + sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD: [guidance.message_id] + }), + Some(&session.session_id), + &auth, + None, + ) + .await; + assert!(repeated.success); + let after_repeat = runtime + .sessions + .observe_messages( + &session.session_id, + Some(&after_first_ack.observation_token), + None, + None, + ) + .await + .unwrap(); + assert!( + !after_repeat.changed, + "repeated ACK must not churn observation revision" + ); + let stored_after_repeat = runtime + .sessions + .list_messages( + &session.session_id, + sessions::ListSessionMessagesFilter { + message_id: Some(guidance.message_id.clone()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + stored_after_repeat[0].first_ack_observed_at, + Some(first_ack_at) + ); + + let forgotten = call_with_recorder( + &runtime, + "list_tools", + json!({}), + Some(&session.session_id), + &auth, + None, + ) + .await; + assert!(forgotten.success, "business tool must still execute"); + assert_eq!( + forgotten.output["session_attention"]["messages"][0]["message_id"], + guidance.message_id + ); + + let foreign_ack = call_with_recorder( + &runtime, + "list_tools", + json!({ + sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD: [foreign_guidance.message_id, "wc_msg_unknown"] + }), + Some(&session.session_id), + &auth, + None, + ) + .await; + assert!(foreign_ack.success, "ignored ACK must not block the tool"); + assert_eq!( + foreign_ack.output["session_attention"]["ack"]["accepted_count"], + 0 + ); + assert_eq!( + foreign_ack.output["session_attention"]["ack"]["ignored_count"], + 2 + ); + assert_eq!( + foreign_ack.output["session_attention"]["messages"][0]["message_id"], + guidance.message_id + ); + let foreign_stored = runtime + .sessions + .list_messages( + &foreign.session_id, + sessions::ListSessionMessagesFilter { + message_id: Some(foreign_guidance.message_id), + ..Default::default() + }, + ) + .unwrap(); + assert!(foreign_stored[0].first_ack_observed_at.is_none()); + + runtime + .sessions + .resolve_message( + &session.session_id, + &guidance.message_id, + Some("handled".to_string()), + ) + .unwrap(); + let resolved = call_with_recorder( + &runtime, + "list_tools", + json!({}), + Some(&session.session_id), + &auth, + None, + ) + .await; + assert!(resolved.success); + assert!(resolved.output.get("session_attention").is_none()); +} + +#[test] +fn urgent_guidance_attention_is_bounded_safe_and_also_decorates_failure_results() { + let runtime = test_runtime(); + let session = runtime + .sessions + .start_session(None, Some("attention bounds".to_string())); + for index in 0..5 { + runtime + .sessions + .post_message_with_ack( + PostSessionMessageInput { + session_id: session.session_id.clone(), + kind: SessionMessageKind::Guidance, + message: format!("guidance-{index}-{}", "x".repeat(1800)), + tags: vec!["must-not-piggyback".to_string()], + reply_to: None, + priority: SessionMessagePriority::High, + }, + true, + ) + .unwrap(); + } + let mut failed = super::super::ToolResult::err_with_output( + "synthetic failure", + json!({"error_kind": "synthetic"}), + ); + super::super::session_context::add_session_attention( + &mut failed, + &runtime.sessions, + &session.session_id, + &[], + ); + assert!(!failed.success); + assert_eq!(failed.output["error_kind"], "synthetic"); + let attention = &failed.output["session_attention"]; + assert_eq!(attention["requires_ack"], true); + assert_eq!(attention["messages"].as_array().unwrap().len(), 2); + assert_eq!(attention["omitted_count"], 3); + assert_eq!(attention["truncated"], true); + let body_bytes: usize = attention["messages"] + .as_array() + .unwrap() + .iter() + .map(|message| message["message"].as_str().unwrap().len()) + .sum(); + assert!(body_bytes <= 3072); + let serialized = serde_json::to_string(attention).unwrap(); + assert!(!serialized.contains("must-not-piggyback")); + for forbidden in [ + "tags", + "completion_id", + "completion_key", + "tool_arguments", + "credentials", + ] { + assert!( + !serialized.contains(forbidden), + "leaked {forbidden}: {serialized}" + ); + } +} + #[tokio::test] async fn observe_session_messages_collaboration_recorder_target_scope_fences() { let runtime = runtime_with_resolver_projects().await; diff --git a/src/tool_runtime/tests/sessions.rs b/src/tool_runtime/tests/sessions.rs index b5a31359..81f7c986 100644 --- a/src/tool_runtime/tests/sessions.rs +++ b/src/tool_runtime/tests/sessions.rs @@ -22,6 +22,7 @@ async fn post_session_message( tags: Vec::new(), reply_to: None, priority, + requires_ack: false, }) .await; assert!(result.success, "{:?}", result.error); @@ -629,6 +630,7 @@ async fn closed_session_blocks_write_tools_and_message_post() { tags: Vec::new(), reply_to: None, priority: SessionMessagePriority::Normal, + requires_ack: false, }) .await; assert!(!post.success); diff --git a/src/tool_runtime/tests/validation_events.rs b/src/tool_runtime/tests/validation_events.rs index b0d3645a..f56b2a7e 100644 --- a/src/tool_runtime/tests/validation_events.rs +++ b/src/tool_runtime/tests/validation_events.rs @@ -829,6 +829,216 @@ fn same_validation_identity_success_resolves_failure_without_deleting_history() assert_eq!(validation["events_total"], 2); } +#[test] +fn validation_job_terminal_identity_survives_event_eviction_and_restart_without_refreshing_activity( +) { + let dir = tempfile::tempdir().unwrap(); + let ledger = dir.path().join("sessions.json"); + let store = SessionStore::with_persistence(&ledger, 10, 4); + let session = store.start_session(Some("agent:eval:demo".to_string()), None); + let target = "target:aaaaaaaaaaaaaaaaaaaaaaaa"; + let job_id = "job_terminal_success"; + let retained = [job_id]; + record_finished_tool( + &store, + &session.session_id, + "cargo_check", + json!({ + "project": "agent:eval:demo", + "validation_target_id": target, + }), + false, + json!({"exit_code": 101}), + ); + let before_materialize = store.summary(&session.session_id, None).unwrap(); + let authoritative_finished_at = before_materialize.updated_at; + // Force reconciliation wall-clock time into a later second. Synthetic Job + // terminal evidence must still use the authoritative execution timestamp. + std::thread::sleep(std::time::Duration::from_millis(1100)); + assert!(store.record_validation_job_terminal( + &session.session_id, + job_id, + &retained, + "cargo_check", + Some("agent:eval:demo".to_string()), + target, + "completed", + Some(0), + Some(authoritative_finished_at.saturating_sub(1)), + Some(authoritative_finished_at), + Some(1000), + None, + )); + + let materialized = store.summary(&session.session_id, None).unwrap(); + assert_eq!(materialized.updated_at, authoritative_finished_at); + let validation = validation_summary_for_session(&materialized); + assert_eq!(validation["historical_failures"]["count"], 1); + assert_eq!(validation["resolved_failures"]["count"], 1); + assert_eq!(validation["unresolved_failures"]["count"], 0); + assert_eq!( + materialized + .events + .iter() + .filter(|event| event.kind == "validation_job_terminal") + .count(), + 1 + ); + + // Push well beyond this test store's four-event retention cap. The durable + // materialization identity must outlive the evidence event itself while the + // authoritative Job can still be a reconciliation candidate. + for index in 0..3 { + record_finished_tool( + &store, + &session.session_id, + "read_file", + json!({"project": "agent:eval:demo", "path": format!("src/{index}.rs")}), + true, + json!({}), + ); + } + let after_eviction = store.summary(&session.session_id, None).unwrap(); + assert!(after_eviction + .events + .iter() + .all(|event| event.kind != "validation_job_terminal")); + let events_total_before_repeat = after_eviction.events_total; + let updated_at_before_repeat = after_eviction.updated_at; + assert!( + !store.record_validation_job_terminal( + &session.session_id, + job_id, + &retained, + "cargo_check", + Some("agent:eval:demo".to_string()), + target, + "completed", + Some(0), + Some(authoritative_finished_at.saturating_sub(1)), + Some(authoritative_finished_at), + Some(1000), + None, + ), + "FIFO eviction must not make an authoritative terminal Job materializable again" + ); + let after_repeat = store.summary(&session.session_id, None).unwrap(); + assert_eq!(after_repeat.events_total, events_total_before_repeat); + assert_eq!(after_repeat.updated_at, updated_at_before_repeat); + assert!(after_repeat + .events + .iter() + .all(|event| event.kind != "validation_job_terminal")); + + store.flush_persistence(); + drop(store); + let restored = SessionStore::with_persistence(&ledger, 10, 4); + let restored_before_repeat = restored.summary(&session.session_id, None).unwrap(); + assert!(restored_before_repeat + .events + .iter() + .all(|event| event.kind != "validation_job_terminal")); + assert!( + !restored.record_validation_job_terminal( + &session.session_id, + job_id, + &retained, + "cargo_check", + Some("agent:eval:demo".to_string()), + target, + "completed", + Some(0), + Some(authoritative_finished_at.saturating_sub(1)), + Some(authoritative_finished_at), + Some(1000), + None, + ), + "restart within authoritative Job retention must preserve idempotence" + ); + let restored_after_repeat = restored.summary(&session.session_id, None).unwrap(); + assert_eq!( + restored_after_repeat.events_total, + restored_before_repeat.events_total + ); + assert_eq!( + restored_after_repeat.updated_at, + restored_before_repeat.updated_at + ); +} + +#[test] +fn persisted_validation_job_materialization_ids_are_additive_sanitized_and_bounded() { + let dir = tempfile::tempdir().unwrap(); + let ledger = dir.path().join("sessions.json"); + let store = SessionStore::with_persistence(&ledger, 10, 10); + let session = store.start_session(Some("agent:eval:demo".to_string()), None); + store.flush_persistence(); + drop(store); + + let mut ledger_json: Value = + serde_json::from_str(&std::fs::read_to_string(&ledger).unwrap()).unwrap(); + let record = ledger_json["sessions"][0].as_object_mut().unwrap(); + // Missing field remains valid for pre-feature ledgers. + record.remove("materialized_validation_job_ids"); + std::fs::write(&ledger, serde_json::to_vec(&ledger_json).unwrap()).unwrap(); + let legacy = SessionStore::with_persistence(&ledger, 10, 10); + assert!(legacy.summary(&session.session_id, None).is_some()); + legacy.flush_persistence(); + drop(legacy); + + let mut ledger_json: Value = + serde_json::from_str(&std::fs::read_to_string(&ledger).unwrap()).unwrap(); + let record = ledger_json["sessions"][0].as_object_mut().unwrap(); + let mut ids = (0..70) + .map(|index| Value::String(format!("restored-job-{index:02}"))) + .collect::>(); + ids.extend([ + Value::String("bad/id".to_string()), + Value::String(" padded-job".to_string()), + Value::String("duplicate-job".to_string()), + Value::String("duplicate-job".to_string()), + ]); + record.insert( + "materialized_validation_job_ids".to_string(), + Value::Array(ids), + ); + std::fs::write(&ledger, serde_json::to_vec(&ledger_json).unwrap()).unwrap(); + + let restored = SessionStore::with_persistence(&ledger, 10, 10); + record_finished_tool( + &restored, + &session.session_id, + "read_file", + json!({"project": "agent:eval:demo", "path": "src/sanitize.rs"}), + true, + json!({}), + ); + restored.flush_persistence(); + drop(restored); + let canonical: Value = + serde_json::from_str(&std::fs::read_to_string(&ledger).unwrap()).unwrap(); + let ids = canonical["sessions"][0]["materialized_validation_job_ids"] + .as_array() + .unwrap(); + assert_eq!( + ids.len(), + crate::shell_protocol::JOB_INVENTORY_MAX_TERMINAL_JOBS + ); + assert!(ids.iter().all(|value| { + value + .as_str() + .is_some_and(crate::tool_runtime::helpers::is_safe_job_id) + })); + assert_eq!(ids.first().and_then(Value::as_str), Some("restored-job-07")); + assert_eq!(ids.last().and_then(Value::as_str), Some("duplicate-job")); + assert_eq!( + ids.iter() + .filter(|value| value.as_str() == Some("duplicate-job")) + .count(), + 1 + ); +} + #[test] fn structured_validation_target_resolves_equivalent_semantic_arguments() { let store = SessionStore::default(); diff --git a/src/tool_runtime/tests/validation_handoff.rs b/src/tool_runtime/tests/validation_handoff.rs index c41b6c6e..31f752f8 100644 --- a/src/tool_runtime/tests/validation_handoff.rs +++ b/src/tool_runtime/tests/validation_handoff.rs @@ -7,10 +7,14 @@ //! (mutating) never auto-promotes. use super::support::*; +use crate::shell_client::{ShellJobStartMetadata, ShellJobVisibility}; use crate::shell_protocol::{ ShellAgentJobUpdateRequest, ShellAgentResultPayload, ShellAgentResultRequest, - ShellClientCapabilities, ShellCommandExecutionState, ShellJobValidationProgress, + ShellClientCapabilities, ShellCommandExecutionState, ShellJobOpRequest, + ShellJobValidationMetadata, ShellJobValidationProgress, ShellJobValidationStep, + JOB_INVENTORY_MAX_TERMINAL_JOBS, }; +use crate::tool_runtime::sessions::{SessionTransport, DEFAULT_MAX_EVENTS_PER_SESSION}; #[cfg(unix)] use crate::tool_runtime::tool_inputs::ExecutionPurpose; use crate::tool_runtime::validation_events::validation_summary_for_session; @@ -146,6 +150,118 @@ fn completed_progress() -> ShellJobValidationProgress { } } +#[derive(Clone)] +struct SeededTerminalValidationJob { + job_id: String, + validation_target_id: String, + ended_at: i64, +} + +async fn seed_retained_terminal_validation_job( + runtime: &ToolRuntime, + client_id: &str, + project: &str, + session_id: &str, + ordinal: u64, +) -> SeededTerminalValidationJob { + let validation_target_id = format!("target:{ordinal:024x}"); + let step = ShellJobValidationStep { + name: "check".to_string(), + program: "cargo".to_string(), + args: vec!["check".to_string(), "--all-targets".to_string()], + env: Vec::new(), + }; + let job = runtime + .shell_clients + .start_job_with_metadata( + ShellJobOpRequest { + op: "start".to_string(), + client_id: Some(client_id.to_string()), + cwd: Some("/tmp/agent-proj".to_string()), + command: Some("cargo check --all-targets".to_string()), + timeout_secs: Some(600), + job_id: None, + since_stdout_line: None, + since_stderr_line: None, + tail_lines: None, + limit: None, + codex: None, + }, + "validation-stale-snapshot-test".to_string(), + ShellJobStartMetadata { + project_id: Some(project.to_string()), + session_id: Some(session_id.to_string()), + project_cwd: Some("/tmp/agent-proj".to_string()), + purpose: Some("validation".to_string()), + shell: Some("bash".to_string()), + validation_steps: vec![step.clone()], + validation: Some(ShellJobValidationMetadata { + tool: "cargo_check".to_string(), + kind: "check".to_string(), + steps: vec![step], + effective_timeout_secs: 600, + sync_wait_secs: 10, + adapter: "cargo_check".to_string(), + validation_target_id: Some(validation_target_id.clone()), + }), + visibility: ShellJobVisibility::Public, + ..Default::default() + }, + ) + .await + .unwrap(); + let request = wait_for_patch_agent_request(runtime, client_id).await; + assert_eq!(request.kind, "start_validation_job"); + assert_eq!(request.job_id.as_deref(), Some(job.job_id.as_str())); + runtime + .shell_clients + .update_job(cargo_test_update( + client_id, + &request.request_id, + &job.job_id, + "running", + "Checking seeded v0.1.0\n", + "", + None, + running_progress("check"), + false, + )) + .await + .unwrap(); + runtime + .shell_clients + .update_job(cargo_test_update( + client_id, + &request.request_id, + &job.job_id, + "completed", + "Finished `dev` profile [unoptimized + debuginfo] target(s)\n", + "", + Some(0), + completed_progress(), + true, + )) + .await + .unwrap(); + let status = runtime + .job_status_for_auth(job.job_id.clone(), false, None) + .await; + assert!(status.success, "{:?}", status.error); + assert_eq!(status.output["terminal"], true); + assert_eq!( + status.output["validation"]["validation_target_id"], + validation_target_id + ); + let ended_at = status.output["ended_at"] + .as_i64() + .expect("terminal seeded Job must expose ended_at"); + SeededTerminalValidationJob { + job_id: job.job_id, + validation_target_id, + ended_at, + } +} + fn assert_cargo_result_matches_schema(tool_name: &str, result: &crate::tool_runtime::ToolResult) { use crate::tool_runtime::registry::output_schema_for_tool; use crate::tool_runtime::startup_brief::validate_schema_instance_for_test; @@ -1303,6 +1419,406 @@ async fn handoff_job_terminal_success_produces_passed_validation_summary() { ); } +#[tokio::test] +async fn stale_validation_terminal_snapshot_cannot_evict_newer_materialization_marker() { + let client_id = "vhandoff-stale-terminal-snapshot"; + let runtime = runtime_with_agent_project(client_id); + register_agent( + &runtime, + client_id, + None, + ShellClientCapabilities { + async_shell_jobs: true, + structured_validation_argv: true, + ..Default::default() + }, + ) + .await; + let project = agent_test_project_id(client_id); + let session = runtime.sessions.start_session(Some(project.clone()), None); + let session_id = session.session_id.clone(); + + let mut old_inventory = Vec::with_capacity(JOB_INVENTORY_MAX_TERMINAL_JOBS); + for ordinal in 0..JOB_INVENTORY_MAX_TERMINAL_JOBS as u64 { + old_inventory.push( + seed_retained_terminal_validation_job( + &runtime, + client_id, + &project, + &session_id, + ordinal, + ) + .await, + ); + } + let old_snapshot = runtime + .validation_job_candidates_for_sessions(&project, &[session_id.clone()], None) + .await; + let old_candidates = old_snapshot + .get(&session_id) + .expect("old candidate snapshot"); + assert_eq!(old_candidates.len(), JOB_INVENTORY_MAX_TERMINAL_JOBS); + let old_snapshot_job_ids = old_inventory + .iter() + .map(|job| job.job_id.as_str()) + .collect::>(); + let jold = old_inventory.last().unwrap().clone(); + + // S0: 63 durable markers J0..J62; Jold is retained but deliberately not + // materialized yet. Every insertion uses the complete old authoritative + // inventory, matching the production marker-eviction contract. + for job in old_inventory + .iter() + .take(JOB_INVENTORY_MAX_TERMINAL_JOBS - 1) + { + assert!(runtime.sessions.record_validation_job_terminal( + &session_id, + &job.job_id, + &old_snapshot_job_ids, + "cargo_check", + Some(project.clone()), + &job.validation_target_id, + "completed", + Some(0), + Some(job.ended_at.saturating_sub(1)), + Some(job.ended_at), + Some(25), + None, + )); + } + + let hook = runtime.validation_terminal_reconciliation_test_hook.clone(); + hook.pause_next_snapshot(); + let older_reconciliation = tokio::spawn({ + let runtime = runtime.clone(); + let project = project.clone(); + let session_id = session_id.clone(); + async move { + runtime + .materialize_validation_job_terminals_for_sessions(&project, &[session_id], None) + .await; + } + }); + hook.wait_for_reconciliation_attempt().await; + hook.wait_for_snapshot_acquired().await; + assert_eq!(hook.snapshot_acquisition_count(), 1); + assert!( + runtime + .validation_terminal_reconciliation + .try_lock() + .is_err(), + "the snapshot-to-materialization ordering fence must remain held while S1 is in flight" + ); + + // Churn the authoritative terminal inventory only after A has captured S1: + // J0 leaves retention and Jnew enters. This produces S2 = + // J1..J62 + Jold + Jnew while A still holds its older S1. + let j0 = old_inventory.first().unwrap().clone(); + assert!(runtime.shell_clients.remove_job_record(&j0.job_id).await); + let jnew = + seed_retained_terminal_validation_job(&runtime, client_id, &project, &session_id, 10_000) + .await; + let newer_snapshot = runtime + .validation_job_candidates_for_sessions(&project, &[session_id.clone()], None) + .await; + let newer_candidates = newer_snapshot + .get(&session_id) + .expect("new authoritative candidate snapshot"); + assert_eq!(newer_candidates.len(), JOB_INVENTORY_MAX_TERMINAL_JOBS); + assert!(newer_candidates + .iter() + .all(|job| job["job_id"].as_str() != Some(j0.job_id.as_str()))); + assert!(newer_candidates + .iter() + .any(|job| job["job_id"].as_str() == Some(jold.job_id.as_str()))); + assert!(newer_candidates + .iter() + .any(|job| job["job_id"].as_str() == Some(jnew.job_id.as_str()))); + + let newer_reconciliation = tokio::spawn({ + let runtime = runtime.clone(); + let project = project.clone(); + let session_id = session_id.clone(); + async move { + runtime + .materialize_validation_job_terminals_for_sessions(&project, &[session_id], None) + .await; + } + }); + // B has reached the ordering fence, but while A is paused after acquiring + // S1 it must not acquire S2 or materialize Jnew ahead of A. + hook.wait_for_reconciliation_attempt().await; + assert_eq!( + hook.snapshot_acquisition_count(), + 1, + "a newer reconciliation must not acquire its snapshot before the older snapshot finishes" + ); + + hook.resume_snapshot(); + older_reconciliation.await.unwrap(); + newer_reconciliation.await.unwrap(); + assert_eq!(hook.snapshot_acquisition_count(), 2); + + let materialized = runtime + .sessions + .summary(&session_id, Some(DEFAULT_MAX_EVENTS_PER_SESSION)) + .unwrap(); + for job in [&jold, &jnew] { + assert_eq!( + materialized + .events + .iter() + .filter(|event| { + event.kind == "validation_job_terminal" + && event.job_id.as_deref() == Some(job.job_id.as_str()) + }) + .count(), + 1, + "{} must materialize exactly once", + job.job_id + ); + } + let validation = validation_summary_for_session(&materialized); + assert_eq!(validation["unresolved_failures"]["count"], 0); + let events_total_before_repeat = materialized.events_total; + + // A fresh S2 reconciliation proves Jnew's durable marker survived. If the + // stale S1 had evicted it, this read would append Jnew a second time and + // increase events_observed/events_total. + runtime + .materialize_validation_job_terminals_for_sessions( + &project, + std::slice::from_ref(&session_id), + None, + ) + .await; + let after_repeat = runtime + .sessions + .summary(&session_id, Some(DEFAULT_MAX_EVENTS_PER_SESSION)) + .unwrap(); + assert_eq!(after_repeat.events_total, events_total_before_repeat); + assert_eq!( + after_repeat + .events + .iter() + .filter(|event| { + event.kind == "validation_job_terminal" + && event.job_id.as_deref() == Some(jnew.job_id.as_str()) + }) + .count(), + 1 + ); +} + +#[tokio::test] +async fn async_same_cargo_check_target_success_resolves_prior_failure_without_duplicate_bookkeeping( +) { + let client_id = "vhandoff-resolve-prior"; + let runtime = runtime_with_agent_project(client_id) + .with_validation_sync_wait(std::time::Duration::from_millis(50)); + register_agent( + &runtime, + client_id, + None, + ShellClientCapabilities { + async_shell_jobs: true, + structured_validation_argv: true, + ..Default::default() + }, + ) + .await; + let project = agent_test_project_id(client_id); + let auth = auth_context(None, true); + let session = runtime.sessions.start_session(Some(project.clone()), None); + let session_id = session.session_id.clone(); + + let failed_task = tokio::spawn({ + let runtime = runtime.clone(); + let auth = auth.clone(); + let project = project.clone(); + let session_id = session_id.clone(); + async move { + runtime + .dispatch_with_auth( + ToolCall::CargoCheck { + project, + session_id: Some(session_id), + cwd: None, + all_targets: Some(true), + all_features: None, + no_default_features: None, + features: None, + package: None, + timeout_secs: Some(600), + }, + Some(&auth), + ) + .await + } + }); + let (failed_request, failed_job_id) = poll_start_validation_job(&runtime, client_id).await; + runtime + .shell_clients + .update_job(cargo_test_update( + client_id, + &failed_request.request_id, + &failed_job_id, + "failed", + "", + "error[E0308]: mismatched types\n --> src/lib.rs:1:1\n", + Some(101), + ShellJobValidationProgress { + completed: 0, + current_step: None, + failed_step: Some("check".to_string()), + }, + true, + )) + .await + .unwrap(); + let failed = failed_task.await.unwrap(); + assert!( + !failed.success, + "the first check must retain a real failure" + ); + let failed_summary = runtime.sessions.summary(&session_id, None).unwrap(); + let failed_validation = validation_summary_for_session(&failed_summary); + assert_eq!(failed_validation["unresolved_failures"]["count"], 1); + + let success_task = tokio::spawn({ + let runtime = runtime.clone(); + let auth = auth.clone(); + let project = project.clone(); + let session_id = session_id.clone(); + async move { + runtime + .dispatch_with_auth( + ToolCall::CargoCheck { + project, + session_id: Some(session_id), + cwd: None, + all_targets: Some(true), + all_features: None, + no_default_features: None, + features: None, + package: None, + timeout_secs: Some(600), + }, + Some(&auth), + ) + .await + } + }); + let (success_request, success_job_id) = poll_start_validation_job(&runtime, client_id).await; + runtime + .shell_clients + .update_job(cargo_test_update( + client_id, + &success_request.request_id, + &success_job_id, + "running", + "Checking demo v0.1.0\n", + "", + None, + running_progress("check"), + false, + )) + .await + .unwrap(); + let handoff = success_task.await.unwrap(); + assert!(handoff.success, "{:?}", handoff.error); + assert_eq!(handoff.output["promoted_to_job"], true); + assert_eq!(handoff.output["job_id"], success_job_id); + + runtime + .shell_clients + .update_job(cargo_test_update( + client_id, + &success_request.request_id, + &success_job_id, + "completed", + "Finished `dev` profile [unoptimized + debuginfo] target(s)\n", + "", + Some(0), + completed_progress(), + true, + )) + .await + .unwrap(); + + let before_materialize = runtime.sessions.summary(&session_id, None).unwrap(); + // Two independent read-driven reconcilers may race on the same terminal Job. + // The Session-store marker check + mark + append must commit atomically. + let (validation_a, validation_b) = tokio::join!( + runtime.validation_summary_for_session_with_jobs(&before_materialize, 50, Some(&auth)), + runtime.validation_summary_for_session_with_jobs(&before_materialize, 50, Some(&auth)), + ); + for validation in [&validation_a, &validation_b] { + assert_eq!(validation["latest_status"], "passed"); + assert_eq!(validation["historical_failures"]["count"], 1); + assert_eq!(validation["resolved_failures"]["count"], 1); + assert_eq!(validation["unresolved_failures"]["count"], 0); + } + + let materialized = runtime.sessions.summary(&session_id, None).unwrap(); + let durable_validation = validation_summary_for_session(&materialized); + assert_eq!(durable_validation["unresolved_failures"]["count"], 0); + assert_eq!(durable_validation["resolved_failures"]["count"], 1); + assert_eq!( + materialized + .events + .iter() + .filter(|event| { + event.kind == "validation_job_terminal" + && event.job_id.as_deref() == Some(success_job_id.as_str()) + }) + .count(), + 1, + "concurrent reconciliation must append exactly one terminal evidence event" + ); + + // Evict that evidence from the bounded Session event FIFO without touching + // the authoritative Job registry. The retained Job must remain a candidate, + // but reconciliation must not resurrect its terminal evidence or refresh + // Session activity merely because a read path ran later. + for index in 0..=DEFAULT_MAX_EVENTS_PER_SESSION { + let started = runtime.sessions.record_tool_call_started( + Some(&session_id), + SessionTransport::Api, + "read_file", + &json!({"project": project, "path": format!("src/filler-{index}.rs")}), + ); + runtime + .sessions + .record_tool_call_finished(started, true, &json!({}), None, None); + } + let evicted = runtime.sessions.summary(&session_id, None).unwrap(); + assert!(evicted.events.iter().all(|event| { + event.kind != "validation_job_terminal" + || event.job_id.as_deref() != Some(success_job_id.as_str()) + })); + let candidates = runtime + .validation_job_candidates_for_sessions(&project, &[session_id.clone()], Some(&auth)) + .await; + assert!(candidates + .get(&session_id) + .into_iter() + .flatten() + .any(|job| job["job_id"].as_str() == Some(success_job_id.as_str()))); + let events_total_before_repeat = evicted.events_total; + let updated_at_before_repeat = evicted.updated_at; + let _ = runtime + .validation_summary_for_session_with_jobs(&evicted, 50, Some(&auth)) + .await; + let after_repeat = runtime.sessions.summary(&session_id, None).unwrap(); + assert_eq!(after_repeat.events_total, events_total_before_repeat); + assert_eq!(after_repeat.updated_at, updated_at_before_repeat); + assert!(after_repeat.events.iter().all(|event| { + event.kind != "validation_job_terminal" + || event.job_id.as_deref() != Some(success_job_id.as_str()) + })); +} + #[tokio::test] async fn partial_agent_status_is_conservative_while_delta_log_uses_frozen_validation_context() { let client_id = "vhandoff-partial-counts"; diff --git a/src/tool_runtime/tests/validation_summary.rs b/src/tool_runtime/tests/validation_summary.rs index 1ac63c82..a364d90a 100644 --- a/src/tool_runtime/tests/validation_summary.rs +++ b/src/tool_runtime/tests/validation_summary.rs @@ -284,6 +284,108 @@ async fn validation_summary_preserves_history_bounds_and_safe_diagnostics() { ); } +#[test] +fn durable_async_validation_terminal_success_resolves_same_target_without_acceptance_event() { + let runtime = test_runtime(); + let project = "agent:validation-terminal:project".to_string(); + let session = runtime + .sessions + .start_session(Some(project.clone()), Some("async terminal".to_string())); + let target = "target:0123456789abcdef01234567"; + let start = runtime.sessions.record_tool_call_started( + Some(&session.session_id), + SessionTransport::Api, + "cargo_check", + &json!({"project": project, "validation_target_id": target}), + ); + runtime.sessions.record_tool_call_finished( + start, + false, + &json!({ + "exit_code": 101, + "purpose": "validation", + "stdout_tail": "", + "stderr_tail": "error: compile failed\n", + "stdout_truncated": false, + "stderr_truncated": false + }), + Some("validation failed"), + None, + ); + let terminal_output = json!({ + "purpose": "validation", + "execution_state": "completed", + "exit_code": 0, + "stdout_tail": "Finished dev profile\n", + "stderr_tail": "", + "stdout_truncated": false, + "stderr_truncated": false, + "passed": true, + "errors_count": 0, + "warnings_count": 0 + }); + let terminal_summary = crate::tool_runtime::sessions::execution_output_summary_for_tool_result( + "cargo_check", + &terminal_output, + ); + assert!(runtime.sessions.record_validation_job_terminal( + &session.session_id, + "job-terminal-success", + &["job-terminal-success"], + "cargo_check", + Some(project.clone()), + target, + "completed", + Some(0), + Some(100), + Some(110), + Some(10_000), + terminal_summary.clone(), + )); + assert!( + !runtime.sessions.record_validation_job_terminal( + &session.session_id, + "job-terminal-success", + &["job-terminal-success"], + "cargo_check", + Some(project), + target, + "completed", + Some(0), + Some(100), + Some(110), + Some(10_000), + terminal_summary, + ), + "same Job terminal must materialize only once" + ); + let summary = runtime.sessions.summary(&session.session_id, None).unwrap(); + assert_eq!( + summary + .events + .iter() + .filter(|event| event.kind == "validation_job_terminal") + .count(), + 1 + ); + assert!( + !summary.events.iter().any(|event| { + event.job_id.as_deref() == Some("job-terminal-success") + && event.kind == "tool_call_finished" + }), + "terminal correctness must not require a retained acceptance event" + ); + let validation = + crate::tool_runtime::validation_events::validation_summary_from_events(&summary.events, 20); + assert_eq!(validation["latest_status"], "passed"); + assert_eq!(validation["historical_failures"]["count"], 1); + assert_eq!(validation["historical_failures"]["resolved"], true); + assert_eq!(validation["historical_failures"]["unresolved"], false); + assert_eq!(validation["resolved_failures"]["count"], 1); + assert_eq!(validation["unresolved_failures"]["count"], 0); + assert_eq!(validation["events_total"], 2); +} + #[tokio::test] async fn validation_summary_keeps_zero_tests_from_resolving_cargo_test_failure() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/tool_runtime/tool_audit.rs b/src/tool_runtime/tool_audit.rs index b74d5111..d3e2b9ae 100644 --- a/src/tool_runtime/tool_audit.rs +++ b/src/tool_runtime/tool_audit.rs @@ -599,7 +599,7 @@ pub(crate) fn session_log_arguments_for_tool_request(tool_name: &str, arguments: copy_keys( obj, &mut out, - &["session_id", "kind", "reply_to", "priority"], + &["session_id", "kind", "reply_to", "priority", "requires_ack"], ); out.insert( "body_present".to_string(), @@ -807,6 +807,7 @@ pub(crate) fn session_log_result_for_tool(tool_name: &str, output: &Value) -> Va "message_id": output.get("message_id").cloned().unwrap_or(Value::Null), "kind": output.pointer("/message/kind").cloned().unwrap_or(Value::Null), "status": output.pointer("/message/status").cloned().unwrap_or(Value::Null), + "requires_ack": output.pointer("/message/requires_ack").cloned().unwrap_or(Value::Null), "author_session_id": output.pointer("/message/author_session_id").cloned().unwrap_or(Value::Null), }), "list_session_messages" => serde_json::json!({ @@ -2766,6 +2767,7 @@ impl ToolCall { tags, reply_to, priority, + requires_ack, } => serde_json::json!({ "session_id": session_id, "kind": kind, @@ -2774,6 +2776,7 @@ impl ToolCall { "tags_count": tags.len(), "reply_to": reply_to, "priority": priority, + "requires_ack": requires_ack, }), Self::ListSessionMessages { session_id, diff --git a/src/tool_runtime/tool_call.rs b/src/tool_runtime/tool_call.rs index 47a3506b..ffecf255 100644 --- a/src/tool_runtime/tool_call.rs +++ b/src/tool_runtime/tool_call.rs @@ -419,6 +419,8 @@ pub enum ToolCall { reply_to: Option, #[serde(default)] priority: SessionMessagePriority, + #[serde(default)] + requires_ack: bool, }, /// List session-local ledger messages in stable newest-first order. diff --git a/src/tool_runtime/validation_events.rs b/src/tool_runtime/validation_events.rs index b35332b9..f2915928 100644 --- a/src/tool_runtime/validation_events.rs +++ b/src/tool_runtime/validation_events.rs @@ -201,42 +201,82 @@ impl ToolRuntime { limit: usize, auth: Option<&AuthContext>, ) -> Value { - let mut events = summary.events.clone(); - let accepted_jobs = summary + self.materialize_session_validation_job_terminals(summary, auth) + .await; + let refreshed = self + .sessions + .summary( + &summary.session_id, + Some(PUBLIC_VALIDATION_SESSION_EVENT_LIMIT), + ) + .unwrap_or_else(|| summary.clone()); + let mut events = refreshed.events.clone(); + self.append_terminal_run_job_validation_events(&refreshed, &mut events, auth) + .await; + events.sort_by_key(|event| { + ( + event.timestamp, + event.finished_at.unwrap_or(event.timestamp), + ) + }); + validation_summary_from_events(&events, limit) + } + + /// Preserve the pre-existing `run_job(purpose=validation|test|build|format|release)` + /// projection without mixing those generic Jobs into the durable structured-validation + /// marker ledger. Structured validation Jobs carry explicit validation metadata and are + /// materialized above; ordinary run_job evidence remains a read-time projection from the + /// retained acceptance event plus the authoritative terminal Job state. + async fn append_terminal_run_job_validation_events( + &self, + summary: &SessionSummary, + events: &mut Vec, + auth: Option<&AuthContext>, + ) { + let Some(project) = summary.project.as_deref() else { + return; + }; + let accepted = summary .events .iter() .filter(|event| { event.kind == "tool_call_finished" + && event.tool_name == "run_job" && event.job_id.is_some() - && (event.tool_name == "run_job" - || validation_adapter_for_tool(&event.tool_name).is_some()) + && execution_purpose(event).is_some() && job_acceptance_only(event) }) - .filter_map(|event| event.job_id.clone()) + .cloned() .collect::>(); - for job_id in accepted_jobs { - let status = self.job_status_for_auth(job_id.clone(), false, auth).await; + + for mut observed in accepted { + let Some(job_id) = observed.job_id.as_deref() else { + continue; + }; + let status = self + .job_status_for_auth(job_id.to_string(), false, auth) + .await; if !status.success - || !status - .output - .get("terminal") - .and_then(Value::as_bool) - .unwrap_or(false) + || status.output.get("terminal").and_then(Value::as_bool) != Some(true) + || status.output.get("session_id").and_then(Value::as_str) + != Some(summary.session_id.as_str()) + || status.output.get("project").and_then(Value::as_str) != Some(project) + { + continue; + } + // A run_job carrying structured validation metadata belongs to the durable + // materialization path and must not be synthesized a second time here. + if status + .output + .get("validation") + .is_some_and(Value::is_object) { continue; } + let log = self - .job_log_for_auth(job_id.clone(), None, Some(200), auth, None, None) + .job_log_for_auth(job_id.to_string(), None, Some(200), auth, None, None) .await; - let Some(accepted) = summary.events.iter().find(|event| { - event.kind == "tool_call_finished" - && event.job_id.as_deref() == Some(job_id.as_str()) - && (event.tool_name == "run_job" - || validation_adapter_for_tool(&event.tool_name).is_some()) - }) else { - continue; - }; - let mut observed = accepted.clone(); let job_status = status .output .get("status") @@ -258,36 +298,20 @@ impl ToolRuntime { "lost" => "execution_lost".to_string(), _ => "command_exit_nonzero".to_string(), }); + let mut output = if log.success { log.output } else { json!({ "stdout_tail": "", "stderr_tail": "", - "stdout_truncated": false, - "stderr_truncated": false, + "stdout_truncated": true, + "stderr_truncated": true, }) }; - if let Some(validation) = status.output.get("validation").and_then(Value::as_object) { - for field in [ - "passed", - "warnings_count", - "errors_count", - "tests_detected", - "tests_run_count", - "tests_passed", - "tests_failed", - "zero_tests_run", - "diagnostics", - ] { - if let Some(value) = validation.get(field) { - output[field] = value.clone(); - } - } - } for field in ["purpose", "command_summary", "cwd", "shell", "executor"] { if output.get(field).is_none_or(Value::is_null) { - output[field] = accepted + output[field] = observed .validation_output_summary .as_ref() .and_then(|value| value.get(field)) @@ -295,12 +319,10 @@ impl ToolRuntime { .unwrap_or(Value::Null); } } - if output.get("purpose").is_none_or(Value::is_null) { - output["purpose"] = json!("other"); - } output["execution_state"] = json!(match job_status { "timeout" | "timed_out" => "timed_out", "stopped" | "cancelled" => "cancelled", + "lost" => "lost", _ => "completed", }); output["exit_code"] = status @@ -309,19 +331,198 @@ impl ToolRuntime { .cloned() .unwrap_or(Value::Null); observed.validation_output_summary = - super::sessions::execution_output_summary_for_tool_result( - &accepted.tool_name, - &output, - ); - events.push(observed); + super::sessions::execution_output_summary_for_tool_result("run_job", &output); + if observed.validation_output_summary.is_some() { + events.push(observed); + } + } + } + + async fn materialize_session_validation_job_terminals( + &self, + summary: &SessionSummary, + auth: Option<&AuthContext>, + ) { + let Some(project) = summary.project.as_deref() else { + return; + }; + self.materialize_validation_job_terminals_for_sessions( + project, + std::slice::from_ref(&summary.session_id), + auth, + ) + .await; + } + + pub(crate) async fn materialize_validation_job_terminals_for_sessions( + &self, + project: &str, + session_ids: &[String], + auth: Option<&AuthContext>, + ) { + // Absence from `grouped` is eviction authority for the bounded durable + // materialization marker set. Hold one runtime-shared ordering fence from + // authoritative snapshot acquisition through every Session mutation so + // a snapshot acquired later can never materialize first and then have a + // still-retained marker removed by an older snapshot. The batch lock is + // deliberately stronger than a per-Session lock because candidate + // acquisition itself is batched across Sessions. + #[cfg(test)] + self.validation_terminal_reconciliation_test_hook + .before_reconciliation_lock(); + let _reconciliation_guard = self.validation_terminal_reconciliation.lock().await; + let mut grouped = self + .validation_job_candidates_for_sessions(project, session_ids, auth) + .await; + #[cfg(test)] + self.validation_terminal_reconciliation_test_hook + .after_snapshot_acquired() + .await; + for session_id in session_ids { + let Some(jobs) = grouped.remove(session_id) else { + continue; + }; + self.materialize_validation_job_candidates(project, session_id, &jobs, auth) + .await; + } + } + + async fn materialize_validation_job_candidates( + &self, + project: &str, + session_id: &str, + jobs: &[Value], + auth: Option<&AuthContext>, + ) { + let retained_terminal_job_ids = jobs + .iter() + .filter(|job| { + matches!( + job.get("status").and_then(Value::as_str), + Some( + "completed" + | "failed" + | "timeout" + | "timed_out" + | "stopped" + | "cancelled" + | "lost" + ) + ) + }) + .filter_map(|job| job.get("job_id").and_then(Value::as_str)) + .collect::>(); + for job in jobs { + let Some(job_id) = job.get("job_id").and_then(Value::as_str) else { + continue; + }; + let status_name = job + .get("status") + .and_then(Value::as_str) + .unwrap_or_default(); + if !matches!( + status_name, + "completed" | "failed" | "timeout" | "timed_out" | "stopped" | "cancelled" | "lost" + ) { + continue; + } + let status = self + .job_status_for_auth(job_id.to_string(), false, auth) + .await; + if !status.success + || status.output.get("terminal").and_then(Value::as_bool) != Some(true) + || status.output.get("session_id").and_then(Value::as_str) != Some(session_id) + || status.output.get("project").and_then(Value::as_str) != Some(project) + { + continue; + } + let Some(validation) = status.output.get("validation").and_then(Value::as_object) + else { + continue; + }; + let Some(tool_name) = validation + .get("tool") + .and_then(Value::as_str) + .filter(|tool| validation_adapter_for_tool(tool).is_some()) + else { + continue; + }; + let Some(validation_target_id) = validation + .get("validation_target_id") + .and_then(Value::as_str) + .filter(|value| is_structured_validation_target_identity(value)) + else { + continue; + }; + let log = self + .job_log_for_auth(job_id.to_string(), None, Some(200), auth, None, None) + .await; + let mut output = if log.success { + log.output + } else { + json!({ + "stdout_tail": "", + "stderr_tail": "", + "stdout_truncated": true, + "stderr_truncated": true, + }) + }; + for field in [ + "passed", + "warnings_count", + "errors_count", + "tests_detected", + "tests_run_count", + "tests_passed", + "tests_failed", + "zero_tests_run", + "diagnostics", + ] { + if let Some(value) = validation.get(field) { + output[field] = value.clone(); + } + } + for field in ["purpose", "command_summary", "cwd", "shell", "executor"] { + if output.get(field).is_none_or(Value::is_null) { + output[field] = status.output.get(field).cloned().unwrap_or(Value::Null); + } + } + if output.get("purpose").is_none_or(Value::is_null) { + output["purpose"] = json!("validation"); + } + let terminal_status = status + .output + .get("status") + .and_then(Value::as_str) + .unwrap_or(status_name); + output["execution_state"] = json!(match terminal_status { + "timeout" | "timed_out" => "timed_out", + "stopped" | "cancelled" => "cancelled", + "lost" => "lost", + _ => "completed", + }); + output["exit_code"] = status + .output + .get("exit_code") + .cloned() + .unwrap_or(Value::Null); + let validation_output_summary = + super::sessions::execution_output_summary_for_tool_result(tool_name, &output); + self.sessions.record_validation_job_terminal( + session_id, + job_id, + &retained_terminal_job_ids, + tool_name, + Some(project.to_string()), + validation_target_id, + terminal_status, + status.output.get("exit_code").and_then(Value::as_i64), + status.output.get("started_at").and_then(Value::as_i64), + status.output.get("ended_at").and_then(Value::as_i64), + status.output.get("duration_ms").and_then(Value::as_u64), + validation_output_summary, + ); } - events.sort_by_key(|event| { - ( - event.timestamp, - event.finished_at.unwrap_or(event.timestamp), - ) - }); - validation_summary_from_events(&events, limit) } } @@ -528,6 +729,7 @@ fn no_failures() -> ValidationFailureSet { pub(crate) fn extract_validation_events(events: &[SessionEvent]) -> Vec { let mut started = Vec::new(); let mut validation_events = Vec::new(); + let mut terminal_jobs = std::collections::HashSet::new(); for event in events { match event.kind.as_str() { @@ -549,6 +751,17 @@ pub(crate) fn extract_validation_events(events: &[SessionEvent]) -> Vec { + let Some(job_id) = event.job_id.as_deref() else { + continue; + }; + if !terminal_jobs.insert(job_id) { + continue; + } + if let Some(validation_event) = validation_event_from_finished(event, Some(event)) { + validation_events.push(validation_event); + } + } _ => {} } }