generated from coulomb/repo-seed
Add workplan execution queue
This commit is contained in:
291
dashboard/src/workplan-queue.md
Normal file
291
dashboard/src/workplan-queue.md
Normal file
@@ -0,0 +1,291 @@
|
||||
---
|
||||
title: Workplan Queue
|
||||
---
|
||||
|
||||
```js
|
||||
import {apiFetch, waitForVisible, pollDelay, POLL_HEAVY} from "./components/config.js";
|
||||
```
|
||||
|
||||
```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}));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const stack = queueState.stack ?? [];
|
||||
const semantics = queueState.semantics ?? {};
|
||||
const _ok = queueState.ok ?? false;
|
||||
const _ts = queueState.ts;
|
||||
```
|
||||
|
||||
# Workplan Queue
|
||||
|
||||
```js
|
||||
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>`);
|
||||
```
|
||||
|
||||
```js
|
||||
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/workstreams/${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;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
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>
|
||||
Reference in New Issue
Block a user