@worca/ui 0.1.0-rc.6 → 0.1.1-rc.2

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/app/styles.css CHANGED
@@ -1735,10 +1735,20 @@ sl-details.log-history-panel::part(content) {
1735
1735
  }
1736
1736
 
1737
1737
  .pricing-table sl-input {
1738
- min-width: 90px;
1739
- max-width: 130px;
1738
+ min-width: 72px;
1739
+ max-width: 104px;
1740
+ display: inline-block;
1741
+ }
1742
+
1743
+ sl-input.pricing-input::part(input) {
1744
+ text-align: right;
1740
1745
  }
1741
1746
 
1747
+ .pricing-table--auto {
1748
+ width: auto;
1749
+ }
1750
+
1751
+
1742
1752
  .pricing-info {
1743
1753
  display: flex;
1744
1754
  gap: 16px;
@@ -3160,6 +3170,19 @@ sl-details.learnings-panel::part(content) {
3160
3170
  color: var(--muted);
3161
3171
  }
3162
3172
 
3173
+ .cost-badge {
3174
+ display: inline-flex;
3175
+ align-items: center;
3176
+ gap: 2px;
3177
+ font-size: 0.7rem;
3178
+ padding: 1px 4px;
3179
+ border-radius: 4px;
3180
+ background: var(--bg-tertiary);
3181
+ color: var(--muted);
3182
+ margin-left: 4px;
3183
+ vertical-align: middle;
3184
+ }
3185
+
3163
3186
  /* Preflight checks view */
3164
3187
 
3165
3188
  .preflight-checks-view {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.1.0-rc.6",
3
+ "version": "0.1.1-rc.2",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -56,7 +56,6 @@
56
56
  "@xterm/addon-fit": "^0.11.0",
57
57
  "@xterm/addon-search": "^0.16.0",
58
58
  "@xterm/xterm": "^6.0.0",
59
- "better-sqlite3": "^12.6.2",
60
59
  "express": "^5.2.1",
61
60
  "lit-html": "^3.3.1",
62
61
  "lucide": "^0.577.0",
@@ -1,199 +1,123 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import { existsSync } from 'node:fs';
2
- import Database from 'better-sqlite3';
3
+
4
+ function runBd(args, dbPath) {
5
+ const fullArgs = [...args, '--json', '--db', dbPath, '--readonly'];
6
+ const stdout = execFileSync('bd', fullArgs, {
7
+ encoding: 'utf8',
8
+ timeout: 10000,
9
+ maxBuffer: 10 * 1024 * 1024,
10
+ stdio: ['ignore', 'pipe', 'pipe'],
11
+ });
12
+ return JSON.parse(stdout);
13
+ }
14
+
15
+ function transformIssue(issue, deps) {
16
+ const depends_on = (deps || []).map((d) => d.id);
17
+ const blocked_by = (deps || [])
18
+ .filter((d) => d.status !== 'closed' && d.status !== 'tombstone')
19
+ .map((d) => d.id);
20
+ return {
21
+ id: issue.id,
22
+ title: issue.title,
23
+ body: issue.description || '',
24
+ status: issue.status,
25
+ priority: issue.priority,
26
+ created_at: issue.created_at || '',
27
+ external_ref: issue.external_ref || null,
28
+ depends_on,
29
+ blocked_by,
30
+ };
31
+ }
32
+
33
+ function enrichWithDeps(issues, dbPath) {
34
+ const needDeps = issues.filter((i) => i.dependency_count > 0);
35
+ if (needDeps.length === 0) {
36
+ return issues.map((i) => transformIssue(i, []));
37
+ }
38
+ const detailed = runBd(['show', ...needDeps.map((i) => i.id)], dbPath);
39
+ const depMap = new Map(detailed.map((d) => [d.id, d.dependencies || []]));
40
+ return issues.map((i) => transformIssue(i, depMap.get(i.id) || []));
41
+ }
3
42
 
4
43
  export function dbExists(beadsDb) {
5
44
  return existsSync(beadsDb);
6
45
  }
7
46
 
8
47
  export function listIssues(beadsDb) {
9
- let db;
10
48
  try {
11
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
12
- const rows = db
13
- .prepare(
14
- `SELECT id, title, description AS body, status, priority, created_at, external_ref
15
- FROM issues
16
- WHERE status NOT IN ('closed','tombstone')
17
- ORDER BY priority ASC, id ASC`,
18
- )
19
- .all();
20
-
21
- const depStmt = db.prepare(
22
- `SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
23
- );
24
- const statusMap = new Map(rows.map((r) => [r.id, r.status]));
25
-
26
- return rows.map((row) => {
27
- const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
28
- const blocked_by = depends_on.filter((depId) => statusMap.has(depId));
29
- return { ...row, depends_on, blocked_by };
30
- });
49
+ const issues = runBd(['list', '--limit', '0'], beadsDb);
50
+ return enrichWithDeps(issues, beadsDb);
31
51
  } catch {
32
52
  return [];
33
- } finally {
34
- try {
35
- db?.close();
36
- } catch {
37
- /* ignore */
38
- }
39
53
  }
40
54
  }
41
55
 
42
56
  export function listIssuesByLabel(beadsDb, label) {
43
- let db;
44
57
  try {
45
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
46
- const rows = db
47
- .prepare(
48
- `SELECT i.id, i.title, i.description AS body, i.status, i.priority, i.created_at
49
- FROM issues i
50
- JOIN labels l ON l.issue_id = i.id
51
- WHERE l.label = ?
52
- ORDER BY i.priority ASC, i.id ASC`,
53
- )
54
- .all(label);
55
-
56
- const depStmt = db.prepare(
57
- `SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
58
+ const issues = runBd(
59
+ ['list', '--label-any', label, '--all', '--limit', '0'],
60
+ beadsDb,
58
61
  );
59
- const statusMap = new Map(rows.map((r) => [r.id, r.status]));
60
-
61
- return rows.map((row) => {
62
- const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
63
- const blocked_by = depends_on.filter((depId) => {
64
- const s = statusMap.get(depId);
65
- return s && s !== 'closed';
66
- });
67
- return { ...row, depends_on, blocked_by };
68
- });
62
+ return enrichWithDeps(issues, beadsDb);
69
63
  } catch {
70
64
  return [];
71
- } finally {
72
- try {
73
- db?.close();
74
- } catch {
75
- /* ignore */
76
- }
77
65
  }
78
66
  }
79
67
 
80
68
  export function listUnlinkedIssues(beadsDb) {
81
- let db;
82
69
  try {
83
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
84
- const rows = db
85
- .prepare(
86
- `SELECT i.id, i.title, i.description AS body, i.status, i.priority, i.created_at
87
- FROM issues i
88
- WHERE NOT EXISTS (
89
- SELECT 1 FROM labels l WHERE l.issue_id = i.id AND l.label LIKE 'run:%'
90
- )
91
- AND i.status NOT IN ('closed','tombstone')
92
- ORDER BY i.priority ASC, i.id ASC`,
93
- )
94
- .all();
95
-
96
- const depStmt = db.prepare(
97
- `SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
98
- );
99
- const statusMap = new Map(rows.map((r) => [r.id, r.status]));
100
-
101
- return rows.map((row) => {
102
- const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
103
- const blocked_by = depends_on.filter((depId) => statusMap.has(depId));
104
- return { ...row, depends_on, blocked_by };
70
+ const issues = runBd(['list', '--limit', '0'], beadsDb);
71
+ if (issues.length === 0) return [];
72
+ // bd list doesn't include labels — use bd show to get them
73
+ const detailed = runBd(['show', ...issues.map((i) => i.id)], beadsDb);
74
+ const detailMap = new Map(detailed.map((d) => [d.id, d]));
75
+ const unlinked = issues.filter((i) => {
76
+ const d = detailMap.get(i.id);
77
+ const labels = d?.labels || [];
78
+ return !labels.some((l) => l.startsWith('run:'));
79
+ });
80
+ // detailed already has dependencies, use them directly
81
+ return unlinked.map((i) => {
82
+ const d = detailMap.get(i.id);
83
+ return transformIssue(i, d?.dependencies || []);
105
84
  });
106
85
  } catch {
107
86
  return [];
108
- } finally {
109
- try {
110
- db?.close();
111
- } catch {
112
- /* ignore */
113
- }
114
87
  }
115
88
  }
116
89
 
117
90
  export function countIssuesByRunLabel(beadsDb) {
118
- let db;
119
91
  try {
120
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
121
- const rows = db
122
- .prepare(
123
- `SELECT l.label, COUNT(*) AS count FROM labels l
124
- WHERE l.label LIKE 'run:%' GROUP BY l.label`,
125
- )
126
- .all();
92
+ const rows = runBd(['label', 'list-all'], beadsDb);
127
93
  const counts = {};
128
94
  for (const row of rows) {
129
- const runId = row.label.replace('run:', '');
130
- counts[runId] = row.count;
95
+ if (row.label.startsWith('run:')) {
96
+ counts[row.label.replace('run:', '')] = row.count;
97
+ }
131
98
  }
132
99
  return counts;
133
100
  } catch {
134
101
  return {};
135
- } finally {
136
- try {
137
- db?.close();
138
- } catch {
139
- /* ignore */
140
- }
141
102
  }
142
103
  }
143
104
 
144
105
  export function listDistinctRunLabels(beadsDb) {
145
- let db;
146
106
  try {
147
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
148
- const rows = db
149
- .prepare(`SELECT DISTINCT label FROM labels WHERE label LIKE 'run:%'`)
150
- .all();
151
- return rows.map((r) => r.label);
107
+ const rows = runBd(['label', 'list-all'], beadsDb);
108
+ return rows.filter((r) => r.label.startsWith('run:')).map((r) => r.label);
152
109
  } catch {
153
110
  return [];
154
- } finally {
155
- try {
156
- db?.close();
157
- } catch {
158
- /* ignore */
159
- }
160
111
  }
161
112
  }
162
113
 
163
114
  export function getIssue(beadsDb, id) {
164
- let db;
165
115
  try {
166
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
167
- const row = db
168
- .prepare(
169
- `SELECT id, title, description AS body, status, priority, created_at, external_ref
170
- FROM issues WHERE id = ?`,
171
- )
172
- .get(id);
173
- if (!row) return null;
174
-
175
- const depends_on = db
176
- .prepare(`SELECT depends_on_id FROM dependencies WHERE issue_id = ?`)
177
- .all(id)
178
- .map((d) => d.depends_on_id);
179
-
180
- const blocked_by = [];
181
- for (const depId of depends_on) {
182
- const dep = db
183
- .prepare(`SELECT status FROM issues WHERE id = ?`)
184
- .get(depId);
185
- if (dep && dep.status !== 'closed' && dep.status !== 'tombstone') {
186
- blocked_by.push(depId);
187
- }
188
- }
189
- return { ...row, depends_on, blocked_by };
116
+ const results = runBd(['show', id], beadsDb);
117
+ if (!results || results.length === 0) return null;
118
+ const issue = results[0];
119
+ return transformIssue(issue, issue.dependencies || []);
190
120
  } catch {
191
121
  return null;
192
- } finally {
193
- try {
194
- db?.close();
195
- } catch {
196
- /* ignore */
197
- }
198
122
  }
199
123
  }
@@ -676,21 +676,17 @@ export function createProjectScopedRoutes() {
676
676
  res.json({ ok: true, stopped: true, runId, pid: result.pid });
677
677
  } catch (err) {
678
678
  if (err.code === 'not_running') {
679
- const statusPath = join(
680
- req.project.worcaDir,
681
- 'runs',
682
- runId,
683
- 'status.json',
684
- );
685
- if (existsSync(statusPath)) {
679
+ const statusPath = findRunStatusPath(req.project.worcaDir, runId);
680
+ if (statusPath) {
686
681
  try {
687
682
  const st = JSON.parse(readFileSync(statusPath, 'utf8'));
688
683
  if (
689
684
  st.pipeline_status === 'paused' ||
690
685
  st.pipeline_status === 'running'
691
686
  ) {
692
- st.pipeline_status = 'failed';
693
- st.stop_reason = 'stopped';
687
+ st.pipeline_status = 'cancelled';
688
+ st.stop_reason = 'force_cancelled';
689
+ st.completed_at = new Date().toISOString();
694
690
  writeFileSync(
695
691
  statusPath,
696
692
  `${JSON.stringify(st, null, 2)}\n`,
@@ -710,6 +706,39 @@ export function createProjectScopedRoutes() {
710
706
  }
711
707
  });
712
708
 
709
+ // POST /api/projects/:projectId/runs/:id/cancel — force-cancel a stale run
710
+ router.post('/runs/:id/cancel', requireWorcaDir, (req, res) => {
711
+ const runId = req.params.id;
712
+ if (!validateRunId(runId)) {
713
+ return res.status(400).json({ ok: false, error: 'Invalid runId' });
714
+ }
715
+ const { worcaDir } = req.project;
716
+ const statusPath = findRunStatusPath(worcaDir, runId);
717
+ if (!statusPath) {
718
+ return res
719
+ .status(404)
720
+ .json({ ok: false, error: `Run "${runId}" not found` });
721
+ }
722
+ try {
723
+ const st = JSON.parse(readFileSync(statusPath, 'utf8'));
724
+ if (
725
+ st.pipeline_status === 'completed' ||
726
+ st.pipeline_status === 'cancelled'
727
+ ) {
728
+ return res.json({ ok: true, already: st.pipeline_status });
729
+ }
730
+ st.pipeline_status = 'cancelled';
731
+ st.stop_reason = 'force_cancelled';
732
+ st.completed_at = new Date().toISOString();
733
+ writeFileSync(statusPath, `${JSON.stringify(st, null, 2)}\n`, 'utf8');
734
+ const { broadcast } = req.app.locals;
735
+ if (broadcast) broadcast('run-stopped', { runId, pid: null });
736
+ res.json({ ok: true, cancelled: true, runId });
737
+ } catch (err) {
738
+ res.status(500).json({ ok: false, error: err.message });
739
+ }
740
+ });
741
+
713
742
  // POST /api/projects/:projectId/runs/:id/archive
714
743
  router.post('/runs/:id/archive', requireWorcaDir, (req, res) => {
715
744
  const runId = req.params.id;
@@ -1183,20 +1212,28 @@ export function createProjectScopedRoutes() {
1183
1212
  let inputTokens = 0,
1184
1213
  outputTokens = 0,
1185
1214
  cacheReadInputTokens = 0,
1186
- cacheCreationInputTokens = 0;
1215
+ cacheCreationInputTokens = 0,
1216
+ webSearchRequests = 0;
1187
1217
  const models = [];
1188
1218
  for (const [model, usage] of Object.entries(mu)) {
1189
1219
  inputTokens += usage.inputTokens || 0;
1190
1220
  outputTokens += usage.outputTokens || 0;
1191
1221
  cacheReadInputTokens += usage.cacheReadInputTokens || 0;
1192
1222
  cacheCreationInputTokens += usage.cacheCreationInputTokens || 0;
1223
+ webSearchRequests += usage.webSearchRequests || 0;
1193
1224
  models.push(model);
1194
1225
  }
1226
+ const cacheCreation = data.usage?.cache_creation || {};
1195
1227
  iters.push({
1196
1228
  inputTokens,
1197
1229
  outputTokens,
1198
1230
  cacheReadInputTokens,
1199
1231
  cacheCreationInputTokens,
1232
+ webSearchRequests,
1233
+ cacheEphemeral1hTokens:
1234
+ cacheCreation.ephemeral_1h_input_tokens || 0,
1235
+ cacheEphemeral5mTokens:
1236
+ cacheCreation.ephemeral_5m_input_tokens || 0,
1200
1237
  models,
1201
1238
  });
1202
1239
  } catch {
@@ -1,11 +1,9 @@
1
1
  // server/versions.js — version fetching + caching for worca-cc and @worca/ui
2
+ import { execFileSync } from 'node:child_process';
2
3
  import { readFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
4
+ import { join } from 'node:path';
5
5
  import { readPreferences } from './preferences.js';
6
6
 
7
- const __dirname = dirname(fileURLToPath(import.meta.url));
8
-
9
7
  /** Cache: { data, timestamp } */
10
8
  let _cache = null;
11
9
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
@@ -173,15 +171,19 @@ export function getDevPathVersions(sourceRepo) {
173
171
  }
174
172
 
175
173
  /**
176
- * Get installed @worca/ui version from own package.json.
174
+ * Get globally installed @worca/ui version via npm.
175
+ * Falls back to own package.json if npm query fails.
177
176
  * @returns {string|null}
178
177
  */
179
178
  function getInstalledUiVersion() {
180
179
  try {
181
- const pkg = JSON.parse(
182
- readFileSync(join(__dirname, '..', 'package.json'), 'utf8'),
183
- );
184
- return pkg.version || null;
180
+ const output = execFileSync('npm', ['list', '-g', '@worca/ui', '--json'], {
181
+ encoding: 'utf8',
182
+ timeout: 5000,
183
+ stdio: ['pipe', 'pipe', 'pipe'],
184
+ });
185
+ const data = JSON.parse(output);
186
+ return data.dependencies?.['@worca/ui']?.version || null;
185
187
  } catch {
186
188
  return null;
187
189
  }
@@ -8,7 +8,7 @@ import { existsSync, watch } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
9
  import { listIssues } from './beads-reader.js';
10
10
 
11
- const BEADS_DEBOUNCE_MS = 200;
11
+ const BEADS_DEBOUNCE_MS = 300;
12
12
 
13
13
  /**
14
14
  * @param {{ worcaDir: string, broadcaster: { broadcast: Function }, projectId?: string }} deps