smol-symphony 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +105 -38
- package/PRODUCT.md +2 -1
- package/README.md +195 -98
- package/SPEC.md +543 -1915
- package/WORKFLOW.md +654 -179
- package/WORKFLOW.template.md +761 -121
- package/dist/acp-bridge.js +324 -0
- package/dist/acp-bridge.js.map +1 -0
- package/dist/actions/cache.js +191 -0
- package/dist/actions/cache.js.map +1 -0
- package/dist/actions/effects.js +41 -0
- package/dist/actions/effects.js.map +1 -0
- package/dist/actions/executor.js +570 -0
- package/dist/actions/executor.js.map +1 -0
- package/dist/actions/index.js +13 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/actions/parsing.js +273 -0
- package/dist/actions/parsing.js.map +1 -0
- package/dist/actions/predicate-env.js +27 -0
- package/dist/actions/predicate-env.js.map +1 -0
- package/dist/actions/predicates.js +49 -0
- package/dist/actions/predicates.js.map +1 -0
- package/dist/actions/templating.js +66 -0
- package/dist/actions/templating.js.map +1 -0
- package/dist/actions/types.js +15 -0
- package/dist/actions/types.js.map +1 -0
- package/dist/agent/acp.js +232 -63
- package/dist/agent/acp.js.map +1 -1
- package/dist/agent/adapter-names.js +159 -0
- package/dist/agent/adapter-names.js.map +1 -0
- package/dist/agent/adapters.js +338 -102
- package/dist/agent/adapters.js.map +1 -1
- package/dist/agent/credential-extractors.js +342 -0
- package/dist/agent/credential-extractors.js.map +1 -0
- package/dist/agent/credential-secrets.js +628 -0
- package/dist/agent/credential-secrets.js.map +1 -0
- package/dist/agent/credential-ticker.js +57 -0
- package/dist/agent/credential-ticker.js.map +1 -0
- package/dist/agent/gondolin-creds-staging.js +356 -0
- package/dist/agent/gondolin-creds-staging.js.map +1 -0
- package/dist/agent/gondolin-dispatch.js +375 -0
- package/dist/agent/gondolin-dispatch.js.map +1 -0
- package/dist/agent/gondolin.js +124 -0
- package/dist/agent/gondolin.js.map +1 -0
- package/dist/agent/runner-decisions.js +134 -0
- package/dist/agent/runner-decisions.js.map +1 -0
- package/dist/agent/runner.js +1352 -290
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tool-call-summary.js +102 -0
- package/dist/agent/tool-call-summary.js.map +1 -0
- package/dist/agent/vm-acp-mapping.js +73 -0
- package/dist/agent/vm-acp-mapping.js.map +1 -0
- package/dist/agent/vm-guards.js +262 -0
- package/dist/agent/vm-guards.js.map +1 -0
- package/dist/agent/vm-port.js +22 -0
- package/dist/agent/vm-port.js.map +1 -0
- package/dist/agent/vm-process-registry.js +79 -0
- package/dist/agent/vm-process-registry.js.map +1 -0
- package/dist/bin/cli-args.js +105 -0
- package/dist/bin/cli-args.js.map +1 -0
- package/dist/bin/symphony.js +719 -130
- package/dist/bin/symphony.js.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -0
- package/dist/http-disk.js +135 -0
- package/dist/http-disk.js.map +1 -0
- package/dist/http-handlers.js +180 -0
- package/dist/http-handlers.js.map +1 -0
- package/dist/http.js +1476 -764
- package/dist/http.js.map +1 -1
- package/dist/issues.js +178 -0
- package/dist/issues.js.map +1 -0
- package/dist/logging.js +163 -5
- package/dist/logging.js.map +1 -1
- package/dist/mcp.js +391 -163
- package/dist/mcp.js.map +1 -1
- package/dist/memory.js +85 -0
- package/dist/memory.js.map +1 -0
- package/dist/orchestrator-decisions.js +331 -0
- package/dist/orchestrator-decisions.js.map +1 -0
- package/dist/orchestrator.js +1189 -303
- package/dist/orchestrator.js.map +1 -1
- package/dist/prompt.js +5 -5
- package/dist/prompt.js.map +1 -1
- package/dist/reconciler/cache.js +65 -0
- package/dist/reconciler/cache.js.map +1 -0
- package/dist/reconciler/index.js +448 -0
- package/dist/reconciler/index.js.map +1 -0
- package/dist/reconciler/ledger.js +131 -0
- package/dist/reconciler/ledger.js.map +1 -0
- package/dist/reconciler/pr-adapters.js +174 -0
- package/dist/reconciler/pr-adapters.js.map +1 -0
- package/dist/reconciler/pr-decide.js +167 -0
- package/dist/reconciler/pr-decide.js.map +1 -0
- package/dist/reconciler/pr.js +422 -0
- package/dist/reconciler/pr.js.map +1 -0
- package/dist/reconciler/types.js +12 -0
- package/dist/reconciler/types.js.map +1 -0
- package/dist/reconciler/vm.js +243 -0
- package/dist/reconciler/vm.js.map +1 -0
- package/dist/reconciler/workspace-defaults.js +83 -0
- package/dist/reconciler/workspace-defaults.js.map +1 -0
- package/dist/reconciler/workspace.js +272 -0
- package/dist/reconciler/workspace.js.map +1 -0
- package/dist/runlog.js +403 -0
- package/dist/runlog.js.map +1 -0
- package/dist/scaffold.js +165 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/trackers/local.js +234 -133
- package/dist/trackers/local.js.map +1 -1
- package/dist/trackers/types.js +1 -1
- package/dist/trackers/types.js.map +1 -1
- package/dist/types.js +1 -1
- package/dist/util/clock.js +12 -0
- package/dist/util/clock.js.map +1 -0
- package/dist/util/crypto.js +25 -0
- package/dist/util/crypto.js.map +1 -0
- package/dist/util/frontmatter.js +70 -0
- package/dist/util/frontmatter.js.map +1 -0
- package/dist/util/fs-issues.js +22 -0
- package/dist/util/fs-issues.js.map +1 -0
- package/dist/util/process.js +152 -0
- package/dist/util/process.js.map +1 -0
- package/dist/util/workspace-key.js +10 -0
- package/dist/util/workspace-key.js.map +1 -0
- package/dist/workflow-loader.js +147 -0
- package/dist/workflow-loader.js.map +1 -0
- package/dist/workflow.js +656 -219
- package/dist/workflow.js.map +1 -1
- package/dist/workspace-types.js +8 -0
- package/dist/workspace-types.js.map +1 -0
- package/dist/workspace.js +367 -120
- package/dist/workspace.js.map +1 -1
- package/package.json +14 -6
- package/scripts/vm-agent.mjs +211 -0
- package/dist/agent/codex.js +0 -439
- package/dist/agent/codex.js.map +0 -1
- package/dist/agent/smolvm.js +0 -174
- package/dist/agent/smolvm.js.map +0 -1
- package/scripts/build-vm.sh +0 -67
package/dist/http.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// watching status. The UI polls `/api/v1/state` so no SSE/WebSocket infrastructure is
|
|
3
3
|
// needed.
|
|
4
4
|
import { createServer } from 'node:http';
|
|
5
|
-
import {
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { sanitizeWorkspaceKey } from './workspace.js';
|
|
8
7
|
import { log } from './logging.js';
|
|
8
|
+
import { writeIssueFile } from './issues.js';
|
|
9
|
+
import { matchRoute, resolvePartialName, extractBearerToken, classifyContentType, checkSteeringCsrf, checkTriageCsrf, extractFormText, extractJsonText, decideCreateIssue, decideTriageTransition, } from './http-handlers.js';
|
|
10
|
+
import { listIssuesFromDisk, readIssueFromDisk, } from './http-disk.js';
|
|
9
11
|
function jsonResponse(res, status, body) {
|
|
10
12
|
const text = JSON.stringify(body);
|
|
11
13
|
res.statusCode = status;
|
|
@@ -76,10 +78,12 @@ function isSameOriginRequest(req) {
|
|
|
76
78
|
return !(req.headers['referer'] && req.headers['referer'].length > 0);
|
|
77
79
|
}
|
|
78
80
|
// HTMX's default response handling does not swap content on 4xx/5xx responses, so
|
|
79
|
-
// returning a 4xx with an HTML partial silently leaves the
|
|
80
|
-
// callers
|
|
81
|
-
//
|
|
82
|
-
//
|
|
81
|
+
// returning a 4xx with an HTML partial silently leaves the region stale. For HTMX
|
|
82
|
+
// callers (currently only steering-reply, which targets the #ticker region) we
|
|
83
|
+
// return 200 with the ticker partial plus an inline error chip; the operator's
|
|
84
|
+
// textarea content sits in the board row (separate hx-preserve cycle) and is
|
|
85
|
+
// unaffected. JSON callers still get the appropriate status code and a structured
|
|
86
|
+
// error.
|
|
83
87
|
async function htmxOrJsonError(res, isHtmx, orch, view, jsonStatus, code, message) {
|
|
84
88
|
if (isHtmx) {
|
|
85
89
|
const p = await gatherPartialInputs(orch, view);
|
|
@@ -108,108 +112,6 @@ async function readTextBody(req, maxBytes = 1_000_000) {
|
|
|
108
112
|
req.on('error', reject);
|
|
109
113
|
});
|
|
110
114
|
}
|
|
111
|
-
// Used by the dashboard form. The local tracker stores issues at:
|
|
112
|
-
// <tracker.root>/<state>/<identifier>.md
|
|
113
|
-
// with YAML front matter. Identifier sanitization re-uses the workspace key rules so the
|
|
114
|
-
// file name is always safe across the rest of the orchestrator.
|
|
115
|
-
async function writeIssueFile(input) {
|
|
116
|
-
const ident = sanitizeWorkspaceKey(input.identifier);
|
|
117
|
-
if (!ident)
|
|
118
|
-
throw new Error('identifier must contain at least one allowed character');
|
|
119
|
-
const stateDir = path.join(input.trackerRoot, input.state);
|
|
120
|
-
await mkdir(stateDir, { recursive: true });
|
|
121
|
-
const filePath = path.join(stateDir, `${ident}.md`);
|
|
122
|
-
try {
|
|
123
|
-
await stat(filePath);
|
|
124
|
-
throw new Error(`issue ${ident} already exists at ${filePath}`);
|
|
125
|
-
}
|
|
126
|
-
catch (err) {
|
|
127
|
-
if (err.code !== 'ENOENT')
|
|
128
|
-
throw err;
|
|
129
|
-
}
|
|
130
|
-
const now = new Date().toISOString();
|
|
131
|
-
const fm = {
|
|
132
|
-
id: ident,
|
|
133
|
-
identifier: ident,
|
|
134
|
-
title: input.title,
|
|
135
|
-
created_at: now,
|
|
136
|
-
updated_at: now,
|
|
137
|
-
};
|
|
138
|
-
if (typeof input.priority === 'number')
|
|
139
|
-
fm.priority = input.priority;
|
|
140
|
-
if (input.labels && input.labels.length > 0)
|
|
141
|
-
fm.labels = input.labels;
|
|
142
|
-
if (input.blocked_by && input.blocked_by.length > 0)
|
|
143
|
-
fm.blocked_by = input.blocked_by;
|
|
144
|
-
const yamlLines = ['---'];
|
|
145
|
-
for (const [k, v] of Object.entries(fm)) {
|
|
146
|
-
if (Array.isArray(v)) {
|
|
147
|
-
yamlLines.push(`${k}: [${v.map((x) => JSON.stringify(x)).join(', ')}]`);
|
|
148
|
-
}
|
|
149
|
-
else if (typeof v === 'string') {
|
|
150
|
-
yamlLines.push(`${k}: ${JSON.stringify(v)}`);
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
yamlLines.push(`${k}: ${String(v)}`);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
yamlLines.push('---', '');
|
|
157
|
-
const body = (input.description ?? '').trim();
|
|
158
|
-
const content = yamlLines.join('\n') + (body.length > 0 ? body + '\n' : '');
|
|
159
|
-
await writeFile(filePath, content, 'utf8');
|
|
160
|
-
return { path: filePath, identifier: ident, state: input.state };
|
|
161
|
-
}
|
|
162
|
-
// Browse current issues directly from disk so the UI can show items that are neither
|
|
163
|
-
// currently running nor in the retry queue.
|
|
164
|
-
async function listIssuesFromDisk(trackerRoot) {
|
|
165
|
-
const out = [];
|
|
166
|
-
let entries;
|
|
167
|
-
try {
|
|
168
|
-
entries = await readdir(trackerRoot);
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
return out;
|
|
172
|
-
}
|
|
173
|
-
for (const stateDir of entries) {
|
|
174
|
-
const dirPath = path.join(trackerRoot, stateDir);
|
|
175
|
-
let st;
|
|
176
|
-
try {
|
|
177
|
-
st = await stat(dirPath);
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
if (!st.isDirectory())
|
|
183
|
-
continue;
|
|
184
|
-
let files;
|
|
185
|
-
try {
|
|
186
|
-
files = await readdir(dirPath);
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
for (const f of files) {
|
|
192
|
-
if (!f.endsWith('.md'))
|
|
193
|
-
continue;
|
|
194
|
-
const filePath = path.join(dirPath, f);
|
|
195
|
-
let text;
|
|
196
|
-
try {
|
|
197
|
-
text = await readFile(filePath, 'utf8');
|
|
198
|
-
}
|
|
199
|
-
catch {
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
let title = f.slice(0, -3);
|
|
203
|
-
const titleMatch = /^---[\s\S]*?\ntitle:\s*(.+)\n[\s\S]*?---/m.exec(text);
|
|
204
|
-
if (titleMatch) {
|
|
205
|
-
title = titleMatch[1].trim().replace(/^["'](.*)["']$/, '$1');
|
|
206
|
-
}
|
|
207
|
-
out.push({ identifier: f.slice(0, -3), state: stateDir, title });
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
out.sort((a, b) => a.identifier.localeCompare(b.identifier));
|
|
211
|
-
return out;
|
|
212
|
-
}
|
|
213
115
|
async function gatherPartialInputs(orch, view) {
|
|
214
116
|
const trackerRoot = view.trackerRoot ?? '(unset)';
|
|
215
117
|
let diskIssues = [];
|
|
@@ -225,15 +127,39 @@ async function gatherPartialInputs(orch, view) {
|
|
|
225
127
|
workflowName: path.basename(view.workflowPath || 'workflow.md'),
|
|
226
128
|
workflowPath: view.workflowPath || '',
|
|
227
129
|
trackerRoot,
|
|
228
|
-
|
|
229
|
-
terminalStates: view.terminalStates,
|
|
130
|
+
states: view.states,
|
|
230
131
|
snapshot: orch.snapshot(),
|
|
231
132
|
diskIssues,
|
|
232
133
|
};
|
|
233
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Map a declared state name to its pill colour class. Active states pulse green
|
|
137
|
+
* (`running`), terminals settle into the muted "done" palette, holdings (Triage)
|
|
138
|
+
* use the same idle palette they always have. Unknown names — issues sitting in a
|
|
139
|
+
* directory that's no longer declared in `states:` — fall back to idle so the
|
|
140
|
+
* dashboard still renders something legible.
|
|
141
|
+
*/
|
|
142
|
+
function pillClassForState(states, stateName) {
|
|
143
|
+
const lower = stateName.toLowerCase();
|
|
144
|
+
const match = states.find((s) => s.name.toLowerCase() === lower);
|
|
145
|
+
if (!match)
|
|
146
|
+
return 'idle';
|
|
147
|
+
switch (match.role) {
|
|
148
|
+
case 'active':
|
|
149
|
+
return 'running';
|
|
150
|
+
case 'terminal':
|
|
151
|
+
return 'done';
|
|
152
|
+
case 'holding':
|
|
153
|
+
return 'idle';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
234
156
|
function trackerStatus(snap) {
|
|
235
157
|
const awaiting = snap.running.some((r) => r.steering_requested);
|
|
236
|
-
|
|
158
|
+
// Only failure-backoff retries warrant the operator's eyes. A `continuation`
|
|
159
|
+
// is the calm post-transition resume of a clean handoff (e.g. Review → Todo)
|
|
160
|
+
// and already holds its slot, so it must not flip the whole dashboard into
|
|
161
|
+
// "attention" (issue 146).
|
|
162
|
+
if (awaiting || snap.retrying.some((r) => r.kind === 'failure'))
|
|
237
163
|
return 'attention';
|
|
238
164
|
if (snap.running.length > 0)
|
|
239
165
|
return 'working';
|
|
@@ -257,13 +183,20 @@ function formatRuntime(seconds) {
|
|
|
257
183
|
const rem = m % 60;
|
|
258
184
|
return rem === 0 ? `${h}h` : `${h}h${rem}m`;
|
|
259
185
|
}
|
|
260
|
-
|
|
186
|
+
// `hour12: false` pins the 24-hour clock so the dashboard reads e.g. `14:30:05`
|
|
187
|
+
// instead of locale-default AM/PM (`02:30:05 PM`) on 12-hour locales like en-US.
|
|
188
|
+
export function formatTimeShort(iso) {
|
|
261
189
|
if (!iso)
|
|
262
190
|
return '';
|
|
263
191
|
const d = new Date(iso);
|
|
264
192
|
if (Number.isNaN(d.getTime()))
|
|
265
193
|
return '';
|
|
266
|
-
return d.toLocaleTimeString(undefined, {
|
|
194
|
+
return d.toLocaleTimeString(undefined, {
|
|
195
|
+
hour: '2-digit',
|
|
196
|
+
minute: '2-digit',
|
|
197
|
+
second: '2-digit',
|
|
198
|
+
hour12: false,
|
|
199
|
+
});
|
|
267
200
|
}
|
|
268
201
|
function truncate(text, max) {
|
|
269
202
|
if (!text)
|
|
@@ -279,53 +212,301 @@ function renderHeaderPartial(p) {
|
|
|
279
212
|
const statusLabel = status === 'attention' ? 'attention' : status === 'working' ? 'working' : 'idle';
|
|
280
213
|
return `<span class="badge badge-${status}" aria-label="tracker state: ${statusLabel}">${statusLabel}</span>`;
|
|
281
214
|
}
|
|
215
|
+
// ─── Top-of-page attention ticker ───────────────────────────────────────
|
|
216
|
+
// A horizontal strip listing what currently needs the operator's eyes: pending
|
|
217
|
+
// steering replies and stuck retries. Each identifier is an anchor link to the
|
|
218
|
+
// row inside the board — the operator scans the ticker, jumps to the column,
|
|
219
|
+
// and replies in place. The steering reply form itself now lives inline on its
|
|
220
|
+
// row in the board, so this ticker stays thin.
|
|
282
221
|
function renderAttentionPartial(p, opts) {
|
|
283
222
|
const awaiting = p.snapshot.running.filter((r) => r.steering_requested);
|
|
284
|
-
|
|
223
|
+
// Continuations are calm post-transition resumes, not failures — keep them
|
|
224
|
+
// off the attention ticker so a clean handoff doesn't read as a stuck retry
|
|
225
|
+
// (issue 146). Only failure-backoff retries surface here.
|
|
226
|
+
const retrying = p.snapshot.retrying.filter((r) => r.kind === 'failure');
|
|
285
227
|
const errorMessage = opts?.errorMessage?.trim() ?? '';
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
228
|
+
const segments = [];
|
|
229
|
+
if (awaiting.length > 0) {
|
|
230
|
+
const links = awaiting
|
|
231
|
+
.map((r) => `<a href="#row-${escapeHtml(r.issue_identifier)}" class="tick-id">${escapeHtml(r.issue_identifier)}</a>`)
|
|
232
|
+
.join(' ');
|
|
233
|
+
segments.push(`<span class="tick tick-await"><span class="tick-label">${awaiting.length} awaiting</span> ${links}</span>`);
|
|
234
|
+
}
|
|
235
|
+
if (retrying.length > 0) {
|
|
236
|
+
const stuck = retrying.filter((r) => r.error).length;
|
|
237
|
+
const links = retrying
|
|
238
|
+
.map((r) => `<a href="#row-${escapeHtml(r.issue_identifier)}" class="tick-id">${escapeHtml(r.issue_identifier)}</a>`)
|
|
239
|
+
.join(' ');
|
|
240
|
+
const label = stuck > 0
|
|
241
|
+
? `${retrying.length} retrying · ${stuck} with error`
|
|
242
|
+
: `${retrying.length} retrying`;
|
|
243
|
+
segments.push(`<span class="tick tick-retry"><span class="tick-label">${label}</span> ${links}</span>`);
|
|
244
|
+
}
|
|
245
|
+
if (errorMessage) {
|
|
246
|
+
segments.push(`<span class="tick tick-err" role="alert">${escapeHtml(errorMessage)}</span>`);
|
|
247
|
+
}
|
|
248
|
+
return segments.join('');
|
|
249
|
+
}
|
|
250
|
+
// ─── Board (kanban) ─────────────────────────────────────────────────────
|
|
251
|
+
// One column per declared state in workflow declaration order. Each on-disk
|
|
252
|
+
// issue renders as a flat row in its state's column; transient orchestrator
|
|
253
|
+
// state (running / retrying / awaiting steering) is overlaid via a pill, a
|
|
254
|
+
// metadata trail, and — for awaiting — an inline question + reply form. Issues
|
|
255
|
+
// sitting in an undeclared state directory (e.g. after a workflow rename) get
|
|
256
|
+
// trailing columns so they stay visible until reconciled rather than silently
|
|
257
|
+
// dropping out of the dashboard.
|
|
258
|
+
function renderBoardPartial(p) {
|
|
259
|
+
const runningById = new Map();
|
|
260
|
+
for (const r of p.snapshot.running)
|
|
261
|
+
runningById.set(r.issue_identifier, r);
|
|
262
|
+
const retryingById = new Map();
|
|
263
|
+
for (const r of p.snapshot.retrying)
|
|
264
|
+
retryingById.set(r.issue_identifier, r);
|
|
265
|
+
// Group disk issues by lower-cased state name. LocalMarkdownTracker matches
|
|
266
|
+
// state directories case-insensitively against the declared `states:` keys
|
|
267
|
+
// (src/trackers/local.ts §scanAllAt: `declared.has(dirEntry.toLowerCase())`),
|
|
268
|
+
// so the dashboard must mirror that or a `todo/` directory under declared
|
|
269
|
+
// `Todo` would be misclassified as an orphan and split off into its own
|
|
270
|
+
// trailing column. `displayName` keeps the on-disk casing for orphan column
|
|
271
|
+
// headers (the only place we need to show a non-declared name).
|
|
272
|
+
const byStateLower = new Map();
|
|
273
|
+
for (const i of p.diskIssues) {
|
|
274
|
+
const key = i.state.toLowerCase();
|
|
275
|
+
const entry = byStateLower.get(key);
|
|
276
|
+
if (entry)
|
|
277
|
+
entry.items.push(i);
|
|
278
|
+
else
|
|
279
|
+
byStateLower.set(key, { displayName: i.state, items: [i] });
|
|
280
|
+
}
|
|
281
|
+
// Terminal columns (Done, Cancelled, …) are deliberately omitted from the
|
|
282
|
+
// board. The orchestrator's glance test — "is anything stuck, is anything
|
|
283
|
+
// running" — doesn't read off finished work, and a wall of archived rows
|
|
284
|
+
// dilutes the active surface. Terminal issues stay reachable through their
|
|
285
|
+
// /issues/<id> detail page; a dedicated history surface is the natural place
|
|
286
|
+
// to surface them again once we have something to anchor the row on (a PR
|
|
287
|
+
// link, a final token total, a closed-at timestamp).
|
|
288
|
+
const declared = p.states
|
|
289
|
+
.filter((state) => state.role !== 'terminal')
|
|
290
|
+
.map((state) => {
|
|
291
|
+
const items = byStateLower.get(state.name.toLowerCase())?.items ?? [];
|
|
292
|
+
return renderColumn(state, items, runningById, retryingById);
|
|
293
|
+
});
|
|
294
|
+
// Orphan columns: on-disk state directories whose names are not in the
|
|
295
|
+
// declared `states:` set. These render even when they look "terminal" because
|
|
296
|
+
// they're an error condition the operator needs to see (a workflow rename
|
|
297
|
+
// left files behind), not a clean archive.
|
|
298
|
+
const declaredLower = new Set(p.states.map((s) => s.name.toLowerCase()));
|
|
299
|
+
const orphans = [];
|
|
300
|
+
for (const [key, entry] of byStateLower) {
|
|
301
|
+
if (declaredLower.has(key))
|
|
302
|
+
continue;
|
|
303
|
+
orphans.push({ name: entry.displayName, items: entry.items });
|
|
304
|
+
}
|
|
305
|
+
orphans.sort((a, b) => a.name.localeCompare(b.name));
|
|
306
|
+
const orphanColumns = orphans.map((o) => renderColumn({ name: o.name, role: 'terminal' }, o.items, runningById, retryingById, { orphan: true }));
|
|
307
|
+
return `<div class="kanban">${declared.join('')}${orphanColumns.join('')}</div>`;
|
|
308
|
+
}
|
|
309
|
+
function renderColumn(state, items, runningById, retryingById, opts) {
|
|
310
|
+
const orphan = opts?.orphan === true;
|
|
311
|
+
const canAdd = !orphan && state.role !== 'terminal';
|
|
312
|
+
// Sort within a column so anything needing attention floats to the top:
|
|
313
|
+
// awaiting → running → retrying → idle. Ties break on identifier for stability.
|
|
314
|
+
const sorted = items.slice().sort((a, b) => {
|
|
315
|
+
const rank = (id) => {
|
|
316
|
+
const run = runningById.get(id);
|
|
317
|
+
if (run?.steering_requested)
|
|
318
|
+
return 0;
|
|
319
|
+
if (run)
|
|
320
|
+
return 1;
|
|
321
|
+
const retry = retryingById.get(id);
|
|
322
|
+
// A continuation reads as "running" (slot held across the handoff), so it
|
|
323
|
+
// ranks with the running tier; only failure-backoff retries sort below.
|
|
324
|
+
if (retry?.kind === 'continuation')
|
|
325
|
+
return 1;
|
|
326
|
+
if (retry)
|
|
327
|
+
return 2;
|
|
328
|
+
return 3;
|
|
329
|
+
};
|
|
330
|
+
const ar = rank(a.identifier);
|
|
331
|
+
const br = rank(b.identifier);
|
|
332
|
+
if (ar !== br)
|
|
333
|
+
return ar - br;
|
|
334
|
+
return a.identifier.localeCompare(b.identifier);
|
|
335
|
+
});
|
|
336
|
+
const rowsHtml = sorted
|
|
337
|
+
.map((i) => renderIssueRow(i, state, runningById.get(i.identifier), retryingById.get(i.identifier), { orphan }))
|
|
338
|
+
.join('');
|
|
339
|
+
const addBtn = canAdd
|
|
340
|
+
? `<button type="button" class="col-add"
|
|
341
|
+
data-target-state="${escapeHtml(state.name)}"
|
|
342
|
+
aria-label="add issue to ${escapeHtml(state.name)}"
|
|
343
|
+
title="add issue to ${escapeHtml(state.name)}">+</button>`
|
|
290
344
|
: '';
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
345
|
+
const emptyHint = sorted.length === 0 && state.role === 'active' && !orphan
|
|
346
|
+
? `<p class="col-empty">drop into <code>${escapeHtml(state.name)}/</code></p>`
|
|
347
|
+
: '';
|
|
348
|
+
const orphanBadge = orphan
|
|
349
|
+
? `<span class="col-orphan" title="state not declared in workflow.md">undeclared</span>`
|
|
350
|
+
: '';
|
|
351
|
+
return `<section class="col col-${escapeHtml(state.role)}${orphan ? ' col-orphan-wrap' : ''}" data-state="${escapeHtml(state.name)}">
|
|
352
|
+
<header class="col-head">
|
|
353
|
+
<span class="col-name">${escapeHtml(state.name)}</span>
|
|
354
|
+
<span class="col-count">${sorted.length}</span>
|
|
355
|
+
${orphanBadge}
|
|
356
|
+
<span class="grow"></span>
|
|
357
|
+
${addBtn}
|
|
358
|
+
</header>
|
|
359
|
+
<div class="col-body">${rowsHtml}${emptyHint}</div>
|
|
360
|
+
</section>`;
|
|
361
|
+
}
|
|
362
|
+
function rowFlags(state, running, retrying, orphan) {
|
|
363
|
+
const isAwaiting = !!running?.steering_requested;
|
|
364
|
+
// A `continuation` retry is the slot-holding resume of a clean handoff (e.g.
|
|
365
|
+
// Review → Todo): the orchestrator still holds the issue's slot and redispatches
|
|
366
|
+
// within ~1s, so the card stays "running" across the handoff instead of flashing
|
|
367
|
+
// a transient pill. Only `failure`-backoff retries are "retrying" (issues
|
|
368
|
+
// 146/154; the design system's Three-Pill Rule — running/retrying/idle only).
|
|
369
|
+
const isContinuation = retrying?.kind === 'continuation';
|
|
370
|
+
return {
|
|
371
|
+
isAwaiting,
|
|
372
|
+
isRunning: (!!running || isContinuation) && !isAwaiting,
|
|
373
|
+
isRetrying: !!retrying && !isContinuation,
|
|
374
|
+
isHolding: !orphan && state.role === 'holding',
|
|
375
|
+
orphan,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function rowPillHtml(f) {
|
|
379
|
+
if (f.isAwaiting)
|
|
380
|
+
return '<span class="pill awaiting">awaiting</span>';
|
|
381
|
+
if (f.isRunning)
|
|
382
|
+
return '<span class="pill running">running</span>';
|
|
383
|
+
if (f.isRetrying)
|
|
384
|
+
return '<span class="pill retrying">retrying</span>';
|
|
385
|
+
return '';
|
|
386
|
+
}
|
|
387
|
+
function rowMetaHtml(running, retrying) {
|
|
388
|
+
if (running) {
|
|
389
|
+
const tokens = formatTokens(running.tokens.total_tokens || 0);
|
|
390
|
+
return `<span class="row-meta">turn ${running.turn_count} · ${escapeHtml(tokens)} tok</span>`;
|
|
391
|
+
}
|
|
392
|
+
// A `continuation` carries no operator-facing meta: the card reads as "running"
|
|
393
|
+
// across the handoff (see rowFlags), so the brief slot-holding window stays
|
|
394
|
+
// silent rather than surfacing an "attempt N · due" trail. Only `failure`
|
|
395
|
+
// retries show the backoff schedule.
|
|
396
|
+
if (retrying && retrying.kind !== 'continuation') {
|
|
397
|
+
const dueAt = formatTimeShort(retrying.due_at) || '—';
|
|
398
|
+
return `<span class="row-meta">attempt ${retrying.attempt} · due ${escapeHtml(dueAt)}</span>`;
|
|
399
|
+
}
|
|
400
|
+
return '';
|
|
295
401
|
}
|
|
296
|
-
function
|
|
402
|
+
function rowPeekHtml(running, isAwaiting) {
|
|
403
|
+
if (!running || isAwaiting)
|
|
404
|
+
return '';
|
|
405
|
+
const text = truncate(running.last_message ?? running.last_event ?? '', 110);
|
|
406
|
+
return text ? `<div class="row-peek dim">${escapeHtml(text)}</div>` : '';
|
|
407
|
+
}
|
|
408
|
+
function rowRetryErrHtml(retrying) {
|
|
409
|
+
if (!retrying?.error)
|
|
410
|
+
return '';
|
|
411
|
+
return `<div class="row-err">${escapeHtml(truncate(retrying.error, 200))}</div>`;
|
|
412
|
+
}
|
|
413
|
+
function rowTriageActionsHtml(ident, f) {
|
|
414
|
+
if (!(f.isHolding && !f.isRunning && !f.isAwaiting && !f.isRetrying))
|
|
415
|
+
return '';
|
|
416
|
+
const enc = encodeURIComponent(ident);
|
|
417
|
+
return `<div class="row-actions">
|
|
418
|
+
<form class="row-action"
|
|
419
|
+
hx-post="/api/v1/issues/${enc}/approve"
|
|
420
|
+
hx-target="#board" hx-swap="morph:innerHTML">
|
|
421
|
+
<button type="submit" class="ghost-sm" title="approve into the first active state">approve</button>
|
|
422
|
+
</form>
|
|
423
|
+
<form class="row-action"
|
|
424
|
+
hx-post="/api/v1/issues/${enc}/discard"
|
|
425
|
+
hx-target="#board" hx-swap="morph:innerHTML">
|
|
426
|
+
<button type="submit" class="ghost-sm danger" title="discard the proposal">discard</button>
|
|
427
|
+
</form>
|
|
428
|
+
</div>`;
|
|
429
|
+
}
|
|
430
|
+
function rowClassList(f, retryHasErr) {
|
|
431
|
+
return [
|
|
432
|
+
'row',
|
|
433
|
+
f.isAwaiting ? 'row-await' : '',
|
|
434
|
+
f.isRunning ? 'row-run' : '',
|
|
435
|
+
f.isRetrying && retryHasErr ? 'row-retry-stuck' : '',
|
|
436
|
+
f.orphan ? 'row-orphan' : '',
|
|
437
|
+
]
|
|
438
|
+
.filter(Boolean)
|
|
439
|
+
.join(' ');
|
|
440
|
+
}
|
|
441
|
+
function renderIssueRow(i, state, running, retrying, opts) {
|
|
442
|
+
const f = rowFlags(state, running, retrying, opts?.orphan === true);
|
|
443
|
+
const escIdent = escapeHtml(i.identifier);
|
|
444
|
+
const href = `/issues/${encodeURIComponent(i.identifier)}`;
|
|
445
|
+
const steering = f.isAwaiting ? renderSteeringInline(running) : '';
|
|
446
|
+
const classes = rowClassList(f, !!retrying?.error);
|
|
447
|
+
return `<article class="${classes}" id="row-${escIdent}">
|
|
448
|
+
<div class="row-head">
|
|
449
|
+
<a class="row-ident" href="${href}" title="open ${escIdent}">${escIdent}</a>
|
|
450
|
+
${rowPillHtml(f)}
|
|
451
|
+
<span class="grow"></span>
|
|
452
|
+
${rowMetaHtml(running, retrying)}
|
|
453
|
+
</div>
|
|
454
|
+
<a class="row-title" href="${href}">${escapeHtml(i.title)}</a>
|
|
455
|
+
${rowPeekHtml(running, f.isAwaiting)}
|
|
456
|
+
${rowRetryErrHtml(retrying)}
|
|
457
|
+
${rowTriageActionsHtml(i.identifier, f)}
|
|
458
|
+
${steering}
|
|
459
|
+
</article>`;
|
|
460
|
+
}
|
|
461
|
+
function steeringSummaryLabel(hasOriginalTask, hasContext) {
|
|
462
|
+
if (hasOriginalTask && hasContext)
|
|
463
|
+
return 'original task & agent’s context';
|
|
464
|
+
if (hasOriginalTask)
|
|
465
|
+
return 'original task';
|
|
466
|
+
return 'agent’s context';
|
|
467
|
+
}
|
|
468
|
+
function steeringIssueBlock(title, body) {
|
|
469
|
+
if (!title && !body)
|
|
470
|
+
return '';
|
|
471
|
+
const titleHtml = title ? `<h3 class="issue-title">${escapeHtml(title)}</h3>` : '';
|
|
472
|
+
const bodyHtml = body ? `<p class="issue-body">${escapeHtml(body)}</p>` : '';
|
|
473
|
+
return `<div class="steering-task-label">issue</div>
|
|
474
|
+
${titleHtml}
|
|
475
|
+
${bodyHtml}`;
|
|
476
|
+
}
|
|
477
|
+
function steeringContextBlock(context) {
|
|
478
|
+
if (!context)
|
|
479
|
+
return '';
|
|
480
|
+
return `<div class="steering-task-label">agent’s context</div>
|
|
481
|
+
<pre class="context">${escapeHtml(context)}</pre>`;
|
|
482
|
+
}
|
|
483
|
+
function steeringExtraPanel(title, body, context) {
|
|
484
|
+
const hasOriginalTask = title.length > 0 || body.length > 0;
|
|
485
|
+
if (!hasOriginalTask && !context)
|
|
486
|
+
return '';
|
|
487
|
+
const label = steeringSummaryLabel(hasOriginalTask, context.length > 0);
|
|
488
|
+
return `<details class="steering-task">
|
|
489
|
+
<summary>${escapeHtml(label)}</summary>
|
|
490
|
+
<div class="steering-task-body">
|
|
491
|
+
${steeringIssueBlock(title, body)}
|
|
492
|
+
${steeringContextBlock(context)}
|
|
493
|
+
</div>
|
|
494
|
+
</details>`;
|
|
495
|
+
}
|
|
496
|
+
function renderSteeringInline(r) {
|
|
297
497
|
const question = (r.steering_question ?? '').trim() || '(no question text)';
|
|
298
498
|
const context = (r.steering_context ?? '').trim();
|
|
299
499
|
const issueTitle = (r.issue_title ?? '').trim();
|
|
300
500
|
const issueBody = (r.issue_body ?? '').trim();
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
// The textarea is given a stable id and hx-preserve="true" so the every-2s repoll of
|
|
304
|
-
// the attention zone doesn't wipe the operator's in-progress reply.
|
|
501
|
+
// Stable textarea id + hx-preserve so the every-2s board repoll keeps the
|
|
502
|
+
// operator's draft reply intact across morph swaps.
|
|
305
503
|
const textareaId = `reply-${r.issue_identifier}`;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
return `<article class="steering" data-identifier="${escapeHtml(r.issue_identifier)}">
|
|
310
|
-
<header class="steering-head">
|
|
311
|
-
<strong class="ident">${escapeHtml(r.issue_identifier)}</strong>
|
|
312
|
-
<span class="pill awaiting">awaiting</span>
|
|
313
|
-
<span class="turn"><span class="dim">turn</span> ${r.turn_count}</span>
|
|
314
|
-
</header>
|
|
315
|
-
<p class="question-primary">${escapeHtml(question)}</p>
|
|
316
|
-
${hasAnyExtra ? `<details class="steering-task">
|
|
317
|
-
<summary>${escapeHtml(summaryLabel)}</summary>
|
|
318
|
-
<div class="steering-task-body">
|
|
319
|
-
${hasOriginalTask ? `<div class="steering-task-label">issue</div>
|
|
320
|
-
${issueTitle ? `<h3 class="issue-title">${escapeHtml(issueTitle)}</h3>` : ''}
|
|
321
|
-
${issueBody ? `<p class="issue-body">${escapeHtml(issueBody)}</p>` : ''}` : ''}
|
|
322
|
-
${context ? `<div class="steering-task-label">agent’s context</div>
|
|
323
|
-
<pre class="context">${escapeHtml(context)}</pre>` : ''}
|
|
324
|
-
</div>
|
|
325
|
-
</details>` : ''}
|
|
504
|
+
return `<div class="steering">
|
|
505
|
+
<div class="steering-q">${renderMarkdown(question)}</div>
|
|
506
|
+
${steeringExtraPanel(issueTitle, issueBody, context)}
|
|
326
507
|
<form class="reply"
|
|
327
508
|
hx-post="/api/v1/issues/${encodeURIComponent(r.issue_identifier)}/steering-reply"
|
|
328
|
-
hx-target="#
|
|
509
|
+
hx-target="#ticker" hx-swap="morph:innerHTML">
|
|
329
510
|
<textarea id="${escapeHtml(textareaId)}" name="text" required
|
|
330
511
|
placeholder="your reply…"
|
|
331
512
|
aria-label="reply to ${escapeHtml(r.issue_identifier)}"
|
|
@@ -335,96 +516,53 @@ function renderSteeringBlock(r) {
|
|
|
335
516
|
<button type="submit" class="ghost">send reply</button>
|
|
336
517
|
</div>
|
|
337
518
|
</form>
|
|
338
|
-
</
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
return `<
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
<
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
`session ${sessionId}`,
|
|
375
|
-
r.state,
|
|
376
|
-
r.last_event ?? null,
|
|
377
|
-
`started ${formatTimeShort(r.started_at) || '—'}`,
|
|
378
|
-
]
|
|
379
|
-
.filter(Boolean)
|
|
380
|
-
.join(' · ');
|
|
381
|
-
return `<li class="session" title="${escapeHtml(rowTitle)}">
|
|
382
|
-
<div class="session-row">
|
|
383
|
-
<strong class="ident">${escapeHtml(r.issue_identifier)}</strong>
|
|
384
|
-
${pill}
|
|
385
|
-
<span class="turn"><span class="dim">turn</span> ${r.turn_count}</span>
|
|
386
|
-
<span class="grow"></span>
|
|
387
|
-
<span class="tokens dim">${formatTokens(tokens)} tok</span>
|
|
388
|
-
</div>
|
|
389
|
-
${lastMsg ? `<div class="session-msg dim">${escapeHtml(lastMsg)}</div>` : ''}
|
|
390
|
-
</li>`;
|
|
391
|
-
}
|
|
392
|
-
function renderDiskPartial(p) {
|
|
393
|
-
const runIds = new Set(p.snapshot.running.map((r) => r.issue_identifier));
|
|
394
|
-
const retryIds = new Set(p.snapshot.retrying.map((r) => r.issue_identifier));
|
|
395
|
-
const active = new Set(p.activeStates);
|
|
396
|
-
const filtered = p.diskIssues.filter((i) => active.has(i.state) && !runIds.has(i.identifier) && !retryIds.has(i.identifier));
|
|
397
|
-
if (filtered.length === 0) {
|
|
398
|
-
return `<h2>on disk</h2>
|
|
399
|
-
<p class="empty dim">tracker is clean. drop a markdown file into <code>${escapeHtml(p.activeStates[0] ?? 'Todo')}/</code> or open <em>new issue</em> below.</p>`;
|
|
400
|
-
}
|
|
401
|
-
const items = filtered.map((i) => `<li>
|
|
402
|
-
<span class="ident">${escapeHtml(i.identifier)}</span>
|
|
403
|
-
<span class="state dim">${escapeHtml(i.state)}</span>
|
|
404
|
-
<span class="title">${escapeHtml(i.title)}</span>
|
|
405
|
-
</li>`).join('');
|
|
406
|
-
return `<h2>on disk <span class="count dim">(${filtered.length})</span></h2>
|
|
407
|
-
<ul class="disk">${items}</ul>`;
|
|
519
|
+
</div>`;
|
|
520
|
+
}
|
|
521
|
+
// ─── Right-side new-issue panel (the One Card) ─────────────────────────
|
|
522
|
+
// The dashboard's only card-shaped surface (DESIGN.md §5 One-Card Rule). Slides
|
|
523
|
+
// in from the right edge when the operator clicks a column's `+` button; the
|
|
524
|
+
// column the click came from pre-selects the state field. Non-modal: the board
|
|
525
|
+
// stays visible and interactive on the left. Esc closes; backdrop clicks do not
|
|
526
|
+
// (avoid accidental discards of in-progress drafts).
|
|
527
|
+
function renderNewIssuePanel(p) {
|
|
528
|
+
const targets = p.states.filter((s) => s.role !== 'terminal');
|
|
529
|
+
const firstActive = p.states.find((s) => s.role === 'active');
|
|
530
|
+
const defaultState = (firstActive ?? targets[0])?.name ?? '';
|
|
531
|
+
const options = targets
|
|
532
|
+
.map((s) => {
|
|
533
|
+
const sel = s.name === defaultState ? ' selected' : '';
|
|
534
|
+
return `<option value="${escapeHtml(s.name)}"${sel}>${escapeHtml(s.name)}</option>`;
|
|
535
|
+
})
|
|
536
|
+
.join('');
|
|
537
|
+
return `<aside id="new-panel" class="new-panel" aria-hidden="true" aria-labelledby="np-title">
|
|
538
|
+
<header class="np-head">
|
|
539
|
+
<h2 id="np-title">new issue</h2>
|
|
540
|
+
<button type="button" class="np-close" aria-label="close" title="close (esc)">×</button>
|
|
541
|
+
</header>
|
|
542
|
+
<form class="np-form" id="np-form">
|
|
543
|
+
<label for="np-state">column</label>
|
|
544
|
+
<select id="np-state" name="state" required>${options}</select>
|
|
545
|
+
<label for="np-title-input">title</label>
|
|
546
|
+
<input id="np-title-input" name="title" required autocomplete="off" placeholder="what needs doing?" />
|
|
547
|
+
<label for="np-description">description</label>
|
|
548
|
+
<textarea id="np-description" name="description" rows="6" placeholder="optional — context for the agent"></textarea>
|
|
549
|
+
<div class="np-actions">
|
|
550
|
+
<span class="np-msg" id="np-msg" role="status" aria-live="polite"></span>
|
|
551
|
+
<button type="submit" class="np-submit">create</button>
|
|
552
|
+
</div>
|
|
553
|
+
</form>
|
|
554
|
+
</aside>`;
|
|
408
555
|
}
|
|
409
556
|
function renderTotalsPartial(p) {
|
|
410
|
-
const t = p.snapshot.
|
|
557
|
+
const t = p.snapshot.session_totals;
|
|
411
558
|
if (!t || (t.input_tokens === 0 && t.output_tokens === 0 && t.seconds_running === 0))
|
|
412
559
|
return '';
|
|
413
560
|
return `${formatTokens(t.input_tokens)} in · ${formatTokens(t.output_tokens)} out · ${formatTokens(t.total_tokens)} total · ${formatRuntime(t.seconds_running)} runtime`;
|
|
414
561
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
.join('');
|
|
420
|
-
const diskCount = p.diskIssues.filter((i) => p.activeStates.includes(i.state)).length;
|
|
421
|
-
const formOpen = diskCount === 0 && p.snapshot.running.length === 0 && p.snapshot.retrying.length === 0;
|
|
422
|
-
return `<!doctype html>
|
|
423
|
-
<html lang="en"><head>
|
|
424
|
-
<meta charset="utf-8">
|
|
425
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
426
|
-
<title>symphony · ${escapeHtml(p.workflowName)}</title>
|
|
427
|
-
<style>
|
|
562
|
+
// Dashboard CSS, hoisted to a top-level constant so renderDashboardHtml stays
|
|
563
|
+
// within the imperative-shell max-lines budget. Pure presentation content — no
|
|
564
|
+
// interpolated values — so it lives outside the function.
|
|
565
|
+
const DASHBOARD_CSS = `
|
|
428
566
|
:root {
|
|
429
567
|
color-scheme: dark;
|
|
430
568
|
--inset: #0c0f15; --bench: #0f1115; --raised: #161a22; --chip: #20242c;
|
|
@@ -439,30 +577,20 @@ function renderDashboardHtml(p) {
|
|
|
439
577
|
--err: #ff7676;
|
|
440
578
|
}
|
|
441
579
|
* { box-sizing: border-box; }
|
|
442
|
-
html, body { background: var(--bench); color: var(--base); }
|
|
580
|
+
html, body { background: var(--bench); color: var(--base); margin: 0; }
|
|
443
581
|
body {
|
|
444
582
|
font: 14px/1.45 ui-sans-serif, system-ui, sans-serif;
|
|
445
|
-
|
|
446
|
-
display: flex; flex-direction: column;
|
|
447
|
-
}
|
|
448
|
-
main {
|
|
449
|
-
width: 100%; max-width: 1080px; margin: 0 auto;
|
|
450
|
-
padding: 1rem 1.5rem 2rem; flex: 1;
|
|
583
|
+
min-height: 100vh;
|
|
584
|
+
display: flex; flex-direction: column;
|
|
451
585
|
}
|
|
452
586
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; color: var(--strong); }
|
|
453
587
|
.dim { color: var(--dim); }
|
|
454
|
-
.grow { flex: 1; }
|
|
455
|
-
h2 {
|
|
456
|
-
font-size: 1rem; font-weight: 500; margin: 1.6rem 0 0.5rem;
|
|
457
|
-
padding-bottom: 0.35rem; border-bottom: 1px solid var(--rule-firm);
|
|
458
|
-
letter-spacing: 0.01em;
|
|
459
|
-
}
|
|
460
|
-
h2:first-child { margin-top: 0.4rem; }
|
|
588
|
+
.grow { flex: 1 1 auto; }
|
|
461
589
|
|
|
462
590
|
/* ── header strip ─────────────────────────────────────────────────────── */
|
|
463
591
|
#header {
|
|
464
592
|
display: flex; align-items: center; gap: 0.5rem;
|
|
465
|
-
padding: 0.65rem 1.
|
|
593
|
+
padding: 0.65rem 1.25rem;
|
|
466
594
|
background: var(--bench); border-bottom: 1px solid var(--rule-firm);
|
|
467
595
|
font-size: 13px;
|
|
468
596
|
position: sticky; top: 0; z-index: 10;
|
|
@@ -496,85 +624,204 @@ h2:first-child { margin-top: 0.4rem; }
|
|
|
496
624
|
#header .refresh:hover { border-color: var(--muted); }
|
|
497
625
|
#header .refresh:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
498
626
|
|
|
499
|
-
/* ──
|
|
627
|
+
/* ── ticker ──────────────────────────────────────────────────────────── */
|
|
628
|
+
#ticker {
|
|
629
|
+
display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; align-items: center;
|
|
630
|
+
padding: 0 1.25rem; background: var(--bench);
|
|
631
|
+
border-bottom: 1px solid var(--rule-soft);
|
|
632
|
+
font-size: 13px;
|
|
633
|
+
transition: padding 200ms cubic-bezier(.22,1,.36,1),
|
|
634
|
+
border-bottom-color 200ms cubic-bezier(.22,1,.36,1);
|
|
635
|
+
}
|
|
636
|
+
#ticker:empty { padding-top: 0; padding-bottom: 0; border-bottom-color: transparent; }
|
|
637
|
+
#ticker:not(:empty) { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
|
638
|
+
.tick { display: inline-flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
|
639
|
+
.tick-label { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
640
|
+
.tick-await .tick-label { color: var(--await-fg); }
|
|
641
|
+
.tick-retry .tick-label { color: var(--retry-fg); }
|
|
642
|
+
.tick-err { color: var(--err); }
|
|
643
|
+
.tick-id {
|
|
644
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
645
|
+
color: var(--base); text-decoration: none;
|
|
646
|
+
border-bottom: 1px solid var(--rule-firm);
|
|
647
|
+
font-size: 0.92em;
|
|
648
|
+
padding-bottom: 1px;
|
|
649
|
+
}
|
|
650
|
+
.tick-id:hover, .tick-id:focus-visible { color: #9cc0ff; border-bottom-color: #9cc0ff; outline: none; }
|
|
651
|
+
|
|
652
|
+
/* ── board ───────────────────────────────────────────────────────────── */
|
|
653
|
+
#board {
|
|
654
|
+
flex: 1 1 auto; min-height: 0;
|
|
655
|
+
padding: 1rem 1.25rem 2.5rem;
|
|
656
|
+
overflow-x: auto;
|
|
657
|
+
}
|
|
658
|
+
.kanban {
|
|
659
|
+
display: grid;
|
|
660
|
+
grid-auto-flow: column;
|
|
661
|
+
grid-auto-columns: minmax(260px, 1fr);
|
|
662
|
+
gap: 1.5rem;
|
|
663
|
+
align-items: start;
|
|
664
|
+
min-width: min-content;
|
|
665
|
+
}
|
|
666
|
+
.col { display: flex; flex-direction: column; min-width: 0; }
|
|
667
|
+
.col-head {
|
|
668
|
+
display: flex; align-items: baseline; gap: 0.4rem;
|
|
669
|
+
padding: 0 0 0.35rem;
|
|
670
|
+
border-bottom: 1px solid var(--rule-firm);
|
|
671
|
+
margin-bottom: 0.5rem;
|
|
672
|
+
}
|
|
673
|
+
.col-name {
|
|
674
|
+
color: var(--muted); font-weight: 500; font-size: 14px;
|
|
675
|
+
letter-spacing: 0.01em;
|
|
676
|
+
}
|
|
677
|
+
.col-holding .col-name { color: var(--retry-fg); }
|
|
678
|
+
.col-terminal .col-name { color: var(--dim); }
|
|
679
|
+
.col-count { color: var(--dim); font-size: 0.82em; font-variant-numeric: tabular-nums; }
|
|
680
|
+
.col-orphan {
|
|
681
|
+
color: var(--err); font-size: 0.7em; letter-spacing: 0.05em;
|
|
682
|
+
text-transform: uppercase; margin-left: 0.3rem;
|
|
683
|
+
}
|
|
684
|
+
.col-add {
|
|
685
|
+
background: transparent; color: var(--muted);
|
|
686
|
+
border: 1px solid var(--rule-firm); border-radius: 4px;
|
|
687
|
+
width: 22px; height: 22px;
|
|
688
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
689
|
+
cursor: pointer; font: inherit; line-height: 1; font-size: 16px;
|
|
690
|
+
transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
|
|
691
|
+
}
|
|
692
|
+
.col-add:hover { color: var(--strong); border-color: var(--muted); }
|
|
693
|
+
.col-add:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
694
|
+
.col-body { display: flex; flex-direction: column; }
|
|
695
|
+
.col-empty {
|
|
696
|
+
padding: 0.4rem 0; margin: 0;
|
|
697
|
+
font-size: 0.9em; color: var(--dim);
|
|
698
|
+
}
|
|
699
|
+
.col-empty code { color: var(--muted); }
|
|
700
|
+
|
|
701
|
+
/* ── row ─────────────────────────────────────────────────────────────── */
|
|
702
|
+
.row {
|
|
703
|
+
padding: 0.55rem 0.6rem; margin: 0 -0.6rem;
|
|
704
|
+
border-bottom: 1px solid var(--rule-soft);
|
|
705
|
+
display: flex; flex-direction: column; gap: 0.2rem;
|
|
706
|
+
border-radius: 3px;
|
|
707
|
+
}
|
|
708
|
+
.row:last-child { border-bottom: 0; }
|
|
709
|
+
.row-await { background: rgba(127, 181, 212, 0.07); }
|
|
710
|
+
.row-retry-stuck { background: rgba(240, 192, 96, 0.05); }
|
|
711
|
+
.row-orphan { opacity: 0.7; }
|
|
712
|
+
.row-head {
|
|
713
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
714
|
+
font-variant-numeric: tabular-nums;
|
|
715
|
+
}
|
|
716
|
+
.row-ident {
|
|
717
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
718
|
+
color: var(--strong); text-decoration: none;
|
|
719
|
+
font-size: 13px;
|
|
720
|
+
}
|
|
721
|
+
.row-ident:hover, .row-ident:focus-visible { color: #9cc0ff; outline: none; }
|
|
500
722
|
.pill {
|
|
501
723
|
display: inline-block; padding: 0.1rem 0.55rem; border-radius: 999px;
|
|
502
|
-
font-size: 0.
|
|
724
|
+
font-size: 0.78em; line-height: 1.4;
|
|
503
725
|
font-variant-numeric: tabular-nums;
|
|
504
726
|
}
|
|
505
727
|
.pill.running { background: var(--run-bg); color: var(--run-fg); }
|
|
506
728
|
.pill.retrying { background: var(--retry-bg); color: var(--retry-fg); }
|
|
507
|
-
|
|
729
|
+
/* A "resuming" row is mid clean-handoff (issue 146): calm blue, not the amber
|
|
730
|
+
retry palette, so it never reads as an error. */
|
|
731
|
+
.pill.resuming { background: var(--await-bg); color: var(--await-fg); }
|
|
508
732
|
.pill.awaiting { background: var(--await-bg); color: var(--await-fg); }
|
|
509
|
-
.
|
|
510
|
-
|
|
511
|
-
/* ── attention zone ───────────────────────────────────────────────────── */
|
|
512
|
-
/* Animated open/close: max-height transitions between 0 (empty) and a generous cap
|
|
513
|
-
(populated) so the section eases in and out rather than snapping the page up/down
|
|
514
|
-
when steering arrives or is resolved. */
|
|
515
|
-
#attention {
|
|
733
|
+
.row-meta { color: var(--muted); font-size: 0.82em; }
|
|
734
|
+
.row-title {
|
|
516
735
|
display: block;
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
}
|
|
527
|
-
.
|
|
528
|
-
.
|
|
529
|
-
|
|
530
|
-
background: var(--inset); border: 1px solid var(--rule-firm); border-radius: 4px;
|
|
531
|
-
color: var(--err); font-size: 0.9em; line-height: 1.4;
|
|
736
|
+
color: var(--base); text-decoration: none;
|
|
737
|
+
font-size: 14px; line-height: 1.4;
|
|
738
|
+
overflow-wrap: anywhere;
|
|
739
|
+
}
|
|
740
|
+
.row-title:hover, .row-title:focus-visible { color: var(--strong); outline: none; }
|
|
741
|
+
.row-peek {
|
|
742
|
+
font-size: 0.85em; line-height: 1.4;
|
|
743
|
+
overflow-wrap: anywhere;
|
|
744
|
+
max-height: 2.85em; overflow: hidden;
|
|
745
|
+
}
|
|
746
|
+
.row-err {
|
|
747
|
+
font-size: 0.85em; line-height: 1.35; color: var(--err);
|
|
748
|
+
overflow-wrap: anywhere; word-break: break-word;
|
|
532
749
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
750
|
+
.row-actions { display: flex; gap: 0.4rem; margin-top: 0.2rem; }
|
|
751
|
+
.row-action { display: inline; margin: 0; padding: 0; }
|
|
752
|
+
.ghost {
|
|
753
|
+
background: var(--chip); color: var(--base);
|
|
754
|
+
border: 1px solid var(--rule-firm); border-radius: 4px;
|
|
755
|
+
padding: 0.35rem 0.85rem; font: inherit; cursor: pointer;
|
|
756
|
+
transition: border-color 180ms cubic-bezier(.22,1,.36,1);
|
|
757
|
+
}
|
|
758
|
+
.ghost:hover { border-color: var(--muted); }
|
|
759
|
+
.ghost:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
760
|
+
.ghost-sm {
|
|
761
|
+
background: transparent; color: var(--muted);
|
|
762
|
+
border: 1px solid var(--rule-firm); border-radius: 3px;
|
|
763
|
+
padding: 0.18rem 0.6rem; font: inherit; font-size: 0.82em; cursor: pointer;
|
|
764
|
+
transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
|
|
765
|
+
}
|
|
766
|
+
.ghost-sm:hover { color: var(--strong); border-color: var(--muted); }
|
|
767
|
+
.ghost-sm.danger:hover { color: var(--err); border-color: var(--err); }
|
|
768
|
+
.ghost-sm:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
769
|
+
|
|
770
|
+
/* ── inline steering on awaiting rows ────────────────────────────────── */
|
|
537
771
|
.steering {
|
|
538
|
-
|
|
539
|
-
border-
|
|
772
|
+
margin-top: 0.45rem; padding-top: 0.45rem;
|
|
773
|
+
border-top: 1px dashed var(--rule-firm);
|
|
540
774
|
display: grid; gap: 0.4rem;
|
|
541
775
|
}
|
|
542
|
-
.steering-
|
|
543
|
-
|
|
776
|
+
.steering-q { color: var(--strong); font-size: 0.95em; line-height: 1.45; }
|
|
777
|
+
.steering-q > :first-child { margin-top: 0; }
|
|
778
|
+
.steering-q > :last-child { margin-bottom: 0; }
|
|
779
|
+
.steering-q p { margin: 0.35em 0; }
|
|
780
|
+
.steering-q h1, .steering-q h2, .steering-q h3,
|
|
781
|
+
.steering-q h4, .steering-q h5, .steering-q h6 {
|
|
782
|
+
margin: 0.5em 0 0.25em; font-weight: 500; font-size: 1em;
|
|
783
|
+
}
|
|
784
|
+
.steering-q ul, .steering-q ol { margin: 0.3em 0; padding-left: 1.4em; }
|
|
785
|
+
.steering-q li { margin: 0.15em 0; }
|
|
786
|
+
.steering-q code {
|
|
787
|
+
background: var(--inset); padding: 0.05em 0.35em; border-radius: 3px; font-size: 0.92em;
|
|
788
|
+
}
|
|
789
|
+
.steering-q pre {
|
|
790
|
+
margin: 0.5em 0; padding: 0.5rem 0.65rem;
|
|
791
|
+
background: var(--inset); border: 1px solid var(--rule-firm); border-radius: 4px;
|
|
792
|
+
font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
793
|
+
color: var(--muted); overflow: auto;
|
|
544
794
|
}
|
|
545
|
-
.steering
|
|
546
|
-
.steering
|
|
547
|
-
.
|
|
548
|
-
|
|
549
|
-
font-size: calc(14px * 1.1);
|
|
550
|
-
line-height: 1.4; color: var(--strong); font-weight: 400;
|
|
795
|
+
.steering-q pre code { background: transparent; padding: 0; font-size: inherit; }
|
|
796
|
+
.steering-q blockquote {
|
|
797
|
+
margin: 0.35em 0; padding: 0.1em 0.7em;
|
|
798
|
+
border-left: 2px solid var(--rule-firm); color: var(--muted);
|
|
551
799
|
}
|
|
552
|
-
.steering
|
|
553
|
-
.steering
|
|
800
|
+
.steering-q a { color: var(--await-fg); }
|
|
801
|
+
.steering-task { font-size: 0.88em; }
|
|
802
|
+
.steering-task > summary {
|
|
554
803
|
cursor: pointer; list-style: none;
|
|
555
|
-
color: var(--muted); padding: 0.
|
|
556
|
-
font-size: 0.88em;
|
|
804
|
+
color: var(--muted); padding: 0.2rem 0; user-select: none;
|
|
557
805
|
}
|
|
558
|
-
.steering
|
|
559
|
-
.steering
|
|
806
|
+
.steering-task > summary::-webkit-details-marker { display: none; }
|
|
807
|
+
.steering-task > summary::before {
|
|
560
808
|
content: "▸"; padding-right: 0.4rem;
|
|
561
809
|
transition: transform 180ms cubic-bezier(.22,1,.36,1);
|
|
562
810
|
display: inline-block; color: var(--dim);
|
|
563
811
|
}
|
|
564
|
-
.steering
|
|
565
|
-
.steering
|
|
566
|
-
display: grid; gap: 0.
|
|
567
|
-
padding: 0.
|
|
568
|
-
border-left: 1px solid var(--rule-soft);
|
|
812
|
+
.steering-task[open] > summary::before { transform: rotate(90deg); }
|
|
813
|
+
.steering-task-body {
|
|
814
|
+
display: grid; gap: 0.35rem;
|
|
815
|
+
padding: 0.3rem 0 0 0.75rem;
|
|
569
816
|
}
|
|
570
|
-
.steering
|
|
571
|
-
font-size: 0.
|
|
572
|
-
letter-spacing: 0.
|
|
817
|
+
.steering-task-label {
|
|
818
|
+
font-size: 0.7em; color: var(--dim);
|
|
819
|
+
letter-spacing: 0.08em; text-transform: uppercase;
|
|
573
820
|
}
|
|
574
|
-
.steering
|
|
821
|
+
.steering-task-body .issue-title {
|
|
575
822
|
margin: 0; font-size: 0.95em; font-weight: 500; color: var(--base);
|
|
576
823
|
}
|
|
577
|
-
.steering
|
|
824
|
+
.steering-task-body .issue-body {
|
|
578
825
|
margin: 0; color: var(--muted); font-size: 0.92em; line-height: 1.5;
|
|
579
826
|
}
|
|
580
827
|
.steering .context {
|
|
@@ -584,142 +831,199 @@ h2:first-child { margin-top: 0.4rem; }
|
|
|
584
831
|
color: var(--muted); white-space: pre-wrap; word-break: break-word;
|
|
585
832
|
max-height: 12em; overflow: auto;
|
|
586
833
|
}
|
|
587
|
-
|
|
588
|
-
.
|
|
834
|
+
form.reply { display: grid; gap: 0.4rem; margin: 0; }
|
|
835
|
+
form.reply textarea {
|
|
589
836
|
background: var(--inset); color: var(--strong);
|
|
590
837
|
border: 1px solid var(--rule-firm); border-radius: 4px;
|
|
591
|
-
padding: 0.
|
|
838
|
+
padding: 0.45rem 0.6rem;
|
|
592
839
|
font: 14px/1.5 ui-sans-serif, system-ui, sans-serif;
|
|
593
|
-
width: 100%; min-height:
|
|
594
|
-
}
|
|
595
|
-
.steering textarea:focus-visible { outline: 1px solid var(--accent); outline-offset: 0; border-color: var(--accent); }
|
|
596
|
-
.steering .reply-row { display: flex; align-items: center; gap: 0.75rem; }
|
|
597
|
-
.steering .hint { font-size: 0.82em; }
|
|
598
|
-
.ghost {
|
|
599
|
-
background: var(--chip); color: var(--base);
|
|
600
|
-
border: 1px solid var(--rule-firm); border-radius: 4px;
|
|
601
|
-
padding: 0.35rem 0.85rem; font: inherit; cursor: pointer;
|
|
602
|
-
transition: border-color 180ms cubic-bezier(.22,1,.36,1);
|
|
603
|
-
}
|
|
604
|
-
.ghost:hover { border-color: var(--muted); }
|
|
605
|
-
.ghost:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
606
|
-
.ghost:disabled { color: var(--dim); cursor: not-allowed; }
|
|
607
|
-
|
|
608
|
-
.retry-list { list-style: none; padding: 0; margin: 0.4rem 0 0; }
|
|
609
|
-
.retry-list li {
|
|
610
|
-
display: grid;
|
|
611
|
-
grid-template-columns: max-content max-content max-content max-content;
|
|
612
|
-
gap: 0.55rem; align-items: center;
|
|
613
|
-
padding: 0.4rem 0; border-bottom: 1px solid var(--rule-soft);
|
|
614
|
-
font-variant-numeric: tabular-nums;
|
|
615
|
-
}
|
|
616
|
-
.retry-list li:last-child { border-bottom: 0; }
|
|
617
|
-
.retry-list .ident { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
618
|
-
.retry-list .retry-err {
|
|
619
|
-
grid-column: 1 / -1; color: var(--err); font-size: 0.88em;
|
|
620
|
-
padding-top: 0.15rem;
|
|
621
|
-
word-break: break-word;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/* ── sessions ─────────────────────────────────────────────────────────── */
|
|
625
|
-
.sessions { list-style: none; padding: 0; margin: 0; }
|
|
626
|
-
.sessions li.session {
|
|
627
|
-
padding: 0.5rem 0; border-bottom: 1px solid var(--rule-soft);
|
|
628
|
-
}
|
|
629
|
-
.sessions li.session:last-child { border-bottom: 0; }
|
|
630
|
-
.session-row {
|
|
631
|
-
display: flex; align-items: baseline; gap: 0.65rem;
|
|
632
|
-
font-variant-numeric: tabular-nums;
|
|
840
|
+
width: 100%; min-height: 60px; resize: vertical;
|
|
633
841
|
}
|
|
634
|
-
.
|
|
635
|
-
|
|
636
|
-
.session-row .tokens { font-size: 0.88em; }
|
|
637
|
-
.session-msg {
|
|
638
|
-
margin-top: 0.2rem; padding-left: 0.05rem;
|
|
639
|
-
font-size: 0.9em; line-height: 1.45;
|
|
640
|
-
overflow-wrap: anywhere;
|
|
641
|
-
max-height: 2.9em; overflow: hidden;
|
|
842
|
+
form.reply textarea:focus-visible {
|
|
843
|
+
outline: 1px solid var(--accent); outline-offset: 0; border-color: var(--accent);
|
|
642
844
|
}
|
|
845
|
+
form.reply .reply-row { display: flex; align-items: center; gap: 0.75rem; }
|
|
846
|
+
form.reply .hint { font-size: 0.82em; }
|
|
643
847
|
|
|
644
|
-
/* ──
|
|
645
|
-
.
|
|
646
|
-
.
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
gap: 0.5rem; align-items: baseline;
|
|
650
|
-
padding: 0.35rem 0; border-bottom: 1px solid var(--rule-soft);
|
|
848
|
+
/* ── totals footer ───────────────────────────────────────────────────── */
|
|
849
|
+
footer.totals {
|
|
850
|
+
padding: 0.85rem 1.25rem;
|
|
851
|
+
border-top: 1px solid var(--rule-soft);
|
|
852
|
+
color: var(--dim); font-size: 0.85em;
|
|
651
853
|
font-variant-numeric: tabular-nums;
|
|
854
|
+
display: flex; gap: 0.65rem; flex-wrap: wrap;
|
|
652
855
|
}
|
|
653
|
-
.
|
|
654
|
-
.disk .ident { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--strong); }
|
|
655
|
-
.disk .state { font-size: 0.85em; }
|
|
656
|
-
.disk .title { color: var(--base); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
657
|
-
.count { font-weight: 400; font-size: 0.88em; margin-left: 0.4rem; }
|
|
856
|
+
footer.totals:empty { display: none; }
|
|
658
857
|
|
|
659
|
-
/* ── new
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
border-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
858
|
+
/* ── right-side new-issue panel (the One Card) ───────────────────────── */
|
|
859
|
+
.new-panel {
|
|
860
|
+
position: fixed; top: 0; right: 0;
|
|
861
|
+
width: 400px; max-width: 90vw;
|
|
862
|
+
height: 100vh;
|
|
863
|
+
background: var(--raised);
|
|
864
|
+
border-left: 1px solid var(--rule-firm);
|
|
865
|
+
display: flex; flex-direction: column;
|
|
866
|
+
transform: translateX(100%);
|
|
867
|
+
transition: transform 320ms cubic-bezier(.22, 1, .36, 1),
|
|
868
|
+
visibility 0s linear 320ms;
|
|
869
|
+
visibility: hidden;
|
|
870
|
+
z-index: 20;
|
|
871
|
+
}
|
|
872
|
+
.new-panel.open {
|
|
873
|
+
transform: translateX(0);
|
|
874
|
+
visibility: visible;
|
|
875
|
+
transition: transform 320ms cubic-bezier(.22, 1, .36, 1);
|
|
876
|
+
}
|
|
877
|
+
.np-head {
|
|
878
|
+
display: flex; align-items: center; gap: 0.75rem;
|
|
879
|
+
padding: 1rem 1.25rem;
|
|
880
|
+
border-bottom: 1px solid var(--rule-soft);
|
|
881
|
+
}
|
|
882
|
+
.np-head h2 { margin: 0; font-size: 1rem; font-weight: 500; color: var(--strong); }
|
|
883
|
+
.np-close {
|
|
884
|
+
margin-left: auto;
|
|
885
|
+
background: var(--chip); color: var(--muted);
|
|
886
|
+
border: 1px solid var(--rule-firm); border-radius: 4px;
|
|
887
|
+
width: 28px; height: 28px;
|
|
888
|
+
cursor: pointer; font: inherit; font-size: 16px; line-height: 1;
|
|
889
|
+
transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
|
|
669
890
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
891
|
+
.np-close:hover { color: var(--strong); border-color: var(--muted); }
|
|
892
|
+
.np-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
893
|
+
.np-form {
|
|
894
|
+
display: flex; flex-direction: column; gap: 0.35rem;
|
|
895
|
+
padding: 1rem 1.25rem; flex: 1; overflow: auto;
|
|
675
896
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
form.create {
|
|
679
|
-
display: grid; grid-template-columns: max-content 1fr; gap: 0.5rem 1rem;
|
|
680
|
-
align-items: center; padding: 1rem 0 0; margin-top: 0.4rem;
|
|
897
|
+
.np-form label {
|
|
898
|
+
color: var(--muted); font-size: 14px; margin-top: 0.5rem;
|
|
681
899
|
}
|
|
682
|
-
form
|
|
683
|
-
form
|
|
900
|
+
.np-form label:first-of-type { margin-top: 0; }
|
|
901
|
+
.np-form select, .np-form input, .np-form textarea {
|
|
684
902
|
background: var(--inset); color: var(--strong);
|
|
685
903
|
border: 1px solid var(--rule-firm); border-radius: 4px;
|
|
686
|
-
padding: 0.
|
|
904
|
+
padding: 0.45rem 0.6rem; font: inherit; width: 100%;
|
|
687
905
|
}
|
|
688
|
-
|
|
906
|
+
.np-form textarea { resize: vertical; min-height: 120px; }
|
|
907
|
+
.np-form select:focus-visible,
|
|
908
|
+
.np-form input:focus-visible,
|
|
909
|
+
.np-form textarea:focus-visible {
|
|
689
910
|
outline: 1px solid var(--accent); outline-offset: 0; border-color: var(--accent);
|
|
690
911
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
912
|
+
.np-actions {
|
|
913
|
+
display: flex; align-items: center; gap: 0.75rem;
|
|
914
|
+
margin-top: 0.9rem;
|
|
915
|
+
}
|
|
916
|
+
.np-msg {
|
|
917
|
+
flex: 1; color: var(--muted); font-size: 0.88em;
|
|
918
|
+
min-height: 1.2em; line-height: 1.2;
|
|
694
919
|
}
|
|
695
|
-
|
|
920
|
+
.np-msg.err { color: var(--err); }
|
|
921
|
+
.np-msg.ok { color: var(--run-fg); }
|
|
922
|
+
.np-submit {
|
|
696
923
|
background: var(--accent); color: #f4f6fb; border: 0;
|
|
697
|
-
padding: 0.
|
|
924
|
+
padding: 0.55rem 1.1rem; border-radius: 4px;
|
|
925
|
+
font: inherit; cursor: pointer;
|
|
698
926
|
transition: filter 180ms cubic-bezier(.22,1,.36,1);
|
|
699
|
-
flex: 0 0 auto;
|
|
700
927
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
form.create .msg { min-height: 1.2em; color: var(--muted); font-size: 0.9em; line-height: 1.2; }
|
|
704
|
-
form.create .msg.err { color: var(--err); }
|
|
705
|
-
form.create .msg.ok { color: var(--run-fg); }
|
|
706
|
-
|
|
707
|
-
/* ── totals footer ────────────────────────────────────────────────────── */
|
|
708
|
-
footer.totals {
|
|
709
|
-
margin-top: 2.5rem; padding-top: 0.85rem;
|
|
710
|
-
border-top: 1px solid var(--rule-soft);
|
|
711
|
-
color: var(--dim); font-size: 0.88em;
|
|
712
|
-
font-variant-numeric: tabular-nums;
|
|
713
|
-
display: flex; gap: 0.65rem; flex-wrap: wrap;
|
|
714
|
-
}
|
|
715
|
-
footer.totals:empty { display: none; }
|
|
716
|
-
|
|
717
|
-
.empty { padding: 0.5rem 0; }
|
|
928
|
+
.np-submit:hover { filter: brightness(1.08); }
|
|
929
|
+
.np-submit:focus-visible { outline: 2px solid var(--strong); outline-offset: 2px; }
|
|
718
930
|
|
|
719
931
|
/* htmx ergonomics */
|
|
720
932
|
.htmx-request .refresh { opacity: 0.6; }
|
|
721
|
-
.htmx-settling .
|
|
722
|
-
|
|
933
|
+
.htmx-settling .row-peek { opacity: 0.85; }
|
|
934
|
+
`;
|
|
935
|
+
// Dashboard new-issue panel controller. Stored as a top-level constant so the
|
|
936
|
+
// renderDashboardHtml shell stays inside the imperative-shell line budget.
|
|
937
|
+
const DASHBOARD_SCRIPT = `
|
|
938
|
+
(() => {
|
|
939
|
+
const $ = (id) => document.getElementById(id);
|
|
940
|
+
const panel = $('new-panel');
|
|
941
|
+
const stateSelect = $('np-state');
|
|
942
|
+
const titleInput = $('np-title-input');
|
|
943
|
+
const descTextarea = $('np-description');
|
|
944
|
+
const npMsg = $('np-msg');
|
|
945
|
+
const form = $('np-form');
|
|
946
|
+
|
|
947
|
+
const setMsg = (text, cls) => {
|
|
948
|
+
npMsg.className = 'np-msg' + (cls ? ' ' + cls : '');
|
|
949
|
+
npMsg.textContent = text;
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
const openPanel = (targetState) => {
|
|
953
|
+
if (targetState) {
|
|
954
|
+
for (const opt of stateSelect.options) {
|
|
955
|
+
if (opt.value === targetState) { stateSelect.value = targetState; break; }
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
setMsg('');
|
|
959
|
+
panel.classList.add('open');
|
|
960
|
+
panel.setAttribute('aria-hidden', 'false');
|
|
961
|
+
requestAnimationFrame(() => setTimeout(() => titleInput.focus(), 120));
|
|
962
|
+
};
|
|
963
|
+
const closePanel = () => {
|
|
964
|
+
panel.classList.remove('open');
|
|
965
|
+
panel.setAttribute('aria-hidden', 'true');
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
document.addEventListener('click', (ev) => {
|
|
969
|
+
const t = ev.target;
|
|
970
|
+
if (!(t instanceof Element)) return;
|
|
971
|
+
const addBtn = t.closest('.col-add');
|
|
972
|
+
if (addBtn) {
|
|
973
|
+
openPanel(addBtn.getAttribute('data-target-state') || '');
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (t.closest('.np-close')) { closePanel(); return; }
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
document.addEventListener('keydown', (ev) => {
|
|
980
|
+
if (ev.key === 'Escape' && panel.classList.contains('open')) {
|
|
981
|
+
ev.preventDefault(); closePanel(); return;
|
|
982
|
+
}
|
|
983
|
+
const t = ev.target;
|
|
984
|
+
if (!(t instanceof HTMLTextAreaElement)) return;
|
|
985
|
+
if (!t.closest('form.reply')) return;
|
|
986
|
+
if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) {
|
|
987
|
+
ev.preventDefault();
|
|
988
|
+
t.form && t.form.requestSubmit();
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
form.addEventListener('submit', async (ev) => {
|
|
993
|
+
ev.preventDefault();
|
|
994
|
+
setMsg('creating…');
|
|
995
|
+
const body = {
|
|
996
|
+
state: stateSelect.value,
|
|
997
|
+
title: titleInput.value.trim(),
|
|
998
|
+
description: descTextarea.value,
|
|
999
|
+
};
|
|
1000
|
+
try {
|
|
1001
|
+
const res = await fetch('/api/v1/issues', {
|
|
1002
|
+
method: 'POST',
|
|
1003
|
+
headers: { 'content-type': 'application/json' },
|
|
1004
|
+
body: JSON.stringify(body),
|
|
1005
|
+
});
|
|
1006
|
+
const data = await res.json();
|
|
1007
|
+
if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status));
|
|
1008
|
+
setMsg('created ' + data.identifier, 'ok');
|
|
1009
|
+
titleInput.value = '';
|
|
1010
|
+
descTextarea.value = '';
|
|
1011
|
+
fetch('/api/v1/refresh', { method: 'POST' }).catch(() => {});
|
|
1012
|
+
document.body.dispatchEvent(new CustomEvent('refreshed', { bubbles: true }));
|
|
1013
|
+
setTimeout(() => { closePanel(); setMsg(''); }, 900);
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
setMsg(err.message, 'err');
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
})();
|
|
1019
|
+
`;
|
|
1020
|
+
function renderDashboardHtml(p) {
|
|
1021
|
+
return `<!doctype html>
|
|
1022
|
+
<html lang="en"><head>
|
|
1023
|
+
<meta charset="utf-8">
|
|
1024
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1025
|
+
<title>symphony · ${escapeHtml(p.workflowName)}</title>
|
|
1026
|
+
<style>${DASHBOARD_CSS}</style>
|
|
723
1027
|
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
|
724
1028
|
<script src="https://unpkg.com/idiomorph@0.7.4/dist/idiomorph-ext.min.js"></script>
|
|
725
1029
|
</head><body hx-ext="morph">
|
|
@@ -737,103 +1041,415 @@ footer.totals:empty { display: none; }
|
|
|
737
1041
|
aria-label="refresh now" title="poll & reconcile">⟳</button>
|
|
738
1042
|
</header>
|
|
739
1043
|
|
|
740
|
-
<
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
hx-get="/api/v1/partials/attention" hx-trigger="every 2s, refreshed from:body"
|
|
744
|
-
hx-swap="morph:innerHTML">${renderAttentionPartial(p)}</section>
|
|
1044
|
+
<div id="ticker"
|
|
1045
|
+
hx-get="/api/v1/partials/attention" hx-trigger="every 2s, refreshed from:body"
|
|
1046
|
+
hx-swap="morph:innerHTML">${renderAttentionPartial(p)}</div>
|
|
745
1047
|
|
|
746
|
-
<
|
|
747
|
-
|
|
748
|
-
|
|
1048
|
+
<main id="board"
|
|
1049
|
+
hx-get="/api/v1/partials/board" hx-trigger="every 2s, refreshed from:body"
|
|
1050
|
+
hx-swap="morph:innerHTML">${renderBoardPartial(p)}</main>
|
|
749
1051
|
|
|
750
|
-
|
|
751
|
-
hx-get="/api/v1/partials/disk" hx-trigger="every 2s, refreshed from:body"
|
|
752
|
-
hx-swap="morph:innerHTML">${renderDiskPartial(p)}</section>
|
|
753
|
-
|
|
754
|
-
<details class="new-issue"${formOpen ? ' open' : ''}>
|
|
755
|
-
<summary>new issue</summary>
|
|
756
|
-
<form class="create" id="create-form">
|
|
757
|
-
<label for="identifier">identifier</label>
|
|
758
|
-
<input id="identifier" name="identifier" required placeholder="ABC-42" autocomplete="off" />
|
|
759
|
-
<label for="title">title</label>
|
|
760
|
-
<input id="title" name="title" required autocomplete="off" />
|
|
761
|
-
<label for="state">state</label>
|
|
762
|
-
<select id="state" name="state">${stateOptions}</select>
|
|
763
|
-
<label for="priority">priority</label>
|
|
764
|
-
<input id="priority" name="priority" type="number" min="0" max="10" placeholder="2" />
|
|
765
|
-
<label for="labels">labels</label>
|
|
766
|
-
<input id="labels" name="labels" placeholder="bug, urgent" autocomplete="off" />
|
|
767
|
-
<label for="description">description</label>
|
|
768
|
-
<textarea id="description" name="description" placeholder="what needs to be done?"></textarea>
|
|
769
|
-
<div class="submit-row">
|
|
770
|
-
<button type="submit">create issue</button>
|
|
771
|
-
<span class="msg" id="create-msg" role="status" aria-live="polite"></span>
|
|
772
|
-
</div>
|
|
773
|
-
</form>
|
|
774
|
-
</details>
|
|
1052
|
+
${renderNewIssuePanel(p)}
|
|
775
1053
|
|
|
776
1054
|
<footer class="totals"
|
|
777
1055
|
hx-get="/api/v1/partials/totals" hx-trigger="every 2s, refreshed from:body"
|
|
778
1056
|
hx-swap="morph:innerHTML">${renderTotalsPartial(p)}</footer>
|
|
779
1057
|
|
|
780
|
-
</
|
|
781
|
-
|
|
782
|
-
<script>
|
|
783
|
-
const $ = (id) => document.getElementById(id);
|
|
784
|
-
|
|
785
|
-
// Enter-to-send on steering textareas. HTMX submits the form; shift+enter still inserts a newline.
|
|
786
|
-
document.addEventListener('keydown', (ev) => {
|
|
787
|
-
const t = ev.target;
|
|
788
|
-
if (!(t instanceof HTMLTextAreaElement)) return;
|
|
789
|
-
if (!t.closest('form.reply')) return;
|
|
790
|
-
if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) {
|
|
791
|
-
ev.preventDefault();
|
|
792
|
-
t.form && t.form.requestSubmit();
|
|
793
|
-
}
|
|
794
|
-
});
|
|
795
|
-
|
|
796
|
-
// Create-issue form: stays on fetch+JSON (the create POST takes structured arrays). After
|
|
797
|
-
// success we fire a custom event the polling regions listen to via hx-trigger.
|
|
798
|
-
$('create-form').addEventListener('submit', async (ev) => {
|
|
799
|
-
ev.preventDefault();
|
|
800
|
-
const msg = $('create-msg');
|
|
801
|
-
msg.className = 'msg'; msg.textContent = 'creating…';
|
|
802
|
-
const labels = $('labels').value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
803
|
-
const priorityRaw = $('priority').value.trim();
|
|
804
|
-
const body = {
|
|
805
|
-
identifier: $('identifier').value.trim(),
|
|
806
|
-
title: $('title').value.trim(),
|
|
807
|
-
state: $('state').value,
|
|
808
|
-
description: $('description').value,
|
|
809
|
-
labels,
|
|
810
|
-
priority: priorityRaw === '' ? null : Number(priorityRaw),
|
|
811
|
-
};
|
|
812
|
-
try {
|
|
813
|
-
const res = await fetch('/api/v1/issues', {
|
|
814
|
-
method: 'POST',
|
|
815
|
-
headers: { 'content-type': 'application/json' },
|
|
816
|
-
body: JSON.stringify(body),
|
|
817
|
-
});
|
|
818
|
-
const data = await res.json();
|
|
819
|
-
if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status));
|
|
820
|
-
msg.className = 'msg ok';
|
|
821
|
-
msg.textContent = 'created ' + data.identifier;
|
|
822
|
-
$('create-form').reset();
|
|
823
|
-
fetch('/api/v1/refresh', { method: 'POST' }).catch(() => {});
|
|
824
|
-
document.body.dispatchEvent(new CustomEvent('refreshed', { bubbles: true }));
|
|
825
|
-
} catch (err) {
|
|
826
|
-
msg.className = 'msg err';
|
|
827
|
-
msg.textContent = err.message;
|
|
828
|
-
}
|
|
829
|
-
});
|
|
830
|
-
</script>
|
|
1058
|
+
<script>${DASHBOARD_SCRIPT}</script>
|
|
831
1059
|
|
|
832
1060
|
</body></html>`;
|
|
833
1061
|
}
|
|
834
1062
|
function escapeHtml(s) {
|
|
835
1063
|
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
836
1064
|
}
|
|
1065
|
+
const FENCE_OPEN_RE = /^```([\w-]*)\s*$/;
|
|
1066
|
+
const FENCE_CLOSE_RE = /^```\s*$/;
|
|
1067
|
+
const HEADER_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
1068
|
+
const UL_RE = /^\s*[-*+]\s+/;
|
|
1069
|
+
const UL_ITEM_RE = /^\s*[-*+]\s+(.*)$/;
|
|
1070
|
+
const OL_RE = /^\s*\d+\.\s+/;
|
|
1071
|
+
const OL_ITEM_RE = /^\s*\d+\.\s+(.*)$/;
|
|
1072
|
+
const BLOCKQUOTE_RE = /^\s*>\s?/;
|
|
1073
|
+
function matchFencedCode(lines, i) {
|
|
1074
|
+
const fence = FENCE_OPEN_RE.exec(lines[i]);
|
|
1075
|
+
if (!fence)
|
|
1076
|
+
return null;
|
|
1077
|
+
const lang = fence[1] ?? '';
|
|
1078
|
+
const body = [];
|
|
1079
|
+
let j = i + 1;
|
|
1080
|
+
while (j < lines.length && !FENCE_CLOSE_RE.test(lines[j])) {
|
|
1081
|
+
body.push(lines[j]);
|
|
1082
|
+
j++;
|
|
1083
|
+
}
|
|
1084
|
+
if (j < lines.length)
|
|
1085
|
+
j++; // consume closing fence
|
|
1086
|
+
const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : '';
|
|
1087
|
+
return { html: `<pre><code${langAttr}>${escapeHtml(body.join('\n'))}</code></pre>`, nextI: j };
|
|
1088
|
+
}
|
|
1089
|
+
function matchHeader(lines, i) {
|
|
1090
|
+
const m = HEADER_RE.exec(lines[i]);
|
|
1091
|
+
if (!m)
|
|
1092
|
+
return null;
|
|
1093
|
+
const level = m[1].length;
|
|
1094
|
+
return { html: `<h${level}>${renderInlineMarkdown(m[2])}</h${level}>`, nextI: i + 1 };
|
|
1095
|
+
}
|
|
1096
|
+
function matchList(lines, i, marker, item, tag) {
|
|
1097
|
+
if (!marker.test(lines[i]))
|
|
1098
|
+
return null;
|
|
1099
|
+
const items = [];
|
|
1100
|
+
let j = i;
|
|
1101
|
+
while (j < lines.length && marker.test(lines[j])) {
|
|
1102
|
+
items.push(`<li>${renderInlineMarkdown(item.exec(lines[j])[1])}</li>`);
|
|
1103
|
+
j++;
|
|
1104
|
+
}
|
|
1105
|
+
return { html: `<${tag}>${items.join('')}</${tag}>`, nextI: j };
|
|
1106
|
+
}
|
|
1107
|
+
function matchBlockquote(lines, i) {
|
|
1108
|
+
if (!BLOCKQUOTE_RE.test(lines[i]))
|
|
1109
|
+
return null;
|
|
1110
|
+
const buf = [];
|
|
1111
|
+
let j = i;
|
|
1112
|
+
while (j < lines.length && BLOCKQUOTE_RE.test(lines[j])) {
|
|
1113
|
+
buf.push(lines[j].replace(BLOCKQUOTE_RE, ''));
|
|
1114
|
+
j++;
|
|
1115
|
+
}
|
|
1116
|
+
return { html: `<blockquote>${renderMarkdown(buf.join('\n'))}</blockquote>`, nextI: j };
|
|
1117
|
+
}
|
|
1118
|
+
function isBlockBoundary(line) {
|
|
1119
|
+
return line.trim() === ''
|
|
1120
|
+
|| FENCE_OPEN_RE.test(line)
|
|
1121
|
+
|| /^#{1,6}\s+/.test(line)
|
|
1122
|
+
|| UL_RE.test(line)
|
|
1123
|
+
|| OL_RE.test(line)
|
|
1124
|
+
|| BLOCKQUOTE_RE.test(line);
|
|
1125
|
+
}
|
|
1126
|
+
function matchParagraph(lines, i) {
|
|
1127
|
+
const para = [];
|
|
1128
|
+
let j = i;
|
|
1129
|
+
while (j < lines.length && !isBlockBoundary(lines[j])) {
|
|
1130
|
+
para.push(lines[j]);
|
|
1131
|
+
j++;
|
|
1132
|
+
}
|
|
1133
|
+
return { html: `<p>${renderInlineMarkdown(para.join('\n'))}</p>`, nextI: j };
|
|
1134
|
+
}
|
|
1135
|
+
export function renderMarkdown(input) {
|
|
1136
|
+
const lines = input.replace(/\r\n?/g, '\n').split('\n');
|
|
1137
|
+
const blocks = [];
|
|
1138
|
+
let i = 0;
|
|
1139
|
+
while (i < lines.length) {
|
|
1140
|
+
if (lines[i].trim() === '') {
|
|
1141
|
+
i++;
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
const m = matchFencedCode(lines, i)
|
|
1145
|
+
?? matchHeader(lines, i)
|
|
1146
|
+
?? matchList(lines, i, UL_RE, UL_ITEM_RE, 'ul')
|
|
1147
|
+
?? matchList(lines, i, OL_RE, OL_ITEM_RE, 'ol')
|
|
1148
|
+
?? matchBlockquote(lines, i)
|
|
1149
|
+
?? matchParagraph(lines, i);
|
|
1150
|
+
blocks.push(m.html);
|
|
1151
|
+
i = m.nextI;
|
|
1152
|
+
}
|
|
1153
|
+
return blocks.join('\n');
|
|
1154
|
+
}
|
|
1155
|
+
function renderInlineMarkdown(input) {
|
|
1156
|
+
const codes = [];
|
|
1157
|
+
let text = input.replace(/`([^`\n]+)`/g, (_m, code) => {
|
|
1158
|
+
const idx = codes.length;
|
|
1159
|
+
codes.push(`<code>${escapeHtml(code)}</code>`);
|
|
1160
|
+
return `C${idx}`;
|
|
1161
|
+
});
|
|
1162
|
+
text = escapeHtml(text);
|
|
1163
|
+
text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
|
|
1164
|
+
if (!/^(https?:|mailto:)/i.test(url))
|
|
1165
|
+
return m;
|
|
1166
|
+
return `<a href="${url}" rel="noopener noreferrer">${label}</a>`;
|
|
1167
|
+
});
|
|
1168
|
+
text = text.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
1169
|
+
text = text.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
|
|
1170
|
+
text = text.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1<em>$2</em>');
|
|
1171
|
+
text = text.replace(/(^|[^_\w])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
|
|
1172
|
+
text = text.replace(/\n/g, '<br>');
|
|
1173
|
+
text = text.replace(/C(\d+)/g, (_m, idx) => codes[Number(idx)]);
|
|
1174
|
+
return text;
|
|
1175
|
+
}
|
|
1176
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1177
|
+
// Issue-detail page. Linked from the identifier on every "on disk" and triage
|
|
1178
|
+
// row. Shows what the on-disk Markdown file says: the front-matter metadata
|
|
1179
|
+
// (labels, priority, blocked_by, provenance, timestamps) and the body rendered
|
|
1180
|
+
// through the same minimal Markdown engine the steering panel uses. Read-only
|
|
1181
|
+
// — actions live on the dashboard, not here. Follows the design system's
|
|
1182
|
+
// quiet-workshop rules: flat panels, tabular numerics, no shadows, status pill
|
|
1183
|
+
// uses the existing palette.
|
|
1184
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1185
|
+
function asStringList(v) {
|
|
1186
|
+
if (Array.isArray(v))
|
|
1187
|
+
return v.filter((x) => typeof x === 'string');
|
|
1188
|
+
return [];
|
|
1189
|
+
}
|
|
1190
|
+
function asString(v) {
|
|
1191
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
1192
|
+
}
|
|
1193
|
+
function asNumber(v) {
|
|
1194
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
1195
|
+
return v;
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
function formatTimestamp(v) {
|
|
1199
|
+
if (typeof v === 'string' && v.length > 0)
|
|
1200
|
+
return v;
|
|
1201
|
+
if (v instanceof Date)
|
|
1202
|
+
return v.toISOString();
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
function detailRow(label, value) {
|
|
1206
|
+
return `<div class="meta-row"><dt>${escapeHtml(label)}</dt><dd>${value}</dd></div>`;
|
|
1207
|
+
}
|
|
1208
|
+
function detailLabelsRow(labels) {
|
|
1209
|
+
if (labels.length === 0)
|
|
1210
|
+
return '';
|
|
1211
|
+
const chips = labels.map((l) => `<span class="label-chip">${escapeHtml(l)}</span>`).join('');
|
|
1212
|
+
return detailRow('labels', `<div class="label-chips">${chips}</div>`);
|
|
1213
|
+
}
|
|
1214
|
+
function detailBlockedByRow(blockedBy) {
|
|
1215
|
+
if (blockedBy.length === 0)
|
|
1216
|
+
return '';
|
|
1217
|
+
const items = blockedBy
|
|
1218
|
+
.map((b) => `<a class="blocker-link" href="/issues/${encodeURIComponent(b)}">${escapeHtml(b)}</a>`)
|
|
1219
|
+
.join(', ');
|
|
1220
|
+
return detailRow('blocked by', items);
|
|
1221
|
+
}
|
|
1222
|
+
function detailUrlRow(url) {
|
|
1223
|
+
if (!url)
|
|
1224
|
+
return '';
|
|
1225
|
+
const safe = /^https?:/i.test(url) ? url : '';
|
|
1226
|
+
const value = safe
|
|
1227
|
+
? `<a href="${escapeHtml(safe)}" rel="noopener noreferrer">${escapeHtml(safe)}</a>`
|
|
1228
|
+
: escapeHtml(url);
|
|
1229
|
+
return detailRow('url', value);
|
|
1230
|
+
}
|
|
1231
|
+
function detailProposedByRow(proposedBy) {
|
|
1232
|
+
if (!proposedBy)
|
|
1233
|
+
return '';
|
|
1234
|
+
return detailRow('proposed by', `<a class="blocker-link" href="/issues/${encodeURIComponent(proposedBy)}">${escapeHtml(proposedBy)}</a>`);
|
|
1235
|
+
}
|
|
1236
|
+
function detailTimestampRow(label, ts) {
|
|
1237
|
+
if (!ts)
|
|
1238
|
+
return '';
|
|
1239
|
+
return detailRow(label, `<span class="num">${escapeHtml(ts)}</span>`);
|
|
1240
|
+
}
|
|
1241
|
+
// Pill colour follows the declared role: active → running, terminal → done,
|
|
1242
|
+
// holding → idle. An undeclared state (stale directory, file dropped by hand
|
|
1243
|
+
// into a name not in `states:`) falls back to idle so the detail page still
|
|
1244
|
+
// renders legibly.
|
|
1245
|
+
function buildDetailMetaRows(fm, stateClass, stateName) {
|
|
1246
|
+
const branchName = asString(fm['branch_name']);
|
|
1247
|
+
const priority = asNumber(fm['priority']);
|
|
1248
|
+
const createdAt = formatTimestamp(fm['created_at']);
|
|
1249
|
+
const updatedAt = formatTimestamp(fm['updated_at']);
|
|
1250
|
+
const showUpdated = updatedAt && updatedAt !== createdAt ? updatedAt : null;
|
|
1251
|
+
return [
|
|
1252
|
+
detailRow('state', `<span class="pill ${stateClass}">${escapeHtml(stateName)}</span>`),
|
|
1253
|
+
detailLabelsRow(asStringList(fm['labels'])),
|
|
1254
|
+
priority !== null ? detailRow('priority', `<span class="num">${escapeHtml(String(priority))}</span>`) : '',
|
|
1255
|
+
detailBlockedByRow(asStringList(fm['blocked_by'])),
|
|
1256
|
+
branchName ? detailRow('branch', `<code>${escapeHtml(branchName)}</code>`) : '',
|
|
1257
|
+
detailUrlRow(asString(fm['url'])),
|
|
1258
|
+
detailTimestampRow('created', createdAt),
|
|
1259
|
+
detailTimestampRow('updated', showUpdated),
|
|
1260
|
+
detailProposedByRow(asString(fm['proposed_by'])),
|
|
1261
|
+
detailTimestampRow('proposed at', formatTimestamp(fm['proposed_at'])),
|
|
1262
|
+
].join('');
|
|
1263
|
+
}
|
|
1264
|
+
// Issue-detail CSS, hoisted out of renderIssueDetailPage so the function stays
|
|
1265
|
+
// within the imperative-shell line budget. Pure presentation content.
|
|
1266
|
+
const DETAIL_PAGE_CSS = `
|
|
1267
|
+
:root {
|
|
1268
|
+
color-scheme: dark;
|
|
1269
|
+
--inset: #0c0f15; --bench: #0f1115; --raised: #161a22; --chip: #20242c;
|
|
1270
|
+
--rule-soft: #1c2029; --rule-firm: #2a2e36;
|
|
1271
|
+
--dim: #6b7280; --muted: #9aa4b2; --base: #dfe2e7; --strong: #e6ebf2;
|
|
1272
|
+
--accent: #2a6df4;
|
|
1273
|
+
--run-bg: #1f3a26; --run-fg: #58d68d;
|
|
1274
|
+
--idle-bg: #20242c; --idle-fg: #9aa4b2;
|
|
1275
|
+
--done-bg: #1c2a1f; --done-fg: #82c896;
|
|
1276
|
+
--err: #ff7676;
|
|
1277
|
+
}
|
|
1278
|
+
* { box-sizing: border-box; }
|
|
1279
|
+
html, body { background: var(--bench); color: var(--base); }
|
|
1280
|
+
body {
|
|
1281
|
+
font: 14px/1.45 ui-sans-serif, system-ui, sans-serif;
|
|
1282
|
+
margin: 0; padding: 0;
|
|
1283
|
+
display: flex; flex-direction: column; min-height: 100vh;
|
|
1284
|
+
}
|
|
1285
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; color: var(--strong); }
|
|
1286
|
+
a { color: var(--strong); text-decoration: none; }
|
|
1287
|
+
a:hover { color: #9cc0ff; }
|
|
1288
|
+
.dim { color: var(--dim); }
|
|
1289
|
+
.num { font-variant-numeric: tabular-nums; }
|
|
1290
|
+
|
|
1291
|
+
/* header strip mirrors the dashboard so navigation feels continuous */
|
|
1292
|
+
#header {
|
|
1293
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
1294
|
+
padding: 0.65rem 1.5rem;
|
|
1295
|
+
background: var(--bench); border-bottom: 1px solid var(--rule-firm);
|
|
1296
|
+
font-size: 13px;
|
|
1297
|
+
position: sticky; top: 0; z-index: 10;
|
|
1298
|
+
}
|
|
1299
|
+
#header .brand { font-weight: 600; color: var(--strong); letter-spacing: 0.01em; }
|
|
1300
|
+
#header .rule { color: var(--dim); }
|
|
1301
|
+
#header .crumb { color: var(--muted); }
|
|
1302
|
+
#header .crumb-current { color: var(--base); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
1303
|
+
|
|
1304
|
+
main {
|
|
1305
|
+
width: 100%; max-width: 920px; margin: 0 auto;
|
|
1306
|
+
padding: 1rem 1.5rem 2.5rem; flex: 1;
|
|
1307
|
+
}
|
|
1308
|
+
.back {
|
|
1309
|
+
display: inline-block; padding: 0.2rem 0; margin: 0.2rem 0 0.6rem;
|
|
1310
|
+
font-size: 0.88em; color: var(--muted);
|
|
1311
|
+
}
|
|
1312
|
+
.back:hover { color: var(--strong); }
|
|
1313
|
+
|
|
1314
|
+
.issue-head {
|
|
1315
|
+
display: grid; gap: 0.35rem;
|
|
1316
|
+
padding-bottom: 0.6rem; margin-bottom: 1.1rem;
|
|
1317
|
+
border-bottom: 1px solid var(--rule-firm);
|
|
1318
|
+
}
|
|
1319
|
+
.issue-head .ident {
|
|
1320
|
+
font: 12.5px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1321
|
+
color: var(--dim); letter-spacing: 0.02em;
|
|
1322
|
+
}
|
|
1323
|
+
.issue-head h1 {
|
|
1324
|
+
margin: 0; font-size: 1.3rem; font-weight: 500; color: var(--strong);
|
|
1325
|
+
line-height: 1.3;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/* pills carry status colour, sized to read as inline metadata not content */
|
|
1329
|
+
.pill {
|
|
1330
|
+
display: inline-block; padding: 0.1rem 0.55rem; border-radius: 999px;
|
|
1331
|
+
font-size: 0.82em; line-height: 1.4;
|
|
1332
|
+
font-variant-numeric: tabular-nums;
|
|
1333
|
+
}
|
|
1334
|
+
.pill.running { background: var(--run-bg); color: var(--run-fg); }
|
|
1335
|
+
.pill.idle { background: var(--idle-bg); color: var(--idle-fg); }
|
|
1336
|
+
.pill.done { background: var(--done-bg); color: var(--done-fg); }
|
|
1337
|
+
|
|
1338
|
+
/* metadata definition list: tight two-column grid, label left, value right */
|
|
1339
|
+
.issue-meta {
|
|
1340
|
+
margin: 0 0 1.4rem;
|
|
1341
|
+
display: grid; grid-template-columns: 8.5rem 1fr;
|
|
1342
|
+
row-gap: 0.35rem; column-gap: 1rem;
|
|
1343
|
+
font-variant-numeric: tabular-nums;
|
|
1344
|
+
}
|
|
1345
|
+
.issue-meta .meta-row { display: contents; }
|
|
1346
|
+
.issue-meta dt {
|
|
1347
|
+
color: var(--dim); font-size: 0.85em;
|
|
1348
|
+
letter-spacing: 0.05em; text-transform: uppercase;
|
|
1349
|
+
align-self: baseline; padding-top: 0.15rem;
|
|
1350
|
+
}
|
|
1351
|
+
.issue-meta dd { margin: 0; color: var(--base); }
|
|
1352
|
+
.label-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
|
1353
|
+
.label-chip {
|
|
1354
|
+
display: inline-block; padding: 0.05rem 0.5rem; border-radius: 4px;
|
|
1355
|
+
background: var(--chip); color: var(--muted);
|
|
1356
|
+
font-size: 0.85em; line-height: 1.5;
|
|
1357
|
+
}
|
|
1358
|
+
.blocker-link {
|
|
1359
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1360
|
+
font-size: 0.92em; color: var(--muted);
|
|
1361
|
+
}
|
|
1362
|
+
.blocker-link:hover { color: var(--strong); }
|
|
1363
|
+
|
|
1364
|
+
h2.section-title {
|
|
1365
|
+
font-size: 1rem; font-weight: 500; margin: 0 0 0.5rem;
|
|
1366
|
+
padding-bottom: 0.35rem; border-bottom: 1px solid var(--rule-firm);
|
|
1367
|
+
letter-spacing: 0.01em;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
.issue-body { color: var(--base); font-size: 0.95em; line-height: 1.55; }
|
|
1371
|
+
.issue-body > :first-child { margin-top: 0; }
|
|
1372
|
+
.issue-body > :last-child { margin-bottom: 0; }
|
|
1373
|
+
.issue-body p { margin: 0.6em 0; }
|
|
1374
|
+
.issue-body h1, .issue-body h2, .issue-body h3,
|
|
1375
|
+
.issue-body h4, .issue-body h5, .issue-body h6 {
|
|
1376
|
+
margin: 1em 0 0.4em; font-weight: 500; color: var(--strong);
|
|
1377
|
+
border: 0; padding: 0;
|
|
1378
|
+
}
|
|
1379
|
+
.issue-body h1 { font-size: 1.15em; }
|
|
1380
|
+
.issue-body h2 { font-size: 1.05em; }
|
|
1381
|
+
.issue-body h3 { font-size: 1em; }
|
|
1382
|
+
.issue-body ul, .issue-body ol { margin: 0.5em 0; padding-left: 1.5em; }
|
|
1383
|
+
.issue-body li { margin: 0.15em 0; }
|
|
1384
|
+
.issue-body code {
|
|
1385
|
+
background: var(--inset); padding: 0.05em 0.35em; border-radius: 3px;
|
|
1386
|
+
font-size: 0.92em;
|
|
1387
|
+
}
|
|
1388
|
+
.issue-body pre {
|
|
1389
|
+
margin: 0.7em 0; padding: 0.6rem 0.75rem;
|
|
1390
|
+
background: var(--inset); border: 1px solid var(--rule-firm); border-radius: 4px;
|
|
1391
|
+
font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1392
|
+
color: var(--muted); overflow: auto;
|
|
1393
|
+
}
|
|
1394
|
+
.issue-body pre code { background: transparent; padding: 0; font-size: inherit; }
|
|
1395
|
+
.issue-body blockquote {
|
|
1396
|
+
margin: 0.5em 0; padding: 0.1em 0.8em;
|
|
1397
|
+
border-left: 2px solid var(--rule-firm); color: var(--muted);
|
|
1398
|
+
}
|
|
1399
|
+
.issue-body a { color: #9cc0ff; }
|
|
1400
|
+
|
|
1401
|
+
.file-path {
|
|
1402
|
+
margin-top: 1.4rem; padding-top: 0.7rem;
|
|
1403
|
+
border-top: 1px solid var(--rule-soft);
|
|
1404
|
+
color: var(--dim); font-size: 0.82em;
|
|
1405
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1406
|
+
word-break: break-all;
|
|
1407
|
+
}
|
|
1408
|
+
.empty { padding: 0.5rem 0; }
|
|
1409
|
+
`;
|
|
1410
|
+
function renderIssueDetailPage(issue, view) {
|
|
1411
|
+
const fm = issue.frontMatter;
|
|
1412
|
+
const title = asString(fm['title']) ?? issue.identifier;
|
|
1413
|
+
const stateClass = pillClassForState(view.states, issue.state);
|
|
1414
|
+
const metaRows = buildDetailMetaRows(fm, stateClass, issue.state);
|
|
1415
|
+
const bodyHtml = issue.body.length > 0
|
|
1416
|
+
? `<div class="issue-body">${renderMarkdown(issue.body)}</div>`
|
|
1417
|
+
: `<p class="empty dim">no description</p>`;
|
|
1418
|
+
const workflowName = path.basename(view.workflowPath || 'workflow.md');
|
|
1419
|
+
return `<!doctype html>
|
|
1420
|
+
<html lang="en"><head>
|
|
1421
|
+
<meta charset="utf-8">
|
|
1422
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1423
|
+
<title>${escapeHtml(issue.identifier)} · symphony</title>
|
|
1424
|
+
<style>${DETAIL_PAGE_CSS}</style>
|
|
1425
|
+
</head><body>
|
|
1426
|
+
|
|
1427
|
+
<header id="header">
|
|
1428
|
+
<span class="brand">symphony</span>
|
|
1429
|
+
<span class="rule" aria-hidden="true">·</span>
|
|
1430
|
+
<a class="crumb" href="/" title="back to ${escapeHtml(workflowName)}">${escapeHtml(workflowName)}</a>
|
|
1431
|
+
<span class="rule" aria-hidden="true">/</span>
|
|
1432
|
+
<span class="crumb-current">${escapeHtml(issue.identifier)}</span>
|
|
1433
|
+
</header>
|
|
1434
|
+
|
|
1435
|
+
<main>
|
|
1436
|
+
<a class="back" href="/">← back</a>
|
|
1437
|
+
|
|
1438
|
+
<section class="issue-head">
|
|
1439
|
+
<span class="ident">${escapeHtml(issue.identifier)}</span>
|
|
1440
|
+
<h1>${escapeHtml(title)}</h1>
|
|
1441
|
+
</section>
|
|
1442
|
+
|
|
1443
|
+
<dl class="issue-meta">${metaRows}</dl>
|
|
1444
|
+
|
|
1445
|
+
<h2 class="section-title">description</h2>
|
|
1446
|
+
${bodyHtml}
|
|
1447
|
+
|
|
1448
|
+
<p class="file-path" title="path on disk">${escapeHtml(issue.filePath)}</p>
|
|
1449
|
+
</main>
|
|
1450
|
+
|
|
1451
|
+
</body></html>`;
|
|
1452
|
+
}
|
|
837
1453
|
// Resolves once the server has either bound the requested port or rejected with the bind
|
|
838
1454
|
// error so CLI startup can surface EADDRINUSE / EACCES instead of an unhandled rejection.
|
|
839
1455
|
// Returns the *actually bound* port (relevant for --port 0, where the kernel picks an
|
|
@@ -895,295 +1511,391 @@ async function handleRequest(req, res, orch, opts) {
|
|
|
895
1511
|
// URL parsing inside the handler so a malformed Host header doesn't crash the listener.
|
|
896
1512
|
let pathname;
|
|
897
1513
|
try {
|
|
898
|
-
|
|
899
|
-
pathname = url.pathname;
|
|
1514
|
+
pathname = new URL(req.url ?? '/', 'http://symphony.local').pathname;
|
|
900
1515
|
}
|
|
901
1516
|
catch {
|
|
902
1517
|
return badRequest(res, 'invalid request URL');
|
|
903
1518
|
}
|
|
904
1519
|
const method = (req.method ?? 'GET').toUpperCase();
|
|
905
1520
|
const view = opts.getTrackerView();
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
res
|
|
913
|
-
|
|
1521
|
+
const route = matchRoute(pathname);
|
|
1522
|
+
const handler = ROUTE_HANDLERS[route.kind];
|
|
1523
|
+
await handler({ req, res, orch, opts, view, method, route });
|
|
1524
|
+
}
|
|
1525
|
+
async function handleDashboard(ctx) {
|
|
1526
|
+
if (ctx.method !== 'GET')
|
|
1527
|
+
return methodNotAllowed(ctx.res);
|
|
1528
|
+
const p = await gatherPartialInputs(ctx.orch, ctx.view);
|
|
1529
|
+
ctx.res.statusCode = 200;
|
|
1530
|
+
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1531
|
+
ctx.res.end(renderDashboardHtml(p));
|
|
1532
|
+
}
|
|
1533
|
+
// Static preview for impeccable live mode. The file under .impeccable/preview/ is a
|
|
1534
|
+
// captured snapshot of the dashboard with polling disabled, used as a variant playground.
|
|
1535
|
+
// Read on every request so live-wrap edits land immediately.
|
|
1536
|
+
async function handlePreview(ctx) {
|
|
1537
|
+
if (ctx.method !== 'GET')
|
|
1538
|
+
return methodNotAllowed(ctx.res);
|
|
1539
|
+
try {
|
|
1540
|
+
const html = await readFile('.impeccable/preview/dashboard.html', 'utf8');
|
|
1541
|
+
ctx.res.statusCode = 200;
|
|
1542
|
+
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1543
|
+
ctx.res.setHeader('cache-control', 'no-store');
|
|
1544
|
+
ctx.res.end(html);
|
|
1545
|
+
}
|
|
1546
|
+
catch (err) {
|
|
1547
|
+
notFound(ctx.res, 'preview_missing', `preview not available: ${err.message}`);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
// HTMX partials. Each region polls its own endpoint at 2s; this is what the dashboard
|
|
1551
|
+
// <section hx-get=...> elements consume. They return only the inner HTML; the outer
|
|
1552
|
+
// wrapper is in the dashboard shell.
|
|
1553
|
+
async function handlePartial(ctx) {
|
|
1554
|
+
if (ctx.method !== 'GET')
|
|
1555
|
+
return methodNotAllowed(ctx.res);
|
|
1556
|
+
const name = resolvePartialName(ctx.route.slug);
|
|
1557
|
+
if (!name) {
|
|
1558
|
+
return notFound(ctx.res, 'partial_not_found', `partial ${ctx.route.slug} does not exist`);
|
|
1559
|
+
}
|
|
1560
|
+
const p = await gatherPartialInputs(ctx.orch, ctx.view);
|
|
1561
|
+
const body = name === 'header' ? renderHeaderPartial(p)
|
|
1562
|
+
: name === 'attention' ? renderAttentionPartial(p)
|
|
1563
|
+
: name === 'board' ? renderBoardPartial(p)
|
|
1564
|
+
: renderTotalsPartial(p);
|
|
1565
|
+
ctx.res.statusCode = 200;
|
|
1566
|
+
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1567
|
+
ctx.res.setHeader('cache-control', 'no-store');
|
|
1568
|
+
ctx.res.end(body);
|
|
1569
|
+
}
|
|
1570
|
+
async function handleState(ctx) {
|
|
1571
|
+
if (ctx.method !== 'GET')
|
|
1572
|
+
return methodNotAllowed(ctx.res);
|
|
1573
|
+
jsonResponse(ctx.res, 200, ctx.orch.snapshot());
|
|
1574
|
+
}
|
|
1575
|
+
async function handleRefresh(ctx) {
|
|
1576
|
+
if (ctx.method !== 'POST')
|
|
1577
|
+
return methodNotAllowed(ctx.res);
|
|
1578
|
+
const status = ctx.orch.triggerRefresh();
|
|
1579
|
+
jsonResponse(ctx.res, 202, {
|
|
1580
|
+
...status,
|
|
1581
|
+
requested_at: new Date().toISOString(),
|
|
1582
|
+
operations: ['poll', 'reconcile'],
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
async function handleIssues(ctx) {
|
|
1586
|
+
if (ctx.method === 'GET')
|
|
1587
|
+
return handleListIssues(ctx);
|
|
1588
|
+
if (ctx.method === 'POST')
|
|
1589
|
+
return handleCreateIssue(ctx);
|
|
1590
|
+
methodNotAllowed(ctx.res);
|
|
1591
|
+
}
|
|
1592
|
+
async function handleListIssues(ctx) {
|
|
1593
|
+
const root = ctx.view.trackerRoot;
|
|
1594
|
+
if (!root)
|
|
1595
|
+
return jsonResponse(ctx.res, 200, { issues: [] });
|
|
1596
|
+
const issues = await listIssuesFromDisk(root);
|
|
1597
|
+
jsonResponse(ctx.res, 200, { issues });
|
|
1598
|
+
}
|
|
1599
|
+
async function handleCreateIssue(ctx) {
|
|
1600
|
+
const root = ctx.view.trackerRoot;
|
|
1601
|
+
if (!root)
|
|
1602
|
+
return badRequest(ctx.res, 'tracker.root not configured');
|
|
1603
|
+
let body;
|
|
1604
|
+
try {
|
|
1605
|
+
body = await readJsonBody(ctx.req);
|
|
1606
|
+
}
|
|
1607
|
+
catch (err) {
|
|
1608
|
+
return badRequest(ctx.res, err.message);
|
|
1609
|
+
}
|
|
1610
|
+
const decision = decideCreateIssue(body, ctx.view.states);
|
|
1611
|
+
if (!decision.ok) {
|
|
1612
|
+
return jsonResponse(ctx.res, decision.status, {
|
|
1613
|
+
error: { code: decision.code, message: decision.message },
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
try {
|
|
1617
|
+
const created = await writeIssueFile({
|
|
1618
|
+
trackerRoot: root,
|
|
1619
|
+
identifier: decision.identifier,
|
|
1620
|
+
title: decision.title,
|
|
1621
|
+
state: decision.state,
|
|
1622
|
+
description: decision.description,
|
|
1623
|
+
priority: decision.priority,
|
|
1624
|
+
labels: decision.labels,
|
|
1625
|
+
blocked_by: decision.blocked_by,
|
|
1626
|
+
});
|
|
1627
|
+
log.info('issue created via http', { identifier: created.identifier, state: created.state });
|
|
1628
|
+
jsonResponse(ctx.res, 201, created);
|
|
1629
|
+
}
|
|
1630
|
+
catch (err) {
|
|
1631
|
+
jsonResponse(ctx.res, 409, {
|
|
1632
|
+
error: { code: 'create_failed', message: err.message },
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
// MCP JSON-RPC endpoint: agent (inside the smolvm) POSTs JSON-RPC envelopes here. The
|
|
1637
|
+
// URL is per-issue (the agent only knows its own /<id>/mcp), backed by a bearer token
|
|
1638
|
+
// generated at dispatch. Both layers are belt-and-braces against the no-auth 8787 socket.
|
|
1639
|
+
async function handleMcp(ctx) {
|
|
1640
|
+
if (ctx.method !== 'POST')
|
|
1641
|
+
return methodNotAllowed(ctx.res);
|
|
1642
|
+
const mcp = ctx.opts.mcp;
|
|
1643
|
+
if (!mcp)
|
|
1644
|
+
return notFound(ctx.res, 'mcp_disabled', 'mcp endpoint not enabled');
|
|
1645
|
+
const { identifier } = ctx.route;
|
|
1646
|
+
const auth = (ctx.req.headers['authorization'] ?? ctx.req.headers['Authorization']);
|
|
1647
|
+
const token = extractBearerToken(auth);
|
|
1648
|
+
if (!token)
|
|
1649
|
+
return jsonResponse(ctx.res, 401, { error: { code: 'unauthorized', message: 'bearer token required' } });
|
|
1650
|
+
if (!mcp.isActive(identifier, token)) {
|
|
1651
|
+
return jsonResponse(ctx.res, 404, {
|
|
1652
|
+
error: { code: 'not_found', message: 'issue not active or token mismatch' },
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
let body;
|
|
1656
|
+
try {
|
|
1657
|
+
body = await readJsonBody(ctx.req);
|
|
1658
|
+
}
|
|
1659
|
+
catch (err) {
|
|
1660
|
+
return badRequest(ctx.res, err.message);
|
|
1661
|
+
}
|
|
1662
|
+
const reply = await mcp.handleJsonRpc(identifier, token, body);
|
|
1663
|
+
if (reply === null) {
|
|
1664
|
+
// JSON-RPC notification (no id) → 204 No Content
|
|
1665
|
+
ctx.res.statusCode = 204;
|
|
1666
|
+
ctx.res.end();
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
jsonResponse(ctx.res, 200, reply);
|
|
1670
|
+
}
|
|
1671
|
+
// Steering reply: the dashboard (or any operator with access) submits the human's
|
|
1672
|
+
// response to a queued request_human_steering call. The orchestrator-side runner is
|
|
1673
|
+
// awaiting on the registry; this POST unblocks it.
|
|
1674
|
+
//
|
|
1675
|
+
// Two callers:
|
|
1676
|
+
// • Dashboard via HTMX (form-encoded body, `HX-Request: true`, same-origin). We accept
|
|
1677
|
+
// only this combination for form bodies and reply with an HTML partial that swaps
|
|
1678
|
+
// into #attention.
|
|
1679
|
+
// • Direct API client (JSON body). Replies with a structured JSON acknowledgement.
|
|
1680
|
+
//
|
|
1681
|
+
// The form-encoded branch is gated on `HX-Request: true` and a same-origin check so a
|
|
1682
|
+
// simple cross-site form POST cannot inject a steering reply: form-encoded is a "simple"
|
|
1683
|
+
// CORS request that bypasses preflight, and the steering endpoint is unauthenticated.
|
|
1684
|
+
// HTMX errors land at 200 OK with an inline `.steering-error` message because HTMX's
|
|
1685
|
+
// default response-handling does not swap on 4xx/5xx; returning 200 keeps the operator's
|
|
1686
|
+
// form in sync with reality (their textarea text is preserved by hx-preserve regardless).
|
|
1687
|
+
async function handleSteeringReply(ctx) {
|
|
1688
|
+
if (ctx.method !== 'POST')
|
|
1689
|
+
return methodNotAllowed(ctx.res);
|
|
1690
|
+
const mcp = ctx.opts.mcp;
|
|
1691
|
+
if (!mcp)
|
|
1692
|
+
return notFound(ctx.res, 'mcp_disabled', 'steering endpoint not enabled');
|
|
1693
|
+
const { identifier } = ctx.route;
|
|
1694
|
+
const isHtmx = ctx.req.headers['hx-request'] === 'true';
|
|
1695
|
+
const ctype = classifyContentType(ctx.req.headers['content-type']);
|
|
1696
|
+
const csrf = checkSteeringCsrf(ctype, isHtmx, isSameOriginRequest(ctx.req));
|
|
1697
|
+
if (!csrf.ok) {
|
|
1698
|
+
return jsonResponse(ctx.res, csrf.status, {
|
|
1699
|
+
error: { code: csrf.code, message: csrf.message },
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
const text = await readSteeringText(ctx, ctype);
|
|
1703
|
+
if (text === null)
|
|
1704
|
+
return;
|
|
1705
|
+
if (!text) {
|
|
1706
|
+
return htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 400, 'bad_request', 'text is required and must be a non-empty string');
|
|
1707
|
+
}
|
|
1708
|
+
if (!mcp.submitSteeringReply(identifier, text)) {
|
|
1709
|
+
return htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 409, 'no_pending_steering', 'no agent is awaiting steering for this issue');
|
|
1710
|
+
}
|
|
1711
|
+
await respondSteeringAccepted(ctx, isHtmx, identifier);
|
|
1712
|
+
}
|
|
1713
|
+
async function respondSteeringAccepted(ctx, isHtmx, identifier) {
|
|
1714
|
+
if (isHtmx) {
|
|
1715
|
+
const p = await gatherPartialInputs(ctx.orch, ctx.view);
|
|
1716
|
+
ctx.res.statusCode = 200;
|
|
1717
|
+
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1718
|
+
ctx.res.end(renderAttentionPartial(p));
|
|
914
1719
|
return;
|
|
915
1720
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1721
|
+
jsonResponse(ctx.res, 202, { identifier, accepted_at: new Date().toISOString() });
|
|
1722
|
+
}
|
|
1723
|
+
// Returns null when the body read errored and a response has already been written;
|
|
1724
|
+
// otherwise returns the extracted text (empty string if absent/blank, caller treats
|
|
1725
|
+
// that as the "no text supplied" case).
|
|
1726
|
+
async function readSteeringText(ctx, ctype) {
|
|
1727
|
+
const isHtmx = ctx.req.headers['hx-request'] === 'true';
|
|
1728
|
+
if (ctype.isFormBody) {
|
|
922
1729
|
try {
|
|
923
|
-
|
|
924
|
-
res.statusCode = 200;
|
|
925
|
-
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
926
|
-
res.setHeader('cache-control', 'no-store');
|
|
927
|
-
res.end(html);
|
|
1730
|
+
return extractFormText(await readTextBody(ctx.req));
|
|
928
1731
|
}
|
|
929
1732
|
catch (err) {
|
|
930
|
-
|
|
1733
|
+
await htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 400, 'bad_request', err.message);
|
|
1734
|
+
return null;
|
|
931
1735
|
}
|
|
932
|
-
return;
|
|
933
1736
|
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
// wrapper is in the dashboard shell.
|
|
937
|
-
if (pathname.startsWith('/api/v1/partials/')) {
|
|
938
|
-
if (method !== 'GET')
|
|
939
|
-
return methodNotAllowed(res);
|
|
940
|
-
const p = await gatherPartialInputs(orch, view);
|
|
941
|
-
const slug = pathname.slice('/api/v1/partials/'.length);
|
|
942
|
-
let body = null;
|
|
943
|
-
if (slug === 'header')
|
|
944
|
-
body = renderHeaderPartial(p);
|
|
945
|
-
else if (slug === 'attention')
|
|
946
|
-
body = renderAttentionPartial(p);
|
|
947
|
-
else if (slug === 'sessions')
|
|
948
|
-
body = renderSessionsPartial(p);
|
|
949
|
-
else if (slug === 'disk')
|
|
950
|
-
body = renderDiskPartial(p);
|
|
951
|
-
else if (slug === 'totals')
|
|
952
|
-
body = renderTotalsPartial(p);
|
|
953
|
-
if (body === null)
|
|
954
|
-
return notFound(res, 'partial_not_found', `partial ${slug} does not exist`);
|
|
955
|
-
res.statusCode = 200;
|
|
956
|
-
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
957
|
-
res.setHeader('cache-control', 'no-store');
|
|
958
|
-
res.end(body);
|
|
959
|
-
return;
|
|
1737
|
+
try {
|
|
1738
|
+
return extractJsonText(await readJsonBody(ctx.req));
|
|
960
1739
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1740
|
+
catch (err) {
|
|
1741
|
+
badRequest(ctx.res, err.message);
|
|
1742
|
+
return null;
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
// Triage approve / discard. The dashboard renders these as buttons inside the triage
|
|
1746
|
+
// section; an operator clicks one and we move the issue file out of Triage/. Approve
|
|
1747
|
+
// sends it to the first active state (typically Todo) where the orchestrator will pick
|
|
1748
|
+
// it up on the next poll; discard sends it to the first terminal state that looks like a
|
|
1749
|
+
// cancellation (case-insensitive "Cancelled" match preferred) so the proposal is
|
|
1750
|
+
// archived rather than deleted — operators can still grep for what was proposed and
|
|
1751
|
+
// turned down.
|
|
1752
|
+
//
|
|
1753
|
+
// CSRF posture mirrors the steering-reply endpoint: form-encoded requires HX-Request
|
|
1754
|
+
// + same-origin; JSON requires application/json (preflight-triggering). HTMX errors
|
|
1755
|
+
// come back as 200 with a partial so the section doesn't go stale.
|
|
1756
|
+
async function handleTriage(ctx) {
|
|
1757
|
+
if (ctx.method !== 'POST')
|
|
1758
|
+
return methodNotAllowed(ctx.res);
|
|
1759
|
+
const tracker = ctx.opts.tracker;
|
|
1760
|
+
if (!tracker || !tracker.moveIssueToState) {
|
|
1761
|
+
return notFound(ctx.res, 'tracker_no_state_transitions', 'tracker does not support state transitions');
|
|
965
1762
|
}
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1763
|
+
const root = ctx.view.trackerRoot;
|
|
1764
|
+
if (!root)
|
|
1765
|
+
return badRequest(ctx.res, 'tracker.root not configured');
|
|
1766
|
+
const { identifier, action } = ctx.route;
|
|
1767
|
+
const ctype = classifyContentType(ctx.req.headers['content-type']);
|
|
1768
|
+
const csrf = checkTriageCsrf(ctype, ctx.req.headers['hx-request'] === 'true', isSameOriginRequest(ctx.req));
|
|
1769
|
+
if (!csrf.ok) {
|
|
1770
|
+
return jsonResponse(ctx.res, csrf.status, {
|
|
1771
|
+
error: { code: csrf.code, message: csrf.message },
|
|
974
1772
|
});
|
|
975
1773
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
catch (err) {
|
|
993
|
-
return badRequest(res, err.message);
|
|
994
|
-
}
|
|
995
|
-
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
996
|
-
return badRequest(res, 'body must be a JSON object');
|
|
997
|
-
}
|
|
998
|
-
const b = body;
|
|
999
|
-
const identifier = typeof b.identifier === 'string' ? b.identifier.trim() : '';
|
|
1000
|
-
const title = typeof b.title === 'string' ? b.title.trim() : '';
|
|
1001
|
-
const state = typeof b.state === 'string' ? b.state.trim() : '';
|
|
1002
|
-
if (!identifier)
|
|
1003
|
-
return badRequest(res, 'identifier is required');
|
|
1004
|
-
if (!title)
|
|
1005
|
-
return badRequest(res, 'title is required');
|
|
1006
|
-
if (!state)
|
|
1007
|
-
return badRequest(res, 'state is required');
|
|
1008
|
-
// Restrict `state` to one of the configured active/terminal states. Anything else
|
|
1009
|
-
// (or values containing path separators / `..`) is rejected so the request cannot
|
|
1010
|
-
// escape the tracker root via `path.join`.
|
|
1011
|
-
const allowedStates = new Set([...view.activeStates, ...view.terminalStates]);
|
|
1012
|
-
if (!allowedStates.has(state)) {
|
|
1013
|
-
return badRequest(res, `state must be one of: ${[...allowedStates].join(', ') || '<none configured>'}`);
|
|
1014
|
-
}
|
|
1015
|
-
const description = typeof b.description === 'string' ? b.description : undefined;
|
|
1016
|
-
const priority = typeof b.priority === 'number' && Number.isFinite(b.priority) ? b.priority : null;
|
|
1017
|
-
const labels = Array.isArray(b.labels)
|
|
1018
|
-
? b.labels.filter((x) => typeof x === 'string')
|
|
1019
|
-
: [];
|
|
1020
|
-
const blockedBy = Array.isArray(b.blocked_by)
|
|
1021
|
-
? b.blocked_by.filter((x) => typeof x === 'string')
|
|
1022
|
-
: [];
|
|
1774
|
+
const transition = decideTriageTransition(action, ctx.view.states);
|
|
1775
|
+
if (!transition.ok) {
|
|
1776
|
+
return jsonResponse(ctx.res, transition.status, {
|
|
1777
|
+
error: { code: transition.code, message: transition.message },
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
await runTriageMove(ctx, tracker, root, identifier, action, transition.toState, transition.fromState);
|
|
1781
|
+
}
|
|
1782
|
+
async function runTriageMove(ctx, tracker, root, identifier, action, toState, fromState) {
|
|
1783
|
+
const isHtmx = ctx.req.headers['hx-request'] === 'true';
|
|
1784
|
+
try {
|
|
1785
|
+
const result = await tracker.moveIssueToState(identifier, toState, { fromRoot: root, fromState });
|
|
1786
|
+
log.info('triage action', { identifier, action, from: result.fromState, to: result.toState });
|
|
1787
|
+
// Nudge the orchestrator to pick the freshly approved issue up immediately instead
|
|
1788
|
+
// of waiting for the next poll tick. Best-effort: triggerRefresh is idempotent.
|
|
1789
|
+
if (action === 'approve') {
|
|
1023
1790
|
try {
|
|
1024
|
-
|
|
1025
|
-
trackerRoot: root,
|
|
1026
|
-
identifier,
|
|
1027
|
-
title,
|
|
1028
|
-
state,
|
|
1029
|
-
description,
|
|
1030
|
-
priority,
|
|
1031
|
-
labels,
|
|
1032
|
-
blocked_by: blockedBy,
|
|
1033
|
-
});
|
|
1034
|
-
log.info('issue created via http', { identifier: created.identifier, state: created.state });
|
|
1035
|
-
return jsonResponse(res, 201, created);
|
|
1036
|
-
}
|
|
1037
|
-
catch (err) {
|
|
1038
|
-
return jsonResponse(res, 409, {
|
|
1039
|
-
error: { code: 'create_failed', message: err.message },
|
|
1040
|
-
});
|
|
1791
|
+
ctx.orch.triggerRefresh();
|
|
1041
1792
|
}
|
|
1793
|
+
catch { /* refresh request is fire-and-forget */ }
|
|
1042
1794
|
}
|
|
1043
|
-
|
|
1795
|
+
if (isHtmx)
|
|
1796
|
+
return writeBoardPartial(ctx);
|
|
1797
|
+
jsonResponse(ctx.res, 200, {
|
|
1798
|
+
identifier, action, from_state: result.fromState, to_state: result.toState,
|
|
1799
|
+
});
|
|
1044
1800
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
return methodNotAllowed(res);
|
|
1052
|
-
const mcp = opts.mcp;
|
|
1053
|
-
if (!mcp)
|
|
1054
|
-
return notFound(res, 'mcp_disabled', 'mcp endpoint not enabled');
|
|
1055
|
-
const identifier = decodeURIComponent(mcpMatch[1]);
|
|
1056
|
-
const auth = (req.headers['authorization'] ?? req.headers['Authorization']);
|
|
1057
|
-
const token = auth && auth.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : '';
|
|
1058
|
-
if (!token) {
|
|
1059
|
-
res.statusCode = 401;
|
|
1060
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
1061
|
-
res.end(JSON.stringify({ error: { code: 'unauthorized', message: 'bearer token required' } }));
|
|
1062
|
-
return;
|
|
1063
|
-
}
|
|
1064
|
-
if (!mcp.isActive(identifier, token)) {
|
|
1065
|
-
res.statusCode = 404;
|
|
1066
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
1067
|
-
res.end(JSON.stringify({ error: { code: 'not_found', message: 'issue not active or token mismatch' } }));
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
1070
|
-
let body;
|
|
1071
|
-
try {
|
|
1072
|
-
body = await readJsonBody(req);
|
|
1073
|
-
}
|
|
1074
|
-
catch (err) {
|
|
1075
|
-
return badRequest(res, err.message);
|
|
1076
|
-
}
|
|
1077
|
-
const reply = await mcp.handleJsonRpc(identifier, token, body);
|
|
1078
|
-
if (reply === null) {
|
|
1079
|
-
// JSON-RPC notification (no id) → 204 No Content
|
|
1080
|
-
res.statusCode = 204;
|
|
1081
|
-
res.end();
|
|
1082
|
-
return;
|
|
1083
|
-
}
|
|
1084
|
-
return jsonResponse(res, 200, reply);
|
|
1801
|
+
catch (err) {
|
|
1802
|
+
const code = err.code ?? 'triage_failed';
|
|
1803
|
+
const status = code === 'local_issue_not_found' ? 404 : 409;
|
|
1804
|
+
if (isHtmx)
|
|
1805
|
+
return writeBoardPartial(ctx);
|
|
1806
|
+
jsonResponse(ctx.res, status, { error: { code, message: err.message } });
|
|
1085
1807
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
const isHtmx = req.headers['hx-request'] === 'true';
|
|
1111
|
-
const ctype = (req.headers['content-type'] ?? '').toLowerCase();
|
|
1112
|
-
const baseCtype = ctype.split(';', 1)[0].trim();
|
|
1113
|
-
const isFormBody = baseCtype === 'application/x-www-form-urlencoded';
|
|
1114
|
-
const isJsonBody = baseCtype === 'application/json';
|
|
1115
|
-
// Content-type gates against CSRF: form-urlencoded, text/plain, and multipart/form-data
|
|
1116
|
-
// are "simple" CORS requests and bypass preflight. Only application/json (non-simple,
|
|
1117
|
-
// triggers preflight) is accepted on the JSON path; the form path is additionally
|
|
1118
|
-
// gated on HX-Request + same-origin. Anything else is rejected outright.
|
|
1119
|
-
if (!isFormBody && !isJsonBody) {
|
|
1120
|
-
return jsonResponse(res, 415, {
|
|
1121
|
-
error: {
|
|
1122
|
-
code: 'unsupported_media_type',
|
|
1123
|
-
message: 'content-type must be application/json or application/x-www-form-urlencoded',
|
|
1124
|
-
},
|
|
1125
|
-
});
|
|
1126
|
-
}
|
|
1127
|
-
if (isFormBody) {
|
|
1128
|
-
if (!isHtmx || !isSameOriginRequest(req)) {
|
|
1129
|
-
return jsonResponse(res, 403, {
|
|
1130
|
-
error: {
|
|
1131
|
-
code: 'forbidden',
|
|
1132
|
-
message: 'form-encoded steering replies require an HTMX same-origin request',
|
|
1133
|
-
},
|
|
1134
|
-
});
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
let text = '';
|
|
1138
|
-
if (isFormBody) {
|
|
1139
|
-
try {
|
|
1140
|
-
const raw = await readTextBody(req);
|
|
1141
|
-
const params = new URLSearchParams(raw);
|
|
1142
|
-
text = (params.get('text') ?? '').trim();
|
|
1143
|
-
}
|
|
1144
|
-
catch (err) {
|
|
1145
|
-
return htmxOrJsonError(res, isHtmx, orch, view, 400, 'bad_request', err.message);
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
else {
|
|
1149
|
-
let body;
|
|
1150
|
-
try {
|
|
1151
|
-
body = await readJsonBody(req);
|
|
1152
|
-
}
|
|
1153
|
-
catch (err) {
|
|
1154
|
-
return badRequest(res, err.message);
|
|
1155
|
-
}
|
|
1156
|
-
text =
|
|
1157
|
-
body && typeof body === 'object' && !Array.isArray(body) && typeof body.text === 'string'
|
|
1158
|
-
? body.text.trim()
|
|
1159
|
-
: '';
|
|
1160
|
-
}
|
|
1161
|
-
if (!text) {
|
|
1162
|
-
return htmxOrJsonError(res, isHtmx, orch, view, 400, 'bad_request', 'text is required and must be a non-empty string');
|
|
1163
|
-
}
|
|
1164
|
-
const ok = mcp.submitSteeringReply(identifier, text);
|
|
1165
|
-
if (!ok) {
|
|
1166
|
-
return htmxOrJsonError(res, isHtmx, orch, view, 409, 'no_pending_steering', 'no agent is awaiting steering for this issue');
|
|
1167
|
-
}
|
|
1168
|
-
if (isHtmx) {
|
|
1169
|
-
const p = await gatherPartialInputs(orch, view);
|
|
1170
|
-
res.statusCode = 200;
|
|
1171
|
-
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1172
|
-
res.end(renderAttentionPartial(p));
|
|
1173
|
-
return;
|
|
1174
|
-
}
|
|
1175
|
-
return jsonResponse(res, 202, { identifier, accepted_at: new Date().toISOString() });
|
|
1808
|
+
}
|
|
1809
|
+
// Re-render the whole board after a triage move. HTMX morph keeps unchanged columns
|
|
1810
|
+
// stable; only the row that moved (or "failed" and stayed) redraws.
|
|
1811
|
+
async function writeBoardPartial(ctx) {
|
|
1812
|
+
const p = await gatherPartialInputs(ctx.orch, ctx.view);
|
|
1813
|
+
ctx.res.statusCode = 200;
|
|
1814
|
+
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1815
|
+
ctx.res.setHeader('cache-control', 'no-store');
|
|
1816
|
+
ctx.res.end(renderBoardPartial(p));
|
|
1817
|
+
}
|
|
1818
|
+
// Read-only HTML view of one issue. Linked from the identifier on every "on disk" and
|
|
1819
|
+
// triage row; renders front-matter (labels, priority, blockers, provenance) plus the
|
|
1820
|
+
// Markdown body so an operator can read the full task without leaving the browser. No
|
|
1821
|
+
// editing surface — actions stay on the dashboard. Source of truth is the on-disk .md
|
|
1822
|
+
// file, found by walking every state directory under tracker.root for a basename match.
|
|
1823
|
+
async function handleDetailHtml(ctx) {
|
|
1824
|
+
if (ctx.method !== 'GET')
|
|
1825
|
+
return methodNotAllowed(ctx.res);
|
|
1826
|
+
const root = ctx.view.trackerRoot;
|
|
1827
|
+
if (!root) {
|
|
1828
|
+
ctx.res.statusCode = 404;
|
|
1829
|
+
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1830
|
+
ctx.res.end(renderIssueNotFoundPage('(tracker root not configured)', ctx.view));
|
|
1831
|
+
return;
|
|
1176
1832
|
}
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
return notFound(res, 'issue_not_found', `issue ${identifier} is not tracked`);
|
|
1185
|
-
return jsonResponse(res, 200, detail);
|
|
1833
|
+
const { identifier } = ctx.route;
|
|
1834
|
+
const issue = await readIssueFromDisk(root, identifier);
|
|
1835
|
+
if (!issue) {
|
|
1836
|
+
ctx.res.statusCode = 404;
|
|
1837
|
+
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1838
|
+
ctx.res.end(renderIssueNotFoundPage(identifier, ctx.view));
|
|
1839
|
+
return;
|
|
1186
1840
|
}
|
|
1187
|
-
|
|
1841
|
+
ctx.res.statusCode = 200;
|
|
1842
|
+
ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1843
|
+
ctx.res.setHeader('cache-control', 'no-store');
|
|
1844
|
+
ctx.res.end(renderIssueDetailPage(issue, ctx.view));
|
|
1845
|
+
}
|
|
1846
|
+
async function handleDetailJson(ctx) {
|
|
1847
|
+
if (ctx.method !== 'GET')
|
|
1848
|
+
return methodNotAllowed(ctx.res);
|
|
1849
|
+
const { identifier } = ctx.route;
|
|
1850
|
+
const detail = ctx.orch.detailByIdentifier(identifier);
|
|
1851
|
+
if (!detail)
|
|
1852
|
+
return notFound(ctx.res, 'issue_not_found', `issue ${identifier} is not tracked`);
|
|
1853
|
+
jsonResponse(ctx.res, 200, detail);
|
|
1854
|
+
}
|
|
1855
|
+
async function handleNotFoundRoute(ctx) {
|
|
1856
|
+
notFound(ctx.res);
|
|
1857
|
+
}
|
|
1858
|
+
const ROUTE_HANDLERS = {
|
|
1859
|
+
dashboard: handleDashboard,
|
|
1860
|
+
preview: handlePreview,
|
|
1861
|
+
partial: handlePartial,
|
|
1862
|
+
state: handleState,
|
|
1863
|
+
refresh: handleRefresh,
|
|
1864
|
+
issues: handleIssues,
|
|
1865
|
+
mcp: handleMcp,
|
|
1866
|
+
steering: handleSteeringReply,
|
|
1867
|
+
triage: handleTriage,
|
|
1868
|
+
detail_html: handleDetailHtml,
|
|
1869
|
+
detail_json: handleDetailJson,
|
|
1870
|
+
not_found: handleNotFoundRoute,
|
|
1871
|
+
};
|
|
1872
|
+
function renderIssueNotFoundPage(identifier, view) {
|
|
1873
|
+
const workflowName = path.basename(view.workflowPath || 'workflow.md');
|
|
1874
|
+
return `<!doctype html>
|
|
1875
|
+
<html lang="en"><head>
|
|
1876
|
+
<meta charset="utf-8">
|
|
1877
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1878
|
+
<title>not found · symphony</title>
|
|
1879
|
+
<style>
|
|
1880
|
+
:root { color-scheme: dark; }
|
|
1881
|
+
html, body {
|
|
1882
|
+
background: #0f1115; color: #dfe2e7;
|
|
1883
|
+
font: 14px/1.45 ui-sans-serif, system-ui, sans-serif;
|
|
1884
|
+
margin: 0; padding: 0;
|
|
1885
|
+
}
|
|
1886
|
+
main { max-width: 640px; margin: 0 auto; padding: 2.5rem 1.5rem; }
|
|
1887
|
+
h1 { font-size: 1.2rem; font-weight: 500; color: #e6ebf2; margin: 0 0 0.6rem; }
|
|
1888
|
+
p { color: #9aa4b2; line-height: 1.5; }
|
|
1889
|
+
a { color: #9cc0ff; text-decoration: none; }
|
|
1890
|
+
a:hover { color: #e6ebf2; }
|
|
1891
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #e6ebf2; }
|
|
1892
|
+
</style>
|
|
1893
|
+
</head><body>
|
|
1894
|
+
<main>
|
|
1895
|
+
<h1>issue not found</h1>
|
|
1896
|
+
<p>no issue file matches <code>${escapeHtml(identifier)}</code> under the tracker root.</p>
|
|
1897
|
+
<p><a href="/">← back to ${escapeHtml(workflowName)}</a></p>
|
|
1898
|
+
</main>
|
|
1899
|
+
</body></html>`;
|
|
1188
1900
|
}
|
|
1189
1901
|
//# sourceMappingURL=http.js.map
|