great-cto 3.70.0 → 3.71.1
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/README.md +9 -1
- package/board/.claude-plugin/plugin.json +93 -1
- package/board/packages/board/lib/routes.mjs +14 -1
- package/board/packages/board/lib/work.mjs +34 -3
- package/board/packages/board/public/assets/admin-shell.css +63 -0
- package/board/packages/board/public/assets/admin-shell.js +39 -5
- package/board/packages/board/public/assets/work-view.js +320 -37
- package/board/packages/board/public/index.html +43 -1
- package/board/scripts/codex-pipeline.mjs +7 -1
- package/board/scripts/lib/claude-exec.mjs +49 -6
- package/board/scripts/lib/codex-exec.mjs +42 -18
- package/board/scripts/lib/codex-host-state.mjs +18 -2
- package/board/scripts/lib/codex-pipeline.mjs +44 -7
- package/board/scripts/lib/host-graph-bindings.mjs +135 -0
- package/board/scripts/lib/host-graph-events.mjs +157 -0
- package/board/scripts/lib/host-graph-journal.mjs +210 -0
- package/board/scripts/lib/host-graph-observer.mjs +61 -0
- package/board/scripts/lib/host-graph-runtime.mjs +38 -0
- package/board/scripts/work-task.mjs +17 -4
- package/dist/adapt.js +10 -21
- package/dist/codex.js +4 -15
- package/dist/companion.js +6 -122
- package/dist/installer.js +4 -72
- package/dist/main.js +51 -118
- package/dist/release-activation.mjs +561 -0
- package/dist/release-archive.mjs +165 -0
- package/dist/release-trust.mjs +514 -0
- package/dist/release-update.mjs +303 -0
- package/dist/self-upgrade.js +10 -39
- package/dist/update-check.js +1 -0
- package/dist/upgrade.js +5 -120
- package/package.json +1 -1
- package/board/packages/board/fixtures/beads-read-owner.mjs +0 -3
- package/board/packages/board/fixtures/fake-bd-controlled.mjs +0 -55
- package/board/packages/board/fixtures/fake-bd-fail.sh +0 -9
- package/board/packages/board/fixtures/fake-bd-slow.sh +0 -12
- package/board/packages/board/fixtures/held-receipt-git.mjs +0 -21
|
@@ -69,77 +69,190 @@
|
|
|
69
69
|
function cockpitEntries(data, category, search) {
|
|
70
70
|
return data.entries.filter(e => inCategory(e, category) && (!search || `${e.title} ${e.key}`.toLowerCase().includes(search.toLowerCase())));
|
|
71
71
|
}
|
|
72
|
-
function
|
|
72
|
+
function renderDeclaredWorkflow(graph) {
|
|
73
|
+
if (!graph || graph.source === 'host-observation') return '';
|
|
74
|
+
const snapshots = Array.isArray(graph.snapshots) ? graph.snapshots.slice(0, 64) : [];
|
|
75
|
+
return '<details class="work-declared-workflow"><summary>Declared workflow' + (snapshots.length ? ` · ${snapshots.length} snapshots` : '') + '</summary>'
|
|
76
|
+
+ '<p>Declared possibilities only. These rules do not prove that a stage ran, a dependency was satisfied or a gate was approved.</p>'
|
|
77
|
+
+ (snapshots.length ? snapshots.map(snapshot => `<details><summary>Workflow snapshot ${esc(snapshot.digest || 'identity unavailable')}</summary>`
|
|
78
|
+
+ '<table><caption>Declared transition rules</caption><thead><tr><th>Role</th><th>On</th><th>Next</th><th>Join</th><th>Gates</th></tr></thead><tbody>'
|
|
79
|
+
+ (Array.isArray(snapshot.rules) ? snapshot.rules.slice(0, 256) : []).map(rule => '<tr>' + [rule.role, ...['on', 'next', 'join', 'gates'].map(key => Array.isArray(rule[key]) ? rule[key].slice(0, 16).join(', ') : '')].map(value => `<td>${esc(value || 'None declared')}</td>`).join('') + '</tr>').join('') + '</tbody></table></details>').join('')
|
|
80
|
+
: '<p>No declared workflow snapshot is recorded.</p>') + '</details>';
|
|
81
|
+
}
|
|
82
|
+
function renderHostAttempt(graph, attempts, options) {
|
|
83
|
+
const attemptId = graph.binding?.attemptId || graph.nodes?.find(node => node.attemptId)?.attemptId;
|
|
84
|
+
const attempt = attempts.find(node => node.attemptId === attemptId);
|
|
85
|
+
const hostKey = graph.binding?.host || graph.nodes?.[0]?.host;
|
|
86
|
+
const host = hostKey === 'codex' ? 'Codex' : hostKey === 'claude-code' ? 'Claude Code' : 'Host unknown';
|
|
87
|
+
const label = attempt ? `${attempt.role || 'Role unknown'} · attempt ${attempt.number || attemptId}` : attemptId ? `attempt ${attemptId}` : 'session observations';
|
|
88
|
+
const key = JSON.stringify([graph.binding?.host || null, graph.binding?.runId || null, graph.binding?.sessionId || null, attemptId || null]);
|
|
89
|
+
return `<details class="work-host-attempt" data-host-attempt="${esc(key)}" data-observation-key="${esc(key)}"><summary>Host observations · ${esc(host)} · ${esc(label)}</summary>${renderExecutionGraph(graph, options)}</details>`;
|
|
90
|
+
}
|
|
91
|
+
function renderExecution(entry, options = {}) {
|
|
73
92
|
const roles = Array.isArray(entry.evidence) ? entry.evidence : [];
|
|
74
93
|
const execution = entry.execution, nodes = Array.isArray(execution?.nodes) ? execution.nodes.slice(0, 200) : [];
|
|
75
|
-
return '<section class="work-execution"><h3>Recorded observations</h3>'
|
|
76
|
-
+
|
|
94
|
+
return (options.canvas ? '<section class="work-execution work-execution-flat">' : '<section class="work-execution"><h3>Recorded observations</h3>')
|
|
95
|
+
+ (execution?.observationHealth?.state === 'degraded' ? '<p class="work-warning">Host observation delivery is incomplete.</p>' : '')
|
|
96
|
+
+ (execution?.omittedObservationAttempts ? `<p class="work-warning">${esc(execution.omittedObservationAttempts)} earlier attempt observation journals are outside this bounded view.</p>` : '')
|
|
97
|
+
+ renderDeclaredWorkflow(execution?.graph)
|
|
98
|
+
+ renderExecutionGraph(execution?.graph, options)
|
|
99
|
+
+ (Array.isArray(execution?.observations) ? execution.observations.slice(0, 16).map(graph => renderHostAttempt(graph, nodes, options)).join('') : '')
|
|
77
100
|
+ (execution?.graph?.state === 'recorded' ? '' : '<p>Dependencies not recorded. This is an observation list, not an inferred execution graph. Event order does not establish dependencies.</p>')
|
|
78
101
|
+ `<p>Recorded stage: ${esc(entry.stage || 'not recorded')}</p>`
|
|
79
102
|
+ (execution?.reason ? `<p>${esc(execution.reason)}</p>` : '')
|
|
80
103
|
+ (execution?.truncated ? '<p class="work-warning">Showing the latest 200 recorded attempts; earlier observations are truncated.</p>' : execution?.partial ? '<p class="work-warning">Attempt observations are incomplete.</p>' : '')
|
|
104
|
+
+ (options.canvas && entry.inspector?.partial ? '<p class="work-warning">Activity window is incomplete.</p>' : '')
|
|
105
|
+
+ (options.canvas && entry.inspector?.reason ? `<p class="work-warning">${esc(entry.inspector.reason)}</p>` : '')
|
|
106
|
+
+ (options.canvas && !entry.inspector ? '<p class="work-meta">Linked activity unavailable. Absence of events is not proof of inactivity.</p>' : '')
|
|
107
|
+
+ (options.canvas ? '<details class="work-attempt-details"><summary>Recorded observations & technical details</summary>' : '')
|
|
81
108
|
+ (nodes.length ? '<ul class="work-observations" aria-label="Recorded execution attempts">' + nodes.map(n => `<li class="work-observation" data-attempt-id="${esc(n.attemptId)}"><strong>${esc(n.role)} · attempt ${esc(n.number || 'unknown')}</strong>`
|
|
82
109
|
+ `<p>${esc(n.host || 'Host not recorded')} · phase: ${esc(n.phase || 'not recorded')}</p><p>Controller recorded status: ${esc(n.status || 'unknown')}; not current verification or acceptance.</p>`
|
|
83
110
|
+ `<p>Run: ${esc(n.runId)} · Attempt: ${esc(n.attemptId)}</p><p>Started: ${esc(n.startedAt || 'not recorded')} · Finished: ${esc(n.finishedAt || 'not recorded')}</p></li>`).join('') + '</ul>' : '')
|
|
84
111
|
+ (roles.length ? '<ul class="work-observations">' + roles.map(role => `<li class="work-observation">${esc(role.label)} · recorded role observation; acceptance is not inferred</li>`).join('') + '</ul>' : '<p>No role observations recorded.</p>')
|
|
85
112
|
+ (entry.inspector ? renderInspector(entry.inspector).replace('<details ', '<details open ') : '<p>Linked activity unavailable. Absence of events is not proof of inactivity.</p>')
|
|
113
|
+
+ (options.canvas ? '</details>' : '')
|
|
86
114
|
+ '</section>';
|
|
87
115
|
}
|
|
88
|
-
function
|
|
116
|
+
function renderHostCoverage(graph) {
|
|
117
|
+
const dimensions = [['lifecycle', 'Lifecycle'], ['parentChild', 'Parent / child'], ['tools', 'Tools'], ['verification', 'Verification'], ['gates', 'Gates'], ['artifacts', 'Artifacts']];
|
|
118
|
+
const total = Number.isSafeInteger(graph.total) && graph.total >= 0 ? graph.total : 'unavailable';
|
|
119
|
+
return `<div class="execution-host-coverage"><p class="work-meta">Source: host observation · Health: ${esc(graph.health || 'unknown')}. Observation metadata does not establish current liveness, controller transitions, gate approval or task acceptance.</p>`
|
|
120
|
+
+ (graph.observedInterval ? `<p>Observed interval · collector receipt times: ${esc(graph.observedInterval.from || 'Unavailable')} to ${esc(graph.observedInterval.to || 'Unavailable')}</p>` : '')
|
|
121
|
+
+ (Array.isArray(graph.diagnostics) && graph.diagnostics.length ? '<details class="work-observation-diagnostics"><summary>Observation diagnostics</summary><ul>' + graph.diagnostics.slice(0, 16).map(code => `<li><code>${esc(code)}</code></li>`).join('') + '</ul></details>' : '')
|
|
122
|
+
+ `<p>Showing ${Array.isArray(graph.nodes) ? graph.nodes.length : 0} of ${esc(total)} recorded observations.${graph.cursor ? ' More observations are available outside this page.' : ''}</p>`
|
|
123
|
+
+ (graph.cursor ? `<details><summary>Observation page cursor · read-only</summary><code>${esc(graph.cursor)}</code></details>` : '')
|
|
124
|
+
+ (graph.cursor && graph.binding?.host && graph.binding?.sessionId ? `<button type="button" data-host-graph-next="${esc(JSON.stringify({ ...graph.binding, cursor: graph.cursor }))}">Next observation page</button>` : '')
|
|
125
|
+
+ '<dl aria-label="Host observation coverage">' + dimensions.map(([key, label]) => {
|
|
126
|
+
const dimension = graph.coverage?.[key];
|
|
127
|
+
const support = dimension?.supported === true ? 'Supported' : dimension?.supported === false ? 'Unsupported' : 'Support unknown';
|
|
128
|
+
const state = dimension?.supported === false ? 'Unavailable' : dimension?.state === 'partial' ? 'Partial'
|
|
129
|
+
: dimension?.supported === true && dimension?.state === 'complete' ? 'Complete' : 'Unavailable';
|
|
130
|
+
return `<dt>${label} · ${support} · ${state}</dt><dd>${esc(dimension?.reason || 'Coverage has not been established.')}</dd>`;
|
|
131
|
+
}).join('') + '</dl></div>';
|
|
132
|
+
}
|
|
133
|
+
const controllerOwnedKinds = new Set(['transition', 'context', 'checks', 'gate', 'approval', 'rework', 'recovery', 'wave', 'manual', 'cancel', 'release', 'reuse']);
|
|
134
|
+
const graphLane = node => ['codex', 'claude-code'].includes(node.host) ? node.host
|
|
135
|
+
: !node.host && controllerOwnedKinds.has(node.kind) ? 'controller' : 'unknown';
|
|
136
|
+
const graphHostLabel = node => node.host || (graphLane(node) === 'controller' ? 'controller' : 'Host not recorded');
|
|
137
|
+
const graphRoleLabel = node => node.role || (graphLane(node) === 'controller' ? 'controller' : node.kind || 'Role not recorded');
|
|
138
|
+
function renderExecutionGraph(graph, options = {}) {
|
|
139
|
+
const hostObserved = graph?.source === 'host-observation';
|
|
140
|
+
const coverage = hostObserved ? renderHostCoverage(graph) : '';
|
|
89
141
|
if (graph?.state !== 'recorded' || !Array.isArray(graph.nodes) || !graph.nodes.length)
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
142
|
+
return (hostObserved ? '<section class="execution-graph" aria-label="Host-observed execution graph"><h3>Host-observed graph</h3>' + coverage : '')
|
|
143
|
+
+ `<p class="work-meta">${esc(graph?.reason || 'Controller-recorded graph unavailable. Native and legacy observations remain partial.')}</p>` + (hostObserved ? '</section>' : '');
|
|
144
|
+
if (graph.nodes.length > 512 || !Array.isArray(graph.edges) || graph.edges.length > 2048) return coverage + '<p>Graph exceeds the display limit.</p>';
|
|
145
|
+
const horizontal = options.canvas && options.direction !== 'tb';
|
|
146
|
+
let nodes = graph.nodes;
|
|
147
|
+
const ids = new Map(nodes.map((n, i) => [n.id, i]));
|
|
148
|
+
if (ids.size !== nodes.length || graph.edges.some(e => !ids.has(e.from) || !ids.has(e.to) || e.from === e.to || (!hostObserved && ids.get(e.from) >= ids.get(e.to))))
|
|
149
|
+
return coverage + '<p>Graph dependencies cannot be displayed safely.</p>';
|
|
150
|
+
if (hostObserved) {
|
|
151
|
+
// Explicit parents may arrive late. Reorder for layout only; never add an edge.
|
|
152
|
+
const indegree = new Map(nodes.map(n => [n.id, 0])), children = new Map(nodes.map(n => [n.id, []]));
|
|
153
|
+
for (const edge of graph.edges) { indegree.set(edge.to, indegree.get(edge.to) + 1); children.get(edge.from).push(edge.to); }
|
|
154
|
+
const pending = nodes.filter(n => indegree.get(n.id) === 0), ordered = [], byId = new Map(nodes.map(n => [n.id, n]));
|
|
155
|
+
for (let index = 0; index < pending.length; index += 1) {
|
|
156
|
+
const node = pending[index]; ordered.push(node);
|
|
157
|
+
for (const child of children.get(node.id)) { indegree.set(child, indegree.get(child) - 1); if (indegree.get(child) === 0) pending.push(byId.get(child)); }
|
|
158
|
+
}
|
|
159
|
+
if (ordered.length !== nodes.length) return coverage + '<p>Graph dependencies cannot be displayed safely.</p>';
|
|
160
|
+
nodes = ordered;
|
|
161
|
+
}
|
|
95
162
|
const levels = new Map(nodes.map(n => [n.id, 0]));
|
|
96
163
|
const parents = new Map(nodes.map(n => [n.id, []]));
|
|
97
164
|
for (const e of graph.edges) parents.get(e.to).push(e);
|
|
98
|
-
const
|
|
165
|
+
const laneFor = graphLane;
|
|
166
|
+
const lanes = ['controller', 'codex', 'claude-code', 'unknown'].filter(host => nodes.some(node => laneFor(node) === host)), laneLayout = lanes.length > 1;
|
|
167
|
+
const rows = new Map(), positions = new Map(), nodeRows = new Map(), laneCounts = new Map();
|
|
99
168
|
for (const n of nodes) {
|
|
100
169
|
const level = Math.max(0, ...parents.get(n.id).map(e => levels.get(e.from) + 1));
|
|
101
170
|
levels.set(n.id, level);
|
|
102
|
-
const
|
|
103
|
-
|
|
171
|
+
const key = laneLayout ? `${laneFor(n)}:${level}` : level, row = rows.get(key) || 0;
|
|
172
|
+
rows.set(key, row + 1); nodeRows.set(n.id, row);
|
|
173
|
+
laneCounts.set(laneFor(n), Math.max(laneCounts.get(laneFor(n)) || 0, row + 1));
|
|
104
174
|
}
|
|
105
|
-
const
|
|
175
|
+
const offsets = new Map(), extents = new Map(); let extent = 0;
|
|
176
|
+
if (laneLayout) for (const host of lanes) {
|
|
177
|
+
const size = (laneCounts.get(host) || 1) * (horizontal ? 112 : 236) + 48;
|
|
178
|
+
offsets.set(host, extent); extents.set(host, size); extent += size;
|
|
179
|
+
}
|
|
180
|
+
for (const n of nodes) {
|
|
181
|
+
const level = levels.get(n.id), row = nodeRows.get(n.id), offset = laneLayout ? offsets.get(laneFor(n)) : 0;
|
|
182
|
+
positions.set(n.id, horizontal ? { x: 24 + level * 272, y: (laneLayout ? offset + 48 : 24) + row * 112 }
|
|
183
|
+
: { x: offset + 24 + row * 236, y: (laneLayout ? 48 : 24) + level * 112 });
|
|
184
|
+
}
|
|
185
|
+
const width = horizontal ? (Math.max(...levels.values()) + 1) * 272 : laneLayout ? Math.max(500, extent) : Math.max(500, Math.max(...rows.values()) * 236 + 24);
|
|
186
|
+
const height = horizontal ? laneLayout ? extent : Math.max(240, Math.max(...rows.values()) * 112 + 24) : (Math.max(...levels.values()) + 1) * 112 + (laneLayout ? 48 : 24);
|
|
187
|
+
const laneBoxes = laneLayout ? lanes.map(host => {
|
|
188
|
+
const label = ({ controller: 'Controller', codex: 'Codex', 'claude-code': 'Claude Code', unknown: 'Host not recorded' })[host], offset = offsets.get(host);
|
|
189
|
+
return `<g class="execution-host-lane" role="group" aria-label="Host lane: ${label}"><rect x="${horizontal ? 0 : offset}" y="${horizontal ? offset : 0}" width="${horizontal ? width : extents.get(host)}" height="${horizontal ? extents.get(host) : height}" fill="var(--bg-page)" stroke="var(--border-strong)"/><text x="${horizontal ? 12 : offset + 12}" y="${horizontal ? offset + 24 : 24}" fill="currentColor">${label}</text></g>`;
|
|
190
|
+
}).join('') : '';
|
|
106
191
|
const edges = graph.edges.map(e => {
|
|
107
192
|
const a = positions.get(e.from), b = positions.get(e.to);
|
|
193
|
+
if (horizontal) return `<path d="M${a.x + 208} ${a.y + 39} C${a.x + 240} ${a.y + 39},${b.x - 32} ${b.y + 39},${b.x} ${b.y + 39}" marker-end="url(#execution-arrow)"><title>${esc(e.kind)}: ${esc(e.from)} to ${esc(e.to)}</title></path>`;
|
|
108
194
|
return `<path d="M${a.x + 104} ${a.y + 78} C${a.x + 104} ${a.y + 98},${b.x + 104} ${b.y - 20},${b.x + 104} ${b.y}" marker-end="url(#execution-arrow)"><title>${esc(e.kind)}: ${esc(e.from)} to ${esc(e.to)}</title></path>`;
|
|
109
195
|
}).join('');
|
|
110
196
|
const short = value => String(value || '').length > 27 ? String(value).slice(0, 26) + '…' : String(value || '');
|
|
111
197
|
const boxes = nodes.map(n => {
|
|
112
198
|
const p = positions.get(n.id);
|
|
113
|
-
return `<g class="execution-node" role="button" tabindex="0" data-graph-node="${esc(n.id)}" aria-label="${esc(n.kind)} ${esc(n.role || '')}: ${esc(n.status)}. Open recorded details." transform="translate(${p.x},${p.y})"
|
|
199
|
+
return `<g class="execution-node" role="button" tabindex="0" data-graph-node="${esc(n.id)}" data-graph-search-text="${esc([n.id, n.role || n.kind, graphHostLabel(n), n.attemptId || ''].join(' '))}" aria-label="${esc(n.kind)} ${esc(n.role || '')}: ${esc(n.status)}. Open recorded details." transform="translate(${p.x},${p.y})">${options.canvas ? `<title>${esc(n.kind)} · ${esc(graphRoleLabel(n))} · ${esc(n.status)}</title>` : ''}<rect width="208" height="78" rx="6"/><text x="12" y="23">${esc(short(n.role || n.kind))}</text><text x="12" y="45">${options.canvas ? esc(short(`${n.kind} · ${n.status}`)) : `${esc(n.kind)} · ${esc(n.status)}`}</text><text x="12" y="64">${esc(graphHostLabel(n))}</text></g>`;
|
|
114
200
|
}).join('');
|
|
115
|
-
const details = nodes.map(n => `<details class="execution-node-detail" data-graph-detail="${esc(n.id)}"><summary>${esc(n.id)} · ${esc(n.kind)} · ${esc(n
|
|
116
|
-
+ [['Attempt', n.attemptId], ['Host', n.host], ['Recorded at', n.at], ['Verdict', n.verdict], ['Wave', n.waveId], ['Gates', (n.gates || []).join(', ')],
|
|
201
|
+
const details = nodes.map(n => `<details class="execution-node-detail" data-graph-detail="${esc(n.id)}"><summary>${esc(n.id)} · ${esc(n.kind)} · ${esc(graphRoleLabel(n))} · ${esc(n.status)}</summary><dl>`
|
|
202
|
+
+ [['Attempt', n.attemptId], ['Host', n.host], ...(hostObserved ? [['Source', n.source || 'host-observation'], ['Source version', n.sourceVersion || 'unverified'], ['Phase', n.phase], ['Outcome', n.outcome]] : []), ['Recorded at', n.at], ['Verdict', n.verdict], ['Wave', n.waveId], ['Gates', (n.gates || []).join(', ')],
|
|
117
203
|
['Workflow snapshot SHA-256', n.graphDigest], ['Input receipt SHA-256', n.inputDigest], ['Output receipt SHA-256', n.outputDigest],
|
|
118
204
|
[n.kind === 'reuse' ? 'Historical report SHA-256 (worker not invoked)' : 'Stage context SHA-256', n.contextDigest], ['Result SHA-256', n.resultDigest]]
|
|
119
205
|
.map(([key, value]) => `<dt>${esc(key)}</dt><dd><code>${esc(value || 'Not recorded')}</code></dd>`).join('')
|
|
120
206
|
+ `<dt>Recorded predecessors</dt><dd>${parents.get(n.id).map(e => `${esc(e.from)} (${esc(e.kind)})`).join(', ') || 'None recorded; not proof of complete external inputs'}</dd></dl></details>`).join('');
|
|
121
|
-
|
|
122
|
-
|
|
207
|
+
const latestAttempts = graph.nodes.filter(node => node.kind === 'attempt'), latestPool = latestAttempts.length ? latestAttempts : graph.nodes;
|
|
208
|
+
const latest = [...latestPool].sort((a, b) => (Date.parse(a.at) || 0) - (Date.parse(b.at) || 0)).at(-1);
|
|
209
|
+
const navigation = `<div class="execution-graph-tools" role="group" aria-label="Graph node navigation"><label>Find graph node <input type="search" data-graph-find="query" maxlength="160" placeholder="Role, host or node ID"></label><button type="button" class="gate-btn" data-graph-search="find">Find node</button>${latest ? `<button type="button" class="gate-btn" data-graph-latest="${esc(latest.id)}">Latest recorded ${latestAttempts.length ? 'attempt' : 'observation'}</button>` : ''}<span data-graph-search-feedback role="status" aria-live="polite"></span></div>`;
|
|
210
|
+
return `<section class="execution-graph" aria-label="${hostObserved ? 'Host-observed' : 'Controller-recorded'} execution graph"><h3>${hostObserved ? 'Host-observed graph' : 'Controller-recorded graph'}</h3><p>${nodes.length} nodes · ${graph.edges.length} recorded edges${hostObserved ? '. Partial history: host delivery completeness is not attested.' : ` · ${graph.snapshots?.length || 0} workflow snapshots. ${graph.complete ? 'Controller history recorded from run creation.' : 'Partial history: missing edges are not inferred.'}`}</p>`
|
|
211
|
+
+ (hostObserved ? coverage : `<p class="work-meta">Dispatch, input, verification, gate and join edges describe controller observations. Intent is not proof of a live worker; recorded approval is not deployment or task acceptance. Historical reuse does not invoke a worker. Stage context digests exclude separately admitted domain packets.</p>`)
|
|
123
212
|
+ (graph.reason ? `<p class="work-warning">${esc(graph.reason)}</p>` : '')
|
|
124
|
-
+
|
|
125
|
-
+ `<div class="execution-graph-
|
|
126
|
-
+ `<
|
|
213
|
+
+ (laneLayout ? '<p class="work-meta">Visual grouping only; host lanes do not establish dependency or concurrency.</p>' : '') + navigation
|
|
214
|
+
+ `<div class="execution-graph-tools execution-graph-toolbar" role="group" aria-label="Graph zoom"><button type="button" class="gate-btn" data-graph-scale="0.75">Zoom out</button><button type="button" class="gate-btn" data-graph-scale="1.333333">Zoom in</button><button type="button" class="gate-btn" data-graph-scale="reset">Reset zoom</button>${options.canvas ? `<button type="button" class="gate-btn" data-graph-scale="fit">Fit graph</button><button type="button" class="gate-btn" data-graph-direction="lr" aria-pressed="${horizontal}">Left to right</button><button type="button" class="gate-btn" data-graph-direction="tb" aria-pressed="${!horizontal}">Top to bottom</button>` : ''}</div>`
|
|
215
|
+
+ `<div class="execution-graph-canvas" tabindex="0" aria-label="Scrollable graph. Tab to a node and press Enter for details."><svg data-graph-layout="${horizontal ? 'lr' : 'tb'}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="group" aria-label="Recorded execution dependencies"><defs><marker id="execution-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8"/></marker></defs>${laneBoxes}<g class="execution-edges" fill="none">${edges}</g>${boxes}</svg></div>`
|
|
216
|
+
+ (options.canvas ? '<details class="execution-node-list"><summary>Recorded node details · keyboard list</summary>' : '<h3>Recorded node details</h3>') + `<p class="work-meta">Use the graph or expand a node below. Digests bind recorded inputs, not correctness.</p>${details}${options.canvas ? '</details>' : ''}</section>`;
|
|
217
|
+
}
|
|
218
|
+
function focusGraphNode(node) {
|
|
219
|
+
if (!node) return false;
|
|
220
|
+
node.focus({ preventScroll: true }); node.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); return true;
|
|
221
|
+
}
|
|
222
|
+
function focusGraphMatch(graph, value) {
|
|
223
|
+
if (!graph) return;
|
|
224
|
+
const query = String(value || '').trim().slice(0, 160).toLowerCase();
|
|
225
|
+
const matches = [...graph.querySelectorAll('[data-graph-node]')].filter(node => String(node.dataset.graphSearchText || '').toLowerCase().includes(query));
|
|
226
|
+
const feedback = graph.querySelector('[data-graph-search-feedback]');
|
|
227
|
+
if (!query) { if (feedback) feedback.textContent = 'Enter a role, host or node ID.'; return; }
|
|
228
|
+
if (feedback) feedback.textContent = matches.length ? `Focused first of ${matches.length} matching nodes. All dependencies remain visible.` : 'No matching node on this page.';
|
|
229
|
+
if (matches.length) focusGraphNode(matches[0]);
|
|
127
230
|
}
|
|
128
231
|
function graphInteraction(target) {
|
|
232
|
+
const search = target.closest('[data-graph-search]');
|
|
233
|
+
if (search?.dataset.graphSearch === 'find') { const graph = search.closest('.execution-graph'); focusGraphMatch(graph, graph?.querySelector('[data-graph-find]')?.value); return true; }
|
|
234
|
+
const latest = target.closest('[data-graph-latest]');
|
|
235
|
+
if (latest?.dataset.graphLatest) { const graph = latest.closest('.execution-graph'); focusGraphNode([...graph?.querySelectorAll('[data-graph-node]') || []].find(node => node.dataset.graphNode === latest.dataset.graphLatest)); return true; }
|
|
236
|
+
|
|
237
|
+
const direction = target.closest('[data-graph-direction]');
|
|
238
|
+
if (config?.canvas && ['lr', 'tb'].includes(direction?.dataset.graphDirection)) { graphDirection = direction.dataset.graphDirection; closeWorkDialog(false); paint(); document.querySelector?.('[data-graph-direction][aria-pressed="true"]')?.focus(); return true; }
|
|
129
239
|
const node = target.closest('[data-graph-node]');
|
|
130
240
|
if (node?.dataset.graphNode) {
|
|
131
241
|
const detail = [...document.querySelectorAll('[data-graph-detail]')].find(el => el.dataset.graphDetail === node.dataset.graphNode);
|
|
132
|
-
if (detail
|
|
242
|
+
if (detail && config?.canvas) openNodeInspector(detail, node);
|
|
243
|
+
else if (detail) { detail.open = true; detail.querySelector('summary')?.focus(); detail.scrollIntoView({ block: 'nearest' }); }
|
|
133
244
|
return true;
|
|
134
245
|
}
|
|
135
246
|
const scale = target.closest('[data-graph-scale]');
|
|
136
247
|
if (!scale?.dataset.graphScale) return false;
|
|
137
248
|
const svg = scale.closest('.execution-graph').querySelector('svg'), bounds = svg.viewBox.baseVal;
|
|
138
|
-
const
|
|
249
|
+
const fit = scale.dataset.graphScale === 'fit' ? Math.min(1, Math.max(0.05, (scale.closest('.execution-graph').querySelector('.execution-graph-canvas').clientWidth - 2) / bounds.width)) : null;
|
|
250
|
+
const next = fit ?? (scale.dataset.graphScale === 'reset' ? 1 : Math.min(2, Math.max(0.25, Number(svg.dataset.zoom || 1) * Number(scale.dataset.graphScale))));
|
|
139
251
|
svg.dataset.zoom = String(next); svg.setAttribute('width', String(bounds.width * next)); svg.setAttribute('height', String(bounds.height * next));
|
|
140
252
|
return true;
|
|
141
253
|
}
|
|
142
254
|
function renderCockpit(data, options = {}) {
|
|
255
|
+
if (options.canvas) return renderWorkspace(data, options);
|
|
143
256
|
const category = categories.some(([key]) => key === options.category) ? options.category : 'active';
|
|
144
257
|
const tab = ['overview', 'execution', 'evidence'].includes(options.tab) ? options.tab : 'overview';
|
|
145
258
|
const rows = cockpitEntries(data, category, options.search || '');
|
|
@@ -159,6 +272,55 @@
|
|
|
159
272
|
+ `<div class="work-cockpit">${list}<section class="work-detail" aria-label="Selected task details">${tabs}<div id="work-detail-panel" role="tabpanel" aria-labelledby="work-tab-${tab}" tabindex="0">${detail}</div></section></div>`
|
|
160
273
|
+ (data.sessions.length ? `<details><summary>Claude sessions (${data.sessions.length}) · task linkage not recorded</summary>${data.sessions.map(s => `<p>${esc(s.session)} · ${esc(s.state)} · ${esc(s.reason || '')}</p>`).join('')}</details>` : '');
|
|
161
274
|
}
|
|
275
|
+
const taskTabs = ['overview', 'execution', 'evidence'];
|
|
276
|
+
function parseRoute(input) {
|
|
277
|
+
if (typeof input !== 'string' || input.length > 4096 || /[\u0000-\u001f\u007f]/.test(input)) return null;
|
|
278
|
+
const [path, query, extra] = input.split('?');
|
|
279
|
+
if (extra !== undefined) return null;
|
|
280
|
+
const params = new URLSearchParams(query || '');
|
|
281
|
+
if ([...params.keys()].some(key => !['category', 'search'].includes(key) || params.getAll(key).length !== 1)) return null;
|
|
282
|
+
const category = params.get('category') || (path === 'work/completed' ? 'completed' : 'active'), search = params.get('search') || '';
|
|
283
|
+
if (!categories.some(([key]) => key === category) || search.length > 1000 || /[\u0000-\u001f\u007f]/.test(search)) return null;
|
|
284
|
+
let selectedKey = null, tab = 'overview';
|
|
285
|
+
if (path !== 'work' && path !== 'work/completed') {
|
|
286
|
+
const match = path.match(/^work\/task\/([^/]+)\/(overview|execution|evidence)$/);
|
|
287
|
+
if (!match) return null;
|
|
288
|
+
try { selectedKey = decodeURIComponent(match[1]); } catch { return null; }
|
|
289
|
+
if (!selectedKey || selectedKey.length > 512 || /[\u0000-\u001f\u007f]/.test(selectedKey)) return null;
|
|
290
|
+
tab = match[2];
|
|
291
|
+
}
|
|
292
|
+
return { canonical: workspaceRoute(selectedKey, tab, category, search), selectedKey, tab, category, search };
|
|
293
|
+
}
|
|
294
|
+
function workspaceRoute(key, selectedTab, selectedCategory, search) {
|
|
295
|
+
const params = new URLSearchParams();
|
|
296
|
+
if (selectedCategory !== 'active') params.set('category', selectedCategory);
|
|
297
|
+
if (search) params.set('search', search);
|
|
298
|
+
return (key ? `work/task/${encodeURIComponent(key)}/${selectedTab}` : 'work') + (params.size ? '?' + params : '');
|
|
299
|
+
}
|
|
300
|
+
function taskRows(rows, selected = null) {
|
|
301
|
+
return rows.map(e => `<button type="button" class="work-task-row" data-work-select="${esc(e.key)}" aria-current="${e.key === selected}"><strong>${esc(e.title)}</strong><span>${esc(phaseLabel(e))}</span><small>${esc(e.host || 'Host not linked')} · ${esc(e.stage || 'Stage not recorded')}</small></button>`).join('');
|
|
302
|
+
}
|
|
303
|
+
function renderWorkspace(data, options) {
|
|
304
|
+
const category = categories.some(([key]) => key === options.category) ? options.category : 'active';
|
|
305
|
+
const tab = taskTabs.includes(options.tab) ? options.tab : 'overview';
|
|
306
|
+
const rows = cockpitEntries(data, category, options.search || '');
|
|
307
|
+
const feedback = data.sources.filter(s => ['degraded', 'unavailable'].includes(s.health)).map(s => `<p class="work-warning">${esc(s.id)}: ${esc(s.reason || s.health)}</p>`).join('');
|
|
308
|
+
const observation = `<p class="work-meta">Observed ${esc(data.observedAt)} · ${data.health === 'current' ? 'Sources read' : 'Sources incomplete'}. Recorded activity is not proof of current liveness or task acceptance.</p>`;
|
|
309
|
+
if (!options.selectedKey) {
|
|
310
|
+
const toolbar = '<div class="work-filters" role="group" aria-label="Task status filters">' + categories.map(([key, label]) => `<button type="button" class="work-filter" data-work-filter="${key}" aria-pressed="${category === key}">${label} <span>${cockpitEntries(data, key, options.search || '').length}</span></button>`).join('') + '</div>';
|
|
311
|
+
return `<section class="work-workspace work-list-only" aria-label="Project work list">${toolbar}${observation}${feedback}` + (rows.length ? `<div class="work-task-list" role="group" aria-label="Project tasks">${taskRows(rows)}</div>` : `<p>${data.health !== 'current' || feedback ? 'No readable entries match this view; sources are incomplete.' : options.search ? 'No entries match the search.' : 'No work records match this filter.'}</p>`) + '</section>';
|
|
312
|
+
}
|
|
313
|
+
const selected = data.entries.find(e => e.key === options.selectedKey);
|
|
314
|
+
const back = '<button type="button" class="work-back" data-work-back>← Work</button>';
|
|
315
|
+
if (!selected) return `<section class="work-workspace work-detail-only">${back}${observation}${feedback}<h2>Selected task is unavailable</h2><p>The requested task is not in the readable records for this project. Another task will not be substituted.</p></section>`;
|
|
316
|
+
const tabs = '<div class="work-tabs" role="tablist" aria-label="Selected task">' + taskTabs.map(name => `<button type="button" class="work-tab" id="work-tab-${name}" data-work-tab="${name}" role="tab" aria-selected="${tab === name}" aria-controls="work-detail-panel" tabindex="${tab === name ? 0 : -1}">${name[0].toUpperCase() + name.slice(1)}</button>`).join('') + '</div>';
|
|
317
|
+
const detail = tab === 'execution' ? renderExecution(selected, { canvas: true, direction: options.direction }) : tab === 'evidence'
|
|
318
|
+
? (Array.isArray(selected.evidenceSections) && selected.evidenceSections.length ? renderEvidence(selected.evidenceSections).replace('<details ', '<details open ') : '<p>Evidence unavailable. No verification or acceptance is inferred.</p>')
|
|
319
|
+
: render({ ...data, sources: [], sessions: [], entries: [{ ...selected, inspector: null, evidenceSections: null }] }, selected.terminal);
|
|
320
|
+
const runLabel = selected.title.match(/^Controlled run ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
|
|
321
|
+
const title = runLabel ? `Controlled run <small class="work-meta work-run-identifier">${esc(runLabel[1].slice(0, 8))}</small>` : esc(selected.title);
|
|
322
|
+
return `<section class="work-workspace work-detail-only" data-selected-work="${esc(selected.key)}" data-observation-scope="${esc(JSON.stringify([options.observationProject || data.projectId || null, selected.key]))}"><header class="work-task-header"><div class="work-task-navigation">${back}<span class="work-meta">${esc(selected.kind)} · ${esc(selected.host || 'Host not linked')}</span><button type="button" data-work-switch>Switch task</button><button type="button" data-work-previous${rows.findIndex(e => e.key === selected.key) > 0 ? '' : ' disabled'} aria-label="Previous task in current filter">Previous</button><button type="button" data-work-next${rows.findIndex(e => e.key === selected.key) >= 0 && rows.findIndex(e => e.key === selected.key) < rows.length - 1 ? '' : ' disabled'} aria-label="Next task in current filter">Next</button></div><h2>${title}</h2><p>${esc(phaseLabel(selected))}</p></header>${tabs}${feedback}${tab === 'overview' ? '' : observation}<div id="work-detail-panel" role="tabpanel" aria-labelledby="work-tab-${tab}" tabindex="0">${detail}</div></section>`;
|
|
323
|
+
}
|
|
162
324
|
function renderEvidence(sections) {
|
|
163
325
|
if (!Array.isArray(sections)) return '';
|
|
164
326
|
return '<details data-work-detail="evidence"><summary>Task evidence · read-only</summary>'
|
|
@@ -207,40 +369,148 @@
|
|
|
207
369
|
status('Draft PR publication recorded. Merge and release remain separate decisions.'); await refresh();
|
|
208
370
|
} catch (error) { if (project === config.project()) { status(error.message); await refresh(); } }
|
|
209
371
|
}
|
|
210
|
-
let config, snapshot, generation = 0, filter = '', selectedKey = null, category = 'active', tab = 'overview';
|
|
372
|
+
let config, snapshot, snapshotProject = null, generation = 0, filter = '', selectedKey = null, category = 'active', tab = 'overview';
|
|
373
|
+
let graphDirection = 'lr';
|
|
374
|
+
let activeDialog = null, dialogOpener = null, dialogReturn = null, dialogEscape = null, pendingPaint = false, listPosition = null;
|
|
211
375
|
const status = message => { document.getElementById('work-feedback').textContent = message; };
|
|
376
|
+
function notifyView() { config?.onViewChange?.({ detail: !!selectedKey, tab }); }
|
|
377
|
+
function navigateRoute(input) {
|
|
378
|
+
const route = parseRoute(input);
|
|
379
|
+
if (!route) return false;
|
|
380
|
+
closeWorkDialog(false);
|
|
381
|
+
const wasDetail = !!selectedKey;
|
|
382
|
+
if (selectedKey !== route.selectedKey) graphDirection = 'lr';
|
|
383
|
+
selectedKey = route.selectedKey; tab = route.tab; category = route.category; filter = route.search;
|
|
384
|
+
if (snapshot && snapshotProject !== config?.project()) { snapshot = null; previews.clear(); document.getElementById('work-list').innerHTML = ''; document.getElementById('work-list').textContent = 'Loading selected project…'; }
|
|
385
|
+
notifyView(); paint();
|
|
386
|
+
if (wasDetail && !selectedKey) restoreListPosition();
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
function rememberListPosition() {
|
|
390
|
+
const list = document.getElementById('work-list'), scroller = list.closest?.('.panel');
|
|
391
|
+
listPosition = { project: config.project(), category, filter, key: selectedKey, top: scroller?.scrollTop || 0 };
|
|
392
|
+
}
|
|
393
|
+
function restoreListPosition() {
|
|
394
|
+
if (!listPosition || listPosition.project !== config.project() || listPosition.category !== category || listPosition.filter !== filter) return;
|
|
395
|
+
const list = document.getElementById('work-list'), scroller = list.closest?.('.panel');
|
|
396
|
+
if (scroller) scroller.scrollTop = listPosition.top;
|
|
397
|
+
[...document.querySelectorAll('[data-work-select]')].find(n => n.dataset.workSelect === listPosition.key)?.focus({ preventScroll: true });
|
|
398
|
+
}
|
|
399
|
+
function changeWorkspace() {
|
|
400
|
+
closeWorkDialog(false); notifyView(); paint();
|
|
401
|
+
config.navigate?.(workspaceRoute(selectedKey, tab, category, filter));
|
|
402
|
+
}
|
|
403
|
+
function closeWorkDialog(restore = true) {
|
|
404
|
+
if (!activeDialog) return;
|
|
405
|
+
const dialog = activeDialog, opener = dialogOpener, returnTo = dialogReturn;
|
|
406
|
+
activeDialog = null; dialogOpener = null; dialogReturn = null;
|
|
407
|
+
if (dialogEscape) { document.removeEventListener('keydown', dialogEscape, true); dialogEscape = null; }
|
|
408
|
+
if (dialog.open) dialog.close();
|
|
409
|
+
dialog.remove();
|
|
410
|
+
if (pendingPaint) { pendingPaint = false; paint(); }
|
|
411
|
+
if (!restore || !returnTo || returnTo.project !== config.project() || returnTo.task !== selectedKey) return;
|
|
412
|
+
const replacement = returnTo.node
|
|
413
|
+
? [...document.querySelectorAll('[data-graph-node]')].find(node => node.dataset.graphNode === returnTo.node)
|
|
414
|
+
: returnTo.switcher ? document.querySelectorAll('[data-work-switch]')[0] : null;
|
|
415
|
+
if (opener?.isConnected) opener.focus();
|
|
416
|
+
else replacement?.focus();
|
|
417
|
+
}
|
|
418
|
+
function leaveView() { pendingPaint = false; closeWorkDialog(false); }
|
|
419
|
+
function makeWorkDialog(className, title, opener, content, modal) {
|
|
420
|
+
closeWorkDialog(false);
|
|
421
|
+
const dialog = document.createElement('dialog'); dialog.className = className;
|
|
422
|
+
dialog.setAttribute('aria-label', title);
|
|
423
|
+
dialog.innerHTML = `<header><h2>${esc(title)}</h2><button type="button" data-dialog-close aria-label="Close ${esc(title)}">Close</button></header>${content}`;
|
|
424
|
+
document.body.append(dialog); activeDialog = dialog; dialogOpener = opener;
|
|
425
|
+
dialogReturn = { project: config.project(), task: selectedKey, node: opener?.dataset?.graphNode || null, switcher: Object.prototype.hasOwnProperty.call(opener?.dataset || {}, 'workSwitch') };
|
|
426
|
+
dialogEscape = event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeWorkDialog(); } };
|
|
427
|
+
document.addEventListener('keydown', dialogEscape, true);
|
|
428
|
+
dialog.addEventListener('cancel', event => { event.preventDefault(); closeWorkDialog(); });
|
|
429
|
+
dialog.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeWorkDialog(); } });
|
|
430
|
+
dialog.addEventListener('click', event => { if (event.target.closest('[data-dialog-close]')) closeWorkDialog(); });
|
|
431
|
+
dialog.addEventListener('close', () => { if (activeDialog === dialog) closeWorkDialog(); });
|
|
432
|
+
if (modal) dialog.showModal(); else dialog.show();
|
|
433
|
+
return dialog;
|
|
434
|
+
}
|
|
435
|
+
function openTaskSwitcher(opener) {
|
|
436
|
+
const rows = cockpitEntries(snapshot, category, filter);
|
|
437
|
+
const dialog = makeWorkDialog('work-switcher', 'Switch task', opener, '<label>Find a task<input type="search" data-switch-search placeholder="Title or task ID"></label><p class="work-meta">Tasks in the current project and list filter.</p><div data-switch-rows>' + taskRows(rows, selectedKey) + '</div><p data-switch-empty hidden>No matching task.</p>', true);
|
|
438
|
+
const input = dialog.querySelector('[data-switch-search]'); input.focus();
|
|
439
|
+
input.addEventListener('input', () => { let count = 0; dialog.querySelectorAll('[data-work-select]').forEach(button => { const entry = rows.find(e => e.key === button.dataset.workSelect); button.hidden = !`${entry.title} ${entry.key}`.toLowerCase().includes(input.value.toLowerCase()); if (!button.hidden) count++; }); dialog.querySelector('[data-switch-empty]').hidden = count > 0; });
|
|
440
|
+
dialog.addEventListener('click', event => { const button = event.target.closest('[data-work-select]'); if (button && snapshot?.entries.some(e => e.key === button.dataset.workSelect) && snapshotProject === config.project()) { selectedKey = button.dataset.workSelect; tab = 'overview'; changeWorkspace(); document.querySelector('[data-work-tab]')?.focus(); } });
|
|
441
|
+
}
|
|
442
|
+
function openNodeInspector(detail, opener) {
|
|
443
|
+
document.querySelectorAll('[data-graph-detail][open]').forEach(node => { node.open = false; });
|
|
444
|
+
const content = detail.outerHTML.replace('<details ', '<details open ');
|
|
445
|
+
const dialog = makeWorkDialog('work-node-inspector', 'Recorded node details', opener, '<p class="work-meta">Recorded inputs, not proof of correctness, current activity or acceptance.</p>' + content, false);
|
|
446
|
+
dialog.querySelector('summary')?.focus();
|
|
447
|
+
}
|
|
448
|
+
async function nextObservationPage(button) {
|
|
449
|
+
const project = config.project(), entryKey = selectedKey, oldSnapshot = snapshot;
|
|
450
|
+
try {
|
|
451
|
+
const binding = JSON.parse(button.dataset.hostGraphNext), query = new URLSearchParams({ host: binding.host, session: binding.sessionId, run: binding.runId, cursor: binding.cursor });
|
|
452
|
+
if (project) query.set('project', project);
|
|
453
|
+
button.disabled = true;
|
|
454
|
+
const page = await config.read('/api/host-graph?' + query.toString());
|
|
455
|
+
if (project !== config.project() || entryKey !== selectedKey || snapshot !== oldSnapshot) return;
|
|
456
|
+
if (!page || page.error || page.source !== 'host-observation') throw Error('unavailable');
|
|
457
|
+
const entry = snapshot.entries.find(e => e.key === entryKey), graphs = [entry?.execution?.graph, ...(entry?.execution?.observations || [])];
|
|
458
|
+
const graph = graphs.find(g => g?.binding?.sessionId === binding.sessionId && g?.binding?.host === binding.host && g?.binding?.runId === binding.runId);
|
|
459
|
+
if (!graph) throw Error('unavailable');
|
|
460
|
+
Object.assign(graph, page); paint();
|
|
461
|
+
status(`Observation page loaded: ${page.nodes?.length || 0} recorded nodes.`);
|
|
462
|
+
const first = page.nodes?.[0]?.id;
|
|
463
|
+
if (first) [...document.querySelectorAll('[data-graph-node]')].find(node => node.dataset.graphNode === first)?.focus();
|
|
464
|
+
} catch { status('Observation page is unavailable. Refresh the selected project and retry.'); }
|
|
465
|
+
finally { if (button.isConnected) button.disabled = false; }
|
|
466
|
+
}
|
|
212
467
|
async function refresh() {
|
|
213
468
|
const seq = ++generation, project = config.project();
|
|
214
469
|
try {
|
|
215
470
|
const data = await config.read(`/api/work${project ? '?project=' + encodeURIComponent(project) : ''}`);
|
|
216
471
|
if (seq !== generation || project !== config.project()) return;
|
|
217
472
|
if (!data || data.error || data.schemaVersion !== 1) throw Error(data?.error || 'Cannot read work state. Check the board connection and refresh.');
|
|
218
|
-
if (snapshot?.revision === data.revision) return;
|
|
219
|
-
snapshot = data;
|
|
473
|
+
if (snapshotProject === project && snapshot?.revision === data.revision) return;
|
|
474
|
+
snapshot = data; snapshotProject = project;
|
|
475
|
+
if (config.canvas && activeDialog?.open) { pendingPaint = true; previews.clear(); } else paint();
|
|
220
476
|
} catch (error) {
|
|
221
477
|
if (seq !== generation || project !== config.project()) return;
|
|
222
|
-
snapshot = null;
|
|
478
|
+
snapshot = null; snapshotProject = null; pendingPaint = false; closeWorkDialog(false);
|
|
223
479
|
for (const id of ['work-list', 'history-list']) document.getElementById(id).textContent = error.message;
|
|
224
480
|
}
|
|
225
481
|
}
|
|
226
482
|
function paint() {
|
|
227
|
-
if (!snapshot) return;
|
|
483
|
+
if (!snapshot || (config?.canvas && snapshotProject !== config.project())) return;
|
|
228
484
|
previews.clear(); // Repaint removes the reviewed diff and its confirmation controls.
|
|
485
|
+
const oldWorkspace = document.querySelector?.('[data-observation-scope]');
|
|
486
|
+
const sameObservationScope = oldWorkspace?.dataset.observationScope === JSON.stringify([snapshotProject, selectedKey]);
|
|
487
|
+
const expandedHosts = sameObservationScope ? [...oldWorkspace.querySelectorAll('[data-observation-key][open]')].slice(0, 16).map(detail => detail.dataset.observationKey).filter(key => typeof key === 'string' && key.length <= 1024) : [];
|
|
229
488
|
const expanded = [...document.querySelectorAll('.work-card details[open]')].map(d => `${d.closest('[data-entry-key]').dataset.entryKey}:${d.dataset.workDetail}`);
|
|
230
489
|
const focused = document.activeElement;
|
|
231
490
|
const focusKey = focused?.dataset?.workKey, focusAction = focused?.dataset?.workAction;
|
|
232
491
|
const focusSelect = focused?.dataset?.workSelect, focusFilter = focused?.dataset?.workFilter, focusTab = focused?.dataset?.workTab;
|
|
233
|
-
|
|
492
|
+
const focusNode = focused?.dataset?.graphNode;
|
|
493
|
+
const oldCanvas = config.canvas ? document.querySelector?.('.execution-graph-canvas') : null;
|
|
494
|
+
const oldSvg = oldCanvas?.querySelector('svg'), oldZoom = oldSvg ? Number(oldSvg.dataset.zoom || 1) : 1;
|
|
495
|
+
const graphScroll = oldCanvas?.closest('[data-selected-work]')?.dataset.selectedWork === selectedKey && oldSvg?.dataset.graphLayout === graphDirection ? { left: oldCanvas.scrollLeft, top: oldCanvas.scrollTop } : null;
|
|
496
|
+
document.getElementById('work-list').innerHTML = renderCockpit(snapshot, { search: filter, selectedKey, category, tab, canvas: !!config.canvas, direction: graphDirection, observationProject: snapshotProject });
|
|
234
497
|
document.getElementById('history-list').innerHTML = render(snapshot, true, filter);
|
|
498
|
+
if (graphScroll) {
|
|
499
|
+
const canvas = document.querySelector?.('.execution-graph-canvas'), svg = canvas?.querySelector('svg');
|
|
500
|
+
if (svg && Number.isFinite(oldZoom) && oldZoom > 0) { const bounds = svg.viewBox.baseVal; svg.dataset.zoom = String(oldZoom); svg.setAttribute('width', String(bounds.width * oldZoom)); svg.setAttribute('height', String(bounds.height * oldZoom)); canvas.scrollLeft = graphScroll.left; canvas.scrollTop = graphScroll.top; }
|
|
501
|
+
}
|
|
502
|
+
for (const detail of document.querySelectorAll('[data-observation-key]')) if (expandedHosts.includes(detail.dataset.observationKey)) detail.open = true;
|
|
235
503
|
for (const card of document.querySelectorAll('[data-entry-key]')) for (const detail of card.querySelectorAll('details'))
|
|
236
504
|
if (expanded.includes(`${card.dataset.entryKey}:${detail.dataset.workDetail}`)) detail.open = true;
|
|
237
505
|
if (focusKey) [...document.querySelectorAll('[data-work-key]')].find(b => b.dataset.workKey === focusKey && b.dataset.workAction === focusAction)?.focus();
|
|
238
506
|
else if (focusSelect) [...document.querySelectorAll('[data-work-select]')].find(b => b.dataset.workSelect === focusSelect)?.focus();
|
|
239
507
|
else if (focusFilter) [...document.querySelectorAll('[data-work-filter]')].find(b => b.dataset.workFilter === focusFilter)?.focus();
|
|
240
508
|
else if (focusTab) [...document.querySelectorAll('[data-work-tab]')].find(b => b.dataset.workTab === focusTab)?.focus();
|
|
509
|
+
else if (focusNode) [...document.querySelectorAll('[data-graph-node]')].find(n => n.dataset.graphNode === focusNode)?.focus({ preventScroll: true });
|
|
241
510
|
}
|
|
242
511
|
function reset() {
|
|
243
|
-
|
|
512
|
+
closeWorkDialog(false); pendingPaint = false; listPosition = null; graphDirection = 'lr';
|
|
513
|
+
generation++; snapshotProject = null; snapshot = null; previews.clear(); selectedKey = null; category = 'active'; tab = 'overview'; filter = ''; status(''); notifyView();
|
|
244
514
|
for (const id of ['work-list', 'history-list']) document.getElementById(id).textContent = 'Loading project work…';
|
|
245
515
|
refresh();
|
|
246
516
|
}
|
|
@@ -262,16 +532,26 @@
|
|
|
262
532
|
document.getElementById('work-command').textContent = ''; status('');
|
|
263
533
|
});
|
|
264
534
|
for (const id of ['work-list', 'history-list']) document.getElementById(id).addEventListener('click', event => {
|
|
535
|
+
if (config.canvas && snapshotProject !== config.project()) return;
|
|
536
|
+
const pageButton = event.target.closest('[data-host-graph-next]');
|
|
537
|
+
if (pageButton?.dataset.hostGraphNext) { nextObservationPage(pageButton); return; }
|
|
265
538
|
if (graphInteraction(event.target)) return;
|
|
266
|
-
if (!snapshot) return;
|
|
539
|
+
if (!snapshot || (config.canvas && snapshotProject !== config.project())) return;
|
|
540
|
+
if (config.canvas) {
|
|
541
|
+
if (event.target.closest('[data-work-back]')) { selectedKey = null; tab = 'overview'; changeWorkspace(); restoreListPosition(); return; }
|
|
542
|
+
const switcher = event.target.closest('[data-work-switch]');
|
|
543
|
+
if (switcher) { openTaskSwitcher(switcher); return; }
|
|
544
|
+
const direction = event.target.closest('[data-work-previous]') ? -1 : event.target.closest('[data-work-next]') ? 1 : 0;
|
|
545
|
+
if (direction) { const rows = cockpitEntries(snapshot, category, filter), index = rows.findIndex(e => e.key === selectedKey), next = index >= 0 ? rows[index + direction] : null; if (next) { selectedKey = next.key; tab = 'overview'; changeWorkspace(); } return; }
|
|
546
|
+
}
|
|
267
547
|
const button = event.target.closest('[data-work-action]');
|
|
268
548
|
if (!button?.dataset.workAction) {
|
|
269
549
|
const selection = event.target.closest('[data-work-select]');
|
|
270
550
|
const filterButton = event.target.closest('[data-work-filter]');
|
|
271
551
|
const tabButton = event.target.closest('[data-work-tab]');
|
|
272
|
-
if (selection?.dataset.workSelect && snapshot.entries.some(e => e.key === selection.dataset.workSelect)) { selectedKey = selection.dataset.workSelect; tab = 'overview'; paint(); }
|
|
273
|
-
else if (categories.some(([key]) => key === filterButton?.dataset.workFilter)) { category = filterButton.dataset.workFilter; selectedKey = null; tab = 'overview'; paint(); }
|
|
274
|
-
else if (['overview', 'execution', 'evidence'].includes(tabButton?.dataset.workTab)) { tab = tabButton.dataset.workTab; paint(); }
|
|
552
|
+
if (selection?.dataset.workSelect && snapshot.entries.some(e => e.key === selection.dataset.workSelect)) { if (config.canvas) { rememberListPosition(); listPosition.key = selection.dataset.workSelect; } selectedKey = selection.dataset.workSelect; tab = 'overview'; if (config.canvas) changeWorkspace(); else paint(); }
|
|
553
|
+
else if (categories.some(([key]) => key === filterButton?.dataset.workFilter)) { category = filterButton.dataset.workFilter; selectedKey = null; tab = 'overview'; if (config.canvas) changeWorkspace(); else paint(); }
|
|
554
|
+
else if (['overview', 'execution', 'evidence'].includes(tabButton?.dataset.workTab)) { tab = tabButton.dataset.workTab; if (config.canvas) changeWorkspace(); else paint(); }
|
|
275
555
|
return;
|
|
276
556
|
}
|
|
277
557
|
const entry = snapshot.entries.find(e => e.key === button.dataset.workKey);
|
|
@@ -283,17 +563,20 @@
|
|
|
283
563
|
else options.openIssue(entry.issueIds[0], action.action === 'review_decision');
|
|
284
564
|
});
|
|
285
565
|
document.getElementById('work-list').addEventListener('keydown', event => {
|
|
286
|
-
if (
|
|
566
|
+
if (config.canvas && snapshotProject !== config.project()) return;
|
|
567
|
+
const graphFind = event.target.closest('[data-graph-find]');
|
|
568
|
+
if (event.key === 'Enter' && graphFind?.dataset.graphFind === 'query') { event.preventDefault(); focusGraphMatch(graphFind.closest('.execution-graph'), graphFind.value); return; }
|
|
569
|
+
if (['Enter', ' '].includes(event.key) && event.target.closest('[data-graph-node]')?.dataset.graphNode) { event.preventDefault(); graphInteraction(event.target); return; }
|
|
287
570
|
const button = event.target.closest('[data-work-tab]');
|
|
288
571
|
if (!button || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
|
289
572
|
const tabs = ['overview', 'execution', 'evidence'], current = tabs.indexOf(button.dataset.workTab);
|
|
290
573
|
if (current < 0) return;
|
|
291
574
|
event.preventDefault();
|
|
292
575
|
tab = event.key === 'Home' ? tabs[0] : event.key === 'End' ? tabs[2] : tabs[(current + (event.key === 'ArrowRight' ? 1 : 2)) % 3];
|
|
293
|
-
paint();
|
|
576
|
+
if (config.canvas) changeWorkspace(); else paint();
|
|
294
577
|
[...document.querySelectorAll('[data-work-tab]')].find(b => b.dataset.workTab === tab)?.focus();
|
|
295
578
|
});
|
|
296
579
|
setInterval(() => { if (!document.hidden && options.visible()) refresh(); }, 10000);
|
|
297
580
|
}
|
|
298
|
-
window.GctoWork = { bind, refresh, reset, render, renderCockpit, renderExecutionGraph, commandFor, publicationCommand, getSnapshot() { return snapshot; }, showCompleted() { category = 'completed'; selectedKey = null; tab = 'overview'; paint(); }, search(value) { filter = value; paint(); } };
|
|
581
|
+
window.GctoWork = { bind, refresh, reset, leaveView, parseRoute, navigateRoute, render, renderCockpit, renderExecutionGraph, commandFor, publicationCommand, getSnapshot() { return snapshot; }, showCompleted() { category = 'completed'; selectedKey = null; tab = 'overview'; notifyView(); paint(); }, search(value) { filter = value; if (config?.canvas) { if (!selectedKey) changeWorkspace(); } else paint(); } };
|
|
299
582
|
})();
|
|
@@ -4139,6 +4139,9 @@ let metrics = {};
|
|
|
4139
4139
|
let shareState = { enabled: false, url: null };
|
|
4140
4140
|
let currentTab = 'work';
|
|
4141
4141
|
window.GctoWork.bind({
|
|
4142
|
+
canvas: true,
|
|
4143
|
+
navigate: canonical => window.GctoAdmin?.navigateWork(canonical),
|
|
4144
|
+
onViewChange: view => window.GctoAdmin?.setWorkView(view),
|
|
4142
4145
|
project: () => new URLSearchParams(location.search).get('project') || '',
|
|
4143
4146
|
visible: () => currentTab === 'work' || currentTab === 'history',
|
|
4144
4147
|
read: url => api(url),
|
|
@@ -7815,6 +7818,7 @@ async function switchProject(slug) {
|
|
|
7815
7818
|
resetProjectUsage();
|
|
7816
7819
|
if (currentTab === 'usage') loadUsage();
|
|
7817
7820
|
if (currentTab === 'skills') loadSkillsInventory();
|
|
7821
|
+
if (window.GctoWork.parseRoute((location.hash || '').replace(/^#\/?/, ''))?.selectedKey) history.replaceState(history.state, '', '#/work');
|
|
7818
7822
|
window.GctoWork.reset();
|
|
7819
7823
|
// Optimistic UI clear
|
|
7820
7824
|
allTasks = [];
|
|
@@ -7882,6 +7886,7 @@ function filterTasks(q) {
|
|
|
7882
7886
|
function onSearch(q) {
|
|
7883
7887
|
window.GctoWork.search(q);
|
|
7884
7888
|
searchQuery = q;
|
|
7889
|
+
if (currentTab === 'work' || currentTab === 'history') return;
|
|
7885
7890
|
renderKanban(filterTasks(q));
|
|
7886
7891
|
document.getElementById('topbar-task-count').textContent =
|
|
7887
7892
|
`${filterTasks(q).length} ${q ? 'MATCH' : 'ISSUES'}`;
|
|
@@ -8025,6 +8030,14 @@ async function init() {
|
|
|
8025
8030
|
|| fromTaskId
|
|
8026
8031
|
|| 'project';
|
|
8027
8032
|
serverDefaultProject = !urlProject && !(allProjects || []).some(p => p.slug === proj);
|
|
8033
|
+
// Registered default projects get an explicit scope before history entries
|
|
8034
|
+
// are created. Keep the unregistered server-default fallback unqualified.
|
|
8035
|
+
if (!urlProject && !serverDefaultProject) {
|
|
8036
|
+
const scopedURL = new URL(location.href); scopedURL.searchParams.set('project', proj);
|
|
8037
|
+
history.replaceState(history.state, '', scopedURL.toString());
|
|
8038
|
+
const workRoute = (location.hash || '#/work').replace(/^#\/?/, '');
|
|
8039
|
+
if (window.GctoWork.parseRoute(workRoute)) window.GctoWork.navigateRoute(workRoute);
|
|
8040
|
+
}
|
|
8028
8041
|
currentProject = proj;
|
|
8029
8042
|
resetProjectUsage();
|
|
8030
8043
|
if (currentTab === 'usage') loadUsage();
|
|
@@ -9432,7 +9445,36 @@ function routeFromHash() {
|
|
|
9432
9445
|
}
|
|
9433
9446
|
return ROUTES[h] ? h : (LEGACY_ROUTES[h] || null);
|
|
9434
9447
|
}
|
|
9435
|
-
|
|
9448
|
+
let routeReplay = 0, lastRouteReplay = '';
|
|
9449
|
+
async function replayBoardRoute() {
|
|
9450
|
+
if (location.href === lastRouteReplay) return;
|
|
9451
|
+
lastRouteReplay = location.href;
|
|
9452
|
+
const ticket = ++routeReplay;
|
|
9453
|
+
let project = new URLSearchParams(location.search).get('project') || '';
|
|
9454
|
+
const expectedHash = location.hash, expectedURL = location.href;
|
|
9455
|
+
if (!project && typeof currentProject !== 'undefined' && currentProject) {
|
|
9456
|
+
// A legacy unqualified history entry means the server default, not the
|
|
9457
|
+
// project whose records happen to be in memory. Clear it before resolving.
|
|
9458
|
+
window.GctoWork.navigateRoute('work');
|
|
9459
|
+
const defaults = await api('/api/work');
|
|
9460
|
+
if (ticket !== routeReplay || location.href !== expectedURL) return;
|
|
9461
|
+
project = defaults?.projectName || '';
|
|
9462
|
+
if (!project || !(allProjects || []).some(item => item.slug === project)) { location.reload(); return; }
|
|
9463
|
+
const scopedURL = new URL(location.href); scopedURL.searchParams.set('project', project);
|
|
9464
|
+
history.replaceState(history.state, '', scopedURL.toString());
|
|
9465
|
+
}
|
|
9466
|
+
if (project && typeof currentProject !== 'undefined' && currentProject && project !== currentProject) {
|
|
9467
|
+
const loading = switchProject(project);
|
|
9468
|
+
history.replaceState(history.state, '', expectedHash || '#/work');
|
|
9469
|
+
await loading;
|
|
9470
|
+
if (ticket !== routeReplay || project !== new URLSearchParams(location.search).get('project')) return;
|
|
9471
|
+
history.replaceState(history.state, '', expectedHash || '#/work');
|
|
9472
|
+
}
|
|
9473
|
+
if (ticket !== routeReplay) return;
|
|
9474
|
+
const r = routeFromHash(); if (r) switchTab(r);
|
|
9475
|
+
}
|
|
9476
|
+
addEventListener('hashchange', replayBoardRoute);
|
|
9477
|
+
addEventListener('popstate', replayBoardRoute);
|
|
9436
9478
|
addEventListener('DOMContentLoaded', () => { const r = routeFromHash(); if (r && r !== 'work') switchTab(r); });
|
|
9437
9479
|
|
|
9438
9480
|
async function refreshLogs() {
|