Files
state-hub/dashboard/src/workplan-queue.md
tegwick 166aedfa8d feat: add workplan aliases and legacy meter
Adds preferred workplan REST/event surfaces, legacy-meter telemetry and weekly review summaries, documentation/dashboard terminology updates, dashboard API loading fixes, and close-out sync for STATE-WP-0052 and STATE-WP-0054.
2026-06-04 08:25:31 +02:00

8.2 KiB

title
title
Workplan Queue
import {apiFetch, waitForVisible, pollDelay, POLL_HEAVY} from "./components/config.js";
const queueState = (async function*() {
  let failures = 0;
  while (true) {
    let stack = [], semantics = {}, ok = false;
    try {
      const [stackResponse, semanticsResponse] = await Promise.all([
        apiFetch("/execution/workplan-stack"),
        apiFetch("/execution/semantics"),
      ]);
      ok = stackResponse.ok && semanticsResponse.ok;
      if (ok) {
        [stack, semantics] = await Promise.all([
          stackResponse.json(),
          semanticsResponse.json(),
        ]);
      }
    } catch {}
    failures = ok ? 0 : failures + 1;
    yield {stack, semantics, ok, ts: new Date()};
    await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
  }
})();
const stack = queueState.stack ?? [];
const semantics = queueState.semantics ?? {};
const _ok = queueState.ok ?? false;
const _ts = queueState.ts;

Workplan Queue

display(html`<div class="queue-live">
  <span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
  ${_ok ? `Live · updated ${_ts?.toLocaleTimeString()}` : html`<span style="color:red">Offline</span>`}
</div>`);
const launchModes = Object.keys(semantics.launch_modes ?? {manual: "", queued: "", scheduled: "", immediate: ""});
const concurrencyModes = Object.keys(semantics.concurrency_modes ?? {sequential: "", parallel: ""});

function optionList(values, selected) {
  return values.map(value => html`<option value=${value} selected=${value === selected}>${value}</option>`);
}

function statusCell(row) {
  const classes = ["queue-status", row.eligible ? "eligible" : "blocked"].join(" ");
  return html`<span class=${classes}>${row.eligible ? "eligible" : "blocked"}</span>`;
}

function blockers(row) {
  const parts = [];
  if (row.blocked_by_workstream_ids?.length) parts.push(`${row.blocked_by_workstream_ids.length} workstream`);
  if (row.blocked_by_task_ids?.length) parts.push(`${row.blocked_by_task_ids.length} task`);
  return parts.length ? parts.join(", ") : "—";
}

function queueControls(row) {
  const root = html`<div class="queue-controls"></div>`;
  const mode = html`<select class="queue-select">${optionList(launchModes, row.launch_mode)}</select>`;
  const concurrency = html`<select class="queue-select">${optionList(concurrencyModes, row.concurrency_mode)}</select>`;
  const rank = html`<input class="queue-rank" type="number" min="0" step="1" value=${row.queue_rank ?? ""} aria-label="Queue rank">`;
  const group = html`<input class="queue-group" type="text" value=${row.execution_group ?? ""} aria-label="Execution group">`;
  const message = html`<span class="queue-message"></span>`;
  const save = html`<button class="queue-btn" type="button">Save</button>`;
  const launch = html`<button class="queue-btn queue-btn-primary" type="button">Request</button>`;

  const payload = () => ({
    execution_state: mode.value === "manual" ? "manual" : mode.value === "scheduled" ? "scheduled" : "queued",
    launch_mode: mode.value,
    concurrency_mode: concurrency.value,
    queue_rank: rank.value === "" ? null : Number(rank.value),
    execution_group: group.value.trim() || null,
  });

  async function run(label, action) {
    message.textContent = label;
    message.className = "queue-message";
    save.disabled = true;
    launch.disabled = true;
    try {
      await action();
      message.textContent = "saved";
      message.classList.add("ok");
      setTimeout(() => location.reload(), 450);
    } catch (error) {
      message.textContent = error?.message ?? "failed";
      message.classList.add("error");
      save.disabled = false;
      launch.disabled = false;
    }
  }

  save.onclick = () => run("saving", async () => {
    const response = await apiFetch(`/execution/workplans/${row.workstream_id}/intent`, {
      method: "PATCH",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify(payload()),
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
  });

  launch.onclick = () => run("requesting", async () => {
    const intent = payload();
    const response = await apiFetch("/execution/launch-requests", {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify({
        workstream_id: row.workstream_id,
        requested_by: "dashboard",
        requested_actor: "activity-core",
        launch_mode: intent.launch_mode,
        concurrency_mode: intent.concurrency_mode,
        immediate_pickup: intent.launch_mode === "immediate",
        priority: row.planning_priority,
        notes: `Queue request from dashboard for ${row.slug}`,
      }),
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
  });

  root.append(mode, concurrency, rank, group, save, launch, message);
  return root;
}
if (stack.length === 0) {
  display(html`<p class="queue-empty">No queue candidates.</p>`);
} else {
  display(html`<table class="queue-table">
    <thead>
      <tr>
        <th>State</th>
        <th>Rank</th>
        <th>Workplan</th>
        <th>Lifecycle</th>
        <th>Priority</th>
        <th>Eligibility</th>
        <th>Blocked By</th>
        <th>Intent</th>
      </tr>
    </thead>
    <tbody>${stack.map(row => html`<tr>
      <td>${row.execution_state}</td>
      <td>${row.queue_rank ?? row.planning_order ?? "—"}</td>
      <td><a href=${`./workstreams/${row.workstream_id}`}>${row.slug}</a><div class="queue-title">${row.title}</div></td>
      <td>${row.status}</td>
      <td>${row.planning_priority ?? "—"}</td>
      <td>${statusCell(row)}</td>
      <td>${blockers(row)}</td>
      <td>${queueControls(row)}</td>
    </tr>`)}</tbody>
  </table>`);
}
<style> .queue-live { font-size: 0.82rem; color: var(--theme-foreground-muted, #666); margin: -0.25rem 0 0.75rem; } .queue-table { width: 100%; border-collapse: collapse; font-size: 0.86rem; } .queue-table th, .queue-table td { border-bottom: 1px solid var(--theme-foreground-faint, #e5e7eb); padding: 0.45rem 0.5rem; vertical-align: middle; text-align: left; } .queue-table th { font-size: 0.72rem; text-transform: uppercase; color: var(--theme-foreground-muted, #666); } .queue-title { color: var(--theme-foreground-muted, #666); font-size: 0.76rem; max-width: 28rem; overflow-wrap: anywhere; } .queue-status { display: inline-block; min-width: 4.6rem; text-align: center; border-radius: 6px; padding: 0.12rem 0.45rem; border: 1px solid var(--theme-foreground-faint, #d1d5db); font-size: 0.76rem; } .queue-status.eligible { color: #166534; border-color: #bbf7d0; background: #f0fdf4; } .queue-status.blocked { color: #92400e; border-color: #fde68a; background: #fffbeb; } .queue-controls { display: grid; grid-template-columns: 7rem 7rem 4.2rem 7rem auto auto minmax(4rem, auto); gap: 0.3rem; align-items: center; } .queue-select, .queue-rank, .queue-group { height: 1.85rem; border: 1px solid var(--theme-foreground-faint, #d1d5db); border-radius: 6px; background: var(--theme-background, #fff); color: var(--theme-foreground, #111); font: inherit; font-size: 0.78rem; padding: 0.1rem 0.35rem; min-width: 0; } .queue-btn { height: 1.85rem; border-radius: 6px; border: 1px solid var(--theme-foreground-faint, #d1d5db); background: var(--theme-background, #fff); color: var(--theme-foreground, #111); font: inherit; font-size: 0.78rem; padding: 0 0.55rem; cursor: pointer; } .queue-btn-primary { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; } .queue-message { font-size: 0.72rem; color: var(--theme-foreground-muted, #666); white-space: nowrap; } .queue-message.ok { color: #16a34a; } .queue-message.error { color: #dc2626; } .queue-empty { color: var(--theme-foreground-muted, #666); } @media (max-width: 980px) { .queue-table, .queue-table thead, .queue-table tbody, .queue-table tr, .queue-table th, .queue-table td { display: block; } .queue-table thead { display: none; } .queue-table tr { border-bottom: 1px solid var(--theme-foreground-faint, #e5e7eb); padding: 0.5rem 0; } .queue-table td { border: 0; padding: 0.25rem 0; } .queue-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); } } </style>