@patwoodman/dsh-sdlc-flow-ui 0.4.0-beta.23 → 0.4.0-beta.24

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 CHANGED
@@ -5,6 +5,7 @@ sidebar, plus the host routes that feed it.
5
5
 
6
6
  | Panel | What it shows |
7
7
  |---|---|
8
+ | Flow | live task DAG, subagent runs, routing, gates, locks, escalations and events |
8
9
  | Status | live project, task and spend summary from `sdlc.db` |
9
10
  | Board | tasks grouped by state-machine state |
10
11
  | Config | a validated editor for `.sdlc/config.yml` (writes are rejected when validation fails) |
@@ -12,7 +13,13 @@ sidebar, plus the host routes that feed it.
12
13
 
13
14
  Host routes: `/api/sdlc.status`, `/api/sdlc.board`, `/api/sdlc.task`,
14
15
  `/api/sdlc.compare`, `/api/sdlc.config` (GET + validated POST),
15
- `/api/sdlc.metrics`.
16
+ `/api/sdlc.metrics`, `/api/sdlc.flow` (bounded visualizer snapshot).
17
+
18
+ The tab is registered as a right-sidebar page with a guide entry, so a new
19
+ session can open it from the guide's **SDLC Flow** capsule. With
20
+ `autoOpen: true` (the shipped default) it also opens once for the first session
21
+ that has an SDLC project; the Flow panel polls every 5 s and keeps the last
22
+ snapshot as *stale* when a poll fails.
16
23
 
17
24
  `lib/client.js` is a prebuilt closure-factory bundle that registers into the
18
25
  right-sidebar slot system; it needs no build step on the consumer's machine.
@@ -31,7 +38,7 @@ It must never be mounted in the headless worker profile (E6).
31
38
  ## Requirements
32
39
 
33
40
  - `@deepseek-ai/dsh` `>=0.2.0-rc.2 <0.3.0-0`
34
- - Node.js ≥ 22.5
41
+ - Node.js ≥ 22.13
35
42
  - a profile whose client platform is `web`
36
43
 
37
44
  ## License
package/cordis.patch.yml CHANGED
@@ -8,4 +8,5 @@
8
8
  - insert:
9
9
  - id: sdlc-ui
10
10
  name: '@patwoodman/dsh-sdlc-flow-ui'
11
- config: {}
11
+ config:
12
+ autoOpen: true
@@ -36,3 +36,164 @@
36
36
  .sdlc-root ul {
37
37
  padding-left: 20px;
38
38
  }
39
+
40
+ /* Flow visualizer */
41
+ .sdlc-flow {
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: 10px;
45
+ }
46
+ .sdlc-flow-head {
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 10px;
50
+ flex-wrap: wrap;
51
+ }
52
+ .sdlc-muted {
53
+ color: #888;
54
+ font-size: 12px;
55
+ }
56
+ .sdlc-warn {
57
+ color: #9a6700;
58
+ background: #9a670014;
59
+ border: 1px solid #9a670033;
60
+ border-radius: 6px;
61
+ padding: 4px 8px;
62
+ }
63
+ .sdlc-bad {
64
+ color: #b42318;
65
+ }
66
+ .sdlc-chips {
67
+ display: flex;
68
+ flex-wrap: wrap;
69
+ gap: 6px;
70
+ }
71
+ .sdlc-chip {
72
+ display: inline-flex;
73
+ align-items: center;
74
+ gap: 4px;
75
+ border: 1px solid #8884;
76
+ border-radius: 999px;
77
+ padding: 1px 8px;
78
+ font-size: 12px;
79
+ white-space: nowrap;
80
+ }
81
+ .sdlc-tone-idle { background: #88888814; }
82
+ .sdlc-tone-ready { background: #2563eb14; }
83
+ .sdlc-tone-running { background: #7c3aed14; }
84
+ .sdlc-tone-verify { background: #0891b214; }
85
+ .sdlc-tone-merge { background: #ca8a0414; }
86
+ .sdlc-tone-good { background: #16803c14; }
87
+ .sdlc-tone-warn { background: #9a670014; }
88
+ .sdlc-tone-bad { background: #b4231814; }
89
+
90
+ .sdlc-dag {
91
+ position: relative;
92
+ overflow: auto;
93
+ border: 1px solid #8883;
94
+ border-radius: 8px;
95
+ background-image: radial-gradient(#8882 1px, transparent 1px);
96
+ background-size: 16px 16px;
97
+ min-height: 120px;
98
+ }
99
+ .sdlc-dag-edges {
100
+ position: absolute;
101
+ inset: 0;
102
+ pointer-events: none;
103
+ }
104
+ .sdlc-edge {
105
+ fill: none;
106
+ stroke: #8888;
107
+ stroke-width: 1.5;
108
+ }
109
+ .sdlc-edge.bad {
110
+ stroke: #b42318;
111
+ stroke-dasharray: 4 3;
112
+ }
113
+ .sdlc-node {
114
+ position: absolute;
115
+ width: 168px;
116
+ min-height: 52px;
117
+ display: flex;
118
+ flex-direction: column;
119
+ gap: 1px;
120
+ text-align: left;
121
+ padding: 6px 8px;
122
+ border: 1px solid #8885;
123
+ border-radius: 8px;
124
+ background: Canvas;
125
+ color: CanvasText;
126
+ font: inherit;
127
+ cursor: pointer;
128
+ box-shadow: 0 1px 2px #0001;
129
+ }
130
+ .sdlc-node:hover {
131
+ border-color: #888;
132
+ }
133
+ .sdlc-node-id {
134
+ font-weight: 600;
135
+ font-size: 12px;
136
+ }
137
+ .sdlc-node-state {
138
+ font-size: 11px;
139
+ letter-spacing: 0.02em;
140
+ }
141
+ .sdlc-node-meta,
142
+ .sdlc-node-deps {
143
+ font-size: 11px;
144
+ color: #777;
145
+ overflow: hidden;
146
+ text-overflow: ellipsis;
147
+ white-space: nowrap;
148
+ }
149
+ .sdlc-flow-section h3 {
150
+ margin: 8px 0 4px;
151
+ font-size: 12px;
152
+ text-transform: uppercase;
153
+ letter-spacing: 0.04em;
154
+ color: #777;
155
+ }
156
+ .sdlc-run-list,
157
+ .sdlc-routing-list,
158
+ .sdlc-gate-list,
159
+ .sdlc-lock-list,
160
+ .sdlc-escalation-list,
161
+ .sdlc-event-list {
162
+ list-style: none;
163
+ margin: 0;
164
+ padding: 0;
165
+ display: flex;
166
+ flex-direction: column;
167
+ gap: 4px;
168
+ }
169
+ .sdlc-run {
170
+ display: flex;
171
+ flex-wrap: wrap;
172
+ gap: 6px;
173
+ align-items: center;
174
+ padding: 4px 6px;
175
+ border-radius: 6px;
176
+ font-size: 12px;
177
+ }
178
+ .sdlc-run-role {
179
+ font-weight: 600;
180
+ }
181
+ .sdlc-run-id {
182
+ color: #777;
183
+ font-family: ui-monospace, SFMono-Regular, monospace;
184
+ }
185
+ .sdlc-route {
186
+ display: flex;
187
+ flex-wrap: wrap;
188
+ gap: 6px;
189
+ align-items: center;
190
+ font-size: 12px;
191
+ }
192
+ .sdlc-route-task {
193
+ font-weight: 600;
194
+ }
195
+ .sdlc-event-list li {
196
+ font-size: 12px;
197
+ border-bottom: 1px solid #8882;
198
+ padding: 2px 0;
199
+ }
@@ -12,17 +12,25 @@
12
12
  * onClick below (single-expression arrows are fine).
13
13
  */
14
14
 
15
- import { useState, useSyncExternalStore } from 'react';
16
- import { StatusPanel, BoardPanel, ConfigPanel, ReviewPanel } from './panels.jsx';
15
+ import { useState, useSyncExternalStore, useEffect } from 'react';
16
+ import { StatusPanel, BoardPanel, ConfigPanel, ReviewPanel, FlowPanel } from './panels.jsx';
17
17
 
18
- const PANELS = ['status', 'board', 'config', 'review'];
18
+ const PANELS = ['flow', 'status', 'board', 'config', 'review'];
19
19
 
20
20
  export function SdlcTab({ stores, t }) {
21
- const [panel, setPanel] = useState('status');
21
+ const [panel, setPanel] = useState('flow');
22
22
  const status = useSyncExternalStore(stores.status.subscribe, stores.status.getSnapshot);
23
23
  const board = useSyncExternalStore(stores.board.subscribe, stores.board.getSnapshot);
24
24
  const config = useSyncExternalStore(stores.config.subscribe, stores.config.getSnapshot);
25
25
  const review = useSyncExternalStore(stores.review.subscribe, stores.review.getSnapshot);
26
+ const flow = useSyncExternalStore(stores.flow.subscribe, stores.flow.getSnapshot);
27
+
28
+ useEffect(() => {
29
+ if (panel !== 'flow') return undefined;
30
+ stores.loadFlow();
31
+ const timer = setInterval(() => { stores.loadFlow(); }, 5000);
32
+ return () => clearInterval(timer);
33
+ }, [panel, stores]);
26
34
 
27
35
  const selectPanel = (p) => setPanel(p);
28
36
  const openTaskReview = (taskId) => {
@@ -34,6 +42,7 @@ export function SdlcTab({ stores, t }) {
34
42
  const reloadConfig = () => stores.loadConfig();
35
43
  const saveConfig = (path, value) => stores.saveConfig(path, value);
36
44
  const loadCompare = (task, a, b) => stores.loadCompare(task, a, b);
45
+ const loadFlow = () => stores.loadFlow();
37
46
 
38
47
  return (
39
48
  <div className="sdlc-root">
@@ -44,12 +53,13 @@ export function SdlcTab({ stores, t }) {
44
53
  role="tab"
45
54
  type="button"
46
55
  aria-selected={panel === p}
47
- onClick={selectPanel}
56
+ onClick={() => selectPanel(p)}
48
57
  >
49
58
  {t(`panel.${p}`)}
50
59
  </button>
51
60
  ))}
52
61
  </div>
62
+ {panel === 'flow' && <FlowPanel snapshot={flow} onReload={loadFlow} onSelectTask={openTaskReview} t={t} />}
53
63
  {panel === 'status' && <StatusPanel snapshot={status} onReload={reloadStatus} />}
54
64
  {panel === 'board' && (
55
65
  <BoardPanel
package/lib/client/api.js CHANGED
@@ -8,8 +8,8 @@
8
8
 
9
9
  import {
10
10
  SDLC_STATUS_ROUTE, SDLC_BOARD_ROUTE, SDLC_TASK_ROUTE, SDLC_COMPARE_ROUTE,
11
- SDLC_CONFIG_ROUTE, SDLC_METRICS_ROUTE,
12
- isStatusPayload, isBoardPayload, isConfigPayload,
11
+ SDLC_CONFIG_ROUTE, SDLC_METRICS_ROUTE, SDLC_FLOW_ROUTE,
12
+ isStatusPayload, isBoardPayload, isConfigPayload, isFlowPayload,
13
13
  } from '../routes.js';
14
14
 
15
15
  async function getJson(path) {
@@ -94,6 +94,15 @@ export async function fetchMetrics() {
94
94
  }
95
95
  }
96
96
 
97
+ export async function fetchFlow({ task = null, events = 100, after = 0 } = {}) {
98
+ const params = new URLSearchParams({ events: String(events), after: String(after) });
99
+ if (task) params.set('task', task);
100
+ const res = await getJson(`${SDLC_FLOW_ROUTE}?${params}`);
101
+ if (!res.available) return res;
102
+ if (!isFlowPayload(res.data)) return { available: false, reason: 'host returned a malformed flow payload' };
103
+ return res;
104
+ }
105
+
97
106
  /**
98
107
  * The single api face the client consumes. NAMED on purpose: the closure-factory
99
108
  * bundle concatenates the client sources into one scope, where a default import
@@ -101,6 +110,6 @@ export async function fetchMetrics() {
101
110
  * apply — shipped broken until the entry contract test called apply). The
102
111
  * default export stays for source-level ergonomics; the bundler strips it.
103
112
  */
104
- export const api = { fetchStatus, fetchBoard, fetchTask, fetchCompare, fetchConfig, writeConfig, fetchMetrics };
113
+ export const api = { fetchStatus, fetchBoard, fetchTask, fetchCompare, fetchConfig, writeConfig, fetchMetrics, fetchFlow };
105
114
 
106
115
  export default api;
@@ -17,6 +17,14 @@ export function sdlcDefinition(t) {
17
17
  kind: SDLC_KIND,
18
18
  priority: 'builtin',
19
19
  title: () => t('tab.title'),
20
+ // The guide is the only user-reachable door for a pure page type: without
21
+ // an entry the type registers but no new session can ever open it.
22
+ guide: [{
23
+ id: 'flow',
24
+ order: 30,
25
+ title: () => t('guide.title'),
26
+ description: () => t('guide.description'),
27
+ }],
20
28
  };
21
29
  }
22
30
 
@@ -0,0 +1,98 @@
1
+ /**
2
+ * packages/sdlc-client-ui/src/client/flow.js — pure geometry/labels for the
3
+ * flow visualizer. No React, no DOM: the panel stays declarative and this can
4
+ * be unit-tested directly.
5
+ */
6
+
7
+ export const STATE_TONE = {
8
+ BACKLOG: 'idle',
9
+ READY: 'ready',
10
+ ASSIGNED: 'running',
11
+ IN_PROGRESS: 'running',
12
+ VERIFYING: 'verify',
13
+ ACCEPTED: 'good',
14
+ MERGING: 'merge',
15
+ DONE: 'good',
16
+ FAILED: 'bad',
17
+ ESCALATED: 'bad',
18
+ BLOCKED: 'warn',
19
+ CANCELLED: 'idle',
20
+ };
21
+
22
+ export function formatDuration(ms) {
23
+ if (ms == null) return '—';
24
+ if (ms < 1000) return `${ms}ms`;
25
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
26
+ return `${(ms / 60_000).toFixed(1)}m`;
27
+ }
28
+
29
+ export function formatTokens(tokens) {
30
+ return tokens == null ? '—' : Number(tokens).toLocaleString('en-US');
31
+ }
32
+
33
+ export function formatCost(cost) {
34
+ return cost == null ? '—' : `$${Number(cost).toFixed(4)}`;
35
+ }
36
+
37
+ /** Convert the host payload into render-ready geometry. */
38
+ export function buildFlowView(data) {
39
+ const nodes = Array.isArray(data?.tasks) ? data.tasks : [];
40
+ const rawEdges = Array.isArray(data?.edges) ? data.edges : [];
41
+ const byLayer = new Map();
42
+ for (const node of nodes) {
43
+ const layer = Number.isFinite(node.layer) ? node.layer : 0;
44
+ if (!byLayer.has(layer)) byLayer.set(layer, []);
45
+ byLayer.get(layer).push(node);
46
+ }
47
+
48
+ const X = 210;
49
+ const Y = 78;
50
+ const placed = new Map();
51
+ let maxPerLayer = 1;
52
+ for (const [layer, list] of [...byLayer.entries()].sort((a, b) => a[0] - b[0])) {
53
+ list.sort((a, b) => String(a.id).localeCompare(String(b.id)));
54
+ maxPerLayer = Math.max(maxPerLayer, list.length);
55
+ list.forEach((node, index) => {
56
+ placed.set(node.id, { ...node, x: 24 + layer * X, y: 24 + index * Y });
57
+ });
58
+ }
59
+
60
+ const edges = rawEdges.map((edge) => {
61
+ const from = placed.get(edge.from);
62
+ const to = placed.get(edge.to);
63
+ if (!from || !to) return null;
64
+ const x1 = from.x + 168;
65
+ const y1 = from.y + 28;
66
+ const x2 = to.x;
67
+ const y2 = to.y + 28;
68
+ const mid = x1 + Math.max(36, (x2 - x1) / 2);
69
+ return {
70
+ ...edge,
71
+ key: `${edge.from}->${edge.to}`,
72
+ d: `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}`,
73
+ bad: Boolean(edge.missing),
74
+ };
75
+ }).filter(Boolean);
76
+
77
+ const layers = [...byLayer.keys()].sort((a, b) => a - b);
78
+ return {
79
+ nodes: [...placed.values()],
80
+ edges,
81
+ layers,
82
+ width: 24 + (layers.length ? (layers.at(-1) + 1) * X : X),
83
+ height: 24 + maxPerLayer * Y,
84
+ runs: Array.isArray(data?.runs) ? data.runs : [],
85
+ routing: Array.isArray(data?.routing) ? data.routing : [],
86
+ gates: Array.isArray(data?.gates) ? data.gates : [],
87
+ gateSummary: data?.gateSummary ?? { pass: 0, fail: 0, skipped: 0, unknown: 0, total: 0 },
88
+ events: Array.isArray(data?.events) ? data.events : [],
89
+ escalations: Array.isArray(data?.escalations) ? data.escalations : [],
90
+ locks: data?.locks ?? { cap: 1, resources: [], pathOwners: [], violations: [] },
91
+ totals: data?.totals ?? {},
92
+ project: data?.project ?? { available: false, reason: 'no payload' },
93
+ unavailable: Array.isArray(data?.unavailable) ? data.unavailable : [],
94
+ tasksTruncated: Boolean(data?.totals?.tasksTruncated),
95
+ };
96
+ }
97
+
98
+ export default { buildFlowView, STATE_TONE, formatDuration, formatTokens, formatCost };
@@ -8,9 +8,9 @@
8
8
  */
9
9
 
10
10
  import { api } from './api.js';
11
- import { createSdlcStores } from './store.js';
11
+ import { createSdlcStores, createSdlcStoreHandle } from './store.js';
12
12
  import { NS, en, zh } from './locales.js';
13
- import { SDLC_ID, sdlcDefinition } from './definition.js';
13
+ import { SDLC_ID, SDLC_KIND, sdlcDefinition } from './definition.js';
14
14
  import { SdlcTab, SdlcTitle } from './SdlcTab.jsx';
15
15
 
16
16
  /** Required browser services: tab registry, keyed seats, Remote, copy. */
@@ -20,18 +20,19 @@ export const inject = ['slots', 'locale', 'sidebarRightTabs', 'sidebarRight', 'r
20
20
  * Client plugin body: register dictionaries, the tab type, and the tab body.
21
21
  * @param ctx - client root context.
22
22
  */
23
- export function apply(ctx) {
23
+ export function apply(ctx, config = {}) {
24
24
  const t = ctx.locale.bind(NS);
25
25
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'sdlc-ui: dictionaries');
26
26
  ctx.effect(() => ctx.sidebarRightTabs.register(sdlcDefinition(t)), 'sdlc-ui: sdlc tab type');
27
27
 
28
28
  const stores = createSdlcStores(api);
29
+ const storeHandle = createSdlcStoreHandle(stores);
29
30
  ctx.effect(() => () => stores.dispose(), 'sdlc-ui: dispose');
30
31
  ctx.on('connection/reset', () => stores.reset());
31
32
 
32
33
  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
33
34
  {
34
- name: 'sidebar.right.pane.tab', key: SDLC_ID, locale: NS, store: stores,
35
+ name: 'sidebar.right.pane.tab', key: SDLC_ID, locale: NS, store: storeHandle,
35
36
  inject: () => ({ stores, t }),
36
37
  },
37
38
  SdlcTab,
@@ -40,6 +41,30 @@ export function apply(ctx) {
40
41
  { name: 'sidebar.right.pane.tab.title', key: SDLC_ID },
41
42
  (props) => SdlcTitle({ ...props, t }),
42
43
  )), 'sdlc-ui: sdlc tab title');
44
+
45
+ // The right sidebar has no surface without a session, and a page type is
46
+ // reachable only through the guide or an explicit openTab. Open the flow
47
+ // panel once per page load for the first session that has an SDLC project,
48
+ // so the visualizer is actually seen (config.autoOpen === false opts out).
49
+ if (config.autoOpen !== false && typeof ctx.sidebarRight?.mounted?.subscribe === 'function') {
50
+ let opened = false;
51
+ let checking = false;
52
+ const maybeOpen = async () => {
53
+ if (opened || checking || !ctx.sidebarRight.mounted.getSnapshot()) return;
54
+ checking = true;
55
+ try {
56
+ const status = await api.fetchStatus();
57
+ if (status.available && status.data.tasks.total > 0) {
58
+ opened = true;
59
+ try { ctx.sidebarRight.openTab(SDLC_KIND); } catch { /* no surface yet */ }
60
+ }
61
+ } finally {
62
+ checking = false;
63
+ }
64
+ };
65
+ const unsubscribe = ctx.sidebarRight.mounted.subscribe(() => { void maybeOpen(); });
66
+ ctx.effect(() => () => { unsubscribe?.(); }, 'sdlc-ui: auto-open flow panel');
67
+ }
43
68
  }
44
69
 
45
70
  // The closure bundle strips this line and re-attaches the same object as
@@ -33,6 +33,31 @@ export const en = {
33
33
  'panel.board': 'Board',
34
34
  'panel.config': 'Config',
35
35
  'panel.review': 'Review',
36
+ 'guide.title': 'SDLC Flow',
37
+ 'guide.description': 'Agents, routing, gates and the live task DAG',
38
+ 'panel.flow': 'Flow',
39
+ 'flow.loading': 'Loading the flow view…',
40
+ 'flow.reload': 'Reload',
41
+ 'flow.tasks': 'tasks',
42
+ 'flow.active': 'active',
43
+ 'flow.estimated': 'est.',
44
+ 'flow.stale': 'Stale snapshot',
45
+ 'flow.truncated': 'Showing the first tasks only; the DAG is truncated.',
46
+ 'flow.empty': 'No tasks yet — the DAG appears once the planner creates work.',
47
+ 'flow.dagLabel': 'Task DAG: {tasks} tasks, {edges} edges',
48
+ 'flow.summary': 'Flow summary',
49
+ 'flow.pass': 'pass',
50
+ 'flow.fail': 'fail',
51
+ 'flow.skipped': 'skipped',
52
+ 'flow.unknown': 'unknown',
53
+ 'flow.subagents': 'Subagents',
54
+ 'flow.noRuns': 'No worker runs yet.',
55
+ 'flow.routing': 'Routing',
56
+ 'flow.gates': 'Gates',
57
+ 'flow.locks': 'Resource locks',
58
+ 'flow.noResources': 'No active resource locks.',
59
+ 'flow.escalations': 'Escalations',
60
+ 'flow.events': 'Recent events',
36
61
  };
37
62
 
38
63
  /** Simplified Chinese dictionary. */
@@ -63,6 +88,31 @@ export const zh = {
63
88
  'panel.board': '看板',
64
89
  'panel.config': '配置',
65
90
  'panel.review': '复核',
91
+ 'guide.title': 'SDLC 流程',
92
+ 'guide.description': '子代理、路由、门禁与实时任务 DAG',
93
+ 'panel.flow': '流程',
94
+ 'flow.loading': '正在加载流程视图…',
95
+ 'flow.reload': '刷新',
96
+ 'flow.tasks': '个任务',
97
+ 'flow.active': '活跃',
98
+ 'flow.estimated': '估算',
99
+ 'flow.stale': '快照已过期',
100
+ 'flow.truncated': '仅显示前部分任务,DAG 已截断。',
101
+ 'flow.empty': '暂无任务 — 规划器创建任务后会出现 DAG。',
102
+ 'flow.dagLabel': '任务 DAG:{tasks} 个任务,{edges} 条边',
103
+ 'flow.summary': '流程摘要',
104
+ 'flow.pass': '通过',
105
+ 'flow.fail': '失败',
106
+ 'flow.skipped': '跳过',
107
+ 'flow.unknown': '未知',
108
+ 'flow.subagents': '子代理',
109
+ 'flow.noRuns': '暂无工作进程运行。',
110
+ 'flow.routing': '路由',
111
+ 'flow.gates': '门禁',
112
+ 'flow.locks': '资源锁',
113
+ 'flow.noResources': '没有活跃的资源锁。',
114
+ 'flow.escalations': '升级',
115
+ 'flow.events': '最近事件',
66
116
  };
67
117
 
68
118
  export default { NS, en, zh };