@worca/ui 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/main.bundle.js +1245 -684
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +194 -19
- package/package.json +1 -1
- package/server/app.js +58 -2
- package/server/dispatch-events-aggregator.js +161 -0
- package/server/project-registry.js +37 -0
- package/server/project-routes.js +132 -5
- package/server/settings-validator.js +29 -2
- package/server/subagents-discovery.js +116 -0
- package/server/version-check.js +35 -0
- package/server/watcher.js +37 -10
- package/server/worca-setup.js +15 -1
- package/server/ws-modular.js +6 -2
package/app/styles.css
CHANGED
|
@@ -714,7 +714,7 @@ h1, h2, h3, h4, h5, h6 {
|
|
|
714
714
|
|
|
715
715
|
.stage-node.status-in-progress .stage-icon {
|
|
716
716
|
border-color: var(--status-in-progress);
|
|
717
|
-
box-shadow: 0 0 0 4px rgba(
|
|
717
|
+
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15);
|
|
718
718
|
}
|
|
719
719
|
|
|
720
720
|
.stage-node.status-completed .stage-icon {
|
|
@@ -1539,6 +1539,11 @@ sl-details.log-history-panel::part(content) {
|
|
|
1539
1539
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
1540
1540
|
gap: 14px;
|
|
1541
1541
|
}
|
|
1542
|
+
/* Worca Versions panels — 50% wider min column (200 → 300) to fit longer
|
|
1543
|
+
version strings (e.g. RCs + local-repo dirty markers) without wrapping */
|
|
1544
|
+
.settings-grid--versions {
|
|
1545
|
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
1546
|
+
}
|
|
1542
1547
|
|
|
1543
1548
|
.settings-switches {
|
|
1544
1549
|
display: flex;
|
|
@@ -1648,8 +1653,8 @@ sl-details.log-history-panel::part(content) {
|
|
|
1648
1653
|
|
|
1649
1654
|
.settings-dispatch-row {
|
|
1650
1655
|
display: grid;
|
|
1651
|
-
grid-template-columns: 120px 1fr;
|
|
1652
|
-
align-items:
|
|
1656
|
+
grid-template-columns: 120px 1fr auto;
|
|
1657
|
+
align-items: start;
|
|
1653
1658
|
gap: 12px;
|
|
1654
1659
|
}
|
|
1655
1660
|
|
|
@@ -1658,6 +1663,97 @@ sl-details.log-history-panel::part(content) {
|
|
|
1658
1663
|
font-weight: 500;
|
|
1659
1664
|
color: var(--fg);
|
|
1660
1665
|
text-transform: capitalize;
|
|
1666
|
+
padding-top: 6px;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/* Tag input component */
|
|
1670
|
+
.dispatch-tag-input-wrapper {
|
|
1671
|
+
position: relative;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
.dispatch-tag-input {
|
|
1675
|
+
display: flex;
|
|
1676
|
+
flex-wrap: wrap;
|
|
1677
|
+
gap: 4px;
|
|
1678
|
+
align-items: center;
|
|
1679
|
+
padding: 4px 8px;
|
|
1680
|
+
border: 1px solid var(--sl-color-neutral-300);
|
|
1681
|
+
border-radius: var(--sl-border-radius-medium);
|
|
1682
|
+
min-height: 32px;
|
|
1683
|
+
cursor: text;
|
|
1684
|
+
background: var(--sl-color-neutral-0);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
.dispatch-tag-input:focus-within {
|
|
1688
|
+
border-color: var(--sl-color-primary-500);
|
|
1689
|
+
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-primary-200);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
.dispatch-tag-input-field {
|
|
1693
|
+
border: none;
|
|
1694
|
+
outline: none;
|
|
1695
|
+
flex: 1;
|
|
1696
|
+
min-width: 60px;
|
|
1697
|
+
font-size: var(--sl-font-size-small);
|
|
1698
|
+
background: transparent;
|
|
1699
|
+
color: var(--fg);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
.dispatch-suggestions {
|
|
1703
|
+
position: absolute;
|
|
1704
|
+
z-index: 100;
|
|
1705
|
+
top: calc(100% + 2px);
|
|
1706
|
+
left: 0;
|
|
1707
|
+
right: 0;
|
|
1708
|
+
background: var(--sl-color-neutral-0);
|
|
1709
|
+
border: 1px solid var(--sl-color-neutral-200);
|
|
1710
|
+
border-radius: var(--sl-border-radius-medium);
|
|
1711
|
+
box-shadow: var(--sl-shadow-large);
|
|
1712
|
+
max-height: 200px;
|
|
1713
|
+
overflow-y: auto;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
.dispatch-suggestions .item {
|
|
1717
|
+
padding: 6px 12px;
|
|
1718
|
+
cursor: pointer;
|
|
1719
|
+
font-size: var(--sl-font-size-small);
|
|
1720
|
+
display: flex;
|
|
1721
|
+
justify-content: space-between;
|
|
1722
|
+
align-items: center;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
.dispatch-suggestions .item:hover,
|
|
1726
|
+
.dispatch-suggestions .item.active {
|
|
1727
|
+
background: var(--sl-color-primary-50);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
.dispatch-suggestions .item.denied {
|
|
1731
|
+
opacity: 0.5;
|
|
1732
|
+
text-decoration: line-through;
|
|
1733
|
+
cursor: not-allowed;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
.dispatch-suggestions .item-label {
|
|
1737
|
+
font-size: 11px;
|
|
1738
|
+
color: var(--sl-color-neutral-500);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
.dispatch-suggestions .group-label {
|
|
1742
|
+
padding: 4px 12px;
|
|
1743
|
+
font-size: 11px;
|
|
1744
|
+
color: var(--sl-color-neutral-500);
|
|
1745
|
+
text-transform: uppercase;
|
|
1746
|
+
letter-spacing: 0.05em;
|
|
1747
|
+
background: var(--sl-color-neutral-50);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
.dispatch-reset-btn {
|
|
1751
|
+
padding-top: 2px;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
.dispatch-reset-placeholder {
|
|
1755
|
+
width: 28px;
|
|
1756
|
+
display: inline-block;
|
|
1661
1757
|
}
|
|
1662
1758
|
|
|
1663
1759
|
/* Permissions list */
|
|
@@ -1823,27 +1919,22 @@ sl-input.pricing-input::part(input) {
|
|
|
1823
1919
|
color: var(--muted);
|
|
1824
1920
|
}
|
|
1825
1921
|
|
|
1826
|
-
|
|
1827
|
-
|
|
1922
|
+
/* --- Iteration Tags Row (trigger, outcome, classification, subagents) --- */
|
|
1923
|
+
.iteration-tags-row {
|
|
1924
|
+
display: flex;
|
|
1925
|
+
flex-wrap: wrap;
|
|
1828
1926
|
align-items: center;
|
|
1829
|
-
gap:
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
font-size: 11px;
|
|
1833
|
-
font-weight: 500;
|
|
1834
|
-
background: var(--bg-tertiary);
|
|
1927
|
+
gap: 6px;
|
|
1928
|
+
margin-top: 6px;
|
|
1929
|
+
font-size: 13px;
|
|
1835
1930
|
}
|
|
1836
1931
|
|
|
1837
|
-
.iteration-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
font-weight: 500;
|
|
1932
|
+
.iteration-tags-sep {
|
|
1933
|
+
color: var(--fg);
|
|
1934
|
+
opacity: 0.7;
|
|
1935
|
+
margin: 0 2px;
|
|
1842
1936
|
}
|
|
1843
1937
|
|
|
1844
|
-
.iteration-outcome.success { color: var(--status-completed); }
|
|
1845
|
-
.iteration-outcome.failure { color: var(--status-error); }
|
|
1846
|
-
|
|
1847
1938
|
/* --- Agent Prompt Section --- */
|
|
1848
1939
|
sl-details.agent-prompt-section {
|
|
1849
1940
|
margin-top: 12px;
|
|
@@ -4143,3 +4234,87 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
4143
4234
|
text-overflow: ellipsis;
|
|
4144
4235
|
}
|
|
4145
4236
|
|
|
4237
|
+
/* ─── Add Project / Worca Setup dialog shared styles ─────────────────── */
|
|
4238
|
+
.dialog-meta-row {
|
|
4239
|
+
display: flex;
|
|
4240
|
+
align-items: center;
|
|
4241
|
+
gap: 0.5rem;
|
|
4242
|
+
flex-wrap: wrap;
|
|
4243
|
+
font-size: 0.85rem;
|
|
4244
|
+
color: var(--sl-color-neutral-700);
|
|
4245
|
+
margin-bottom: 0.5rem;
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
.dialog-meta-row .sep {
|
|
4249
|
+
color: var(--sl-color-neutral-400);
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4252
|
+
.dialog-meta-row .spacer {
|
|
4253
|
+
flex: 1;
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
.dialog-section-heading {
|
|
4257
|
+
margin: 0.75rem 0 0.5rem;
|
|
4258
|
+
font-size: 0.95rem;
|
|
4259
|
+
font-weight: 600;
|
|
4260
|
+
color: var(--sl-color-neutral-900);
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4263
|
+
.dialog-list {
|
|
4264
|
+
max-height: 300px;
|
|
4265
|
+
overflow-y: auto;
|
|
4266
|
+
border: 1px solid var(--sl-color-neutral-200);
|
|
4267
|
+
border-radius: 4px;
|
|
4268
|
+
padding: 6px 8px;
|
|
4269
|
+
}
|
|
4270
|
+
|
|
4271
|
+
.dialog-list-row {
|
|
4272
|
+
display: flex;
|
|
4273
|
+
align-items: center;
|
|
4274
|
+
gap: 8px;
|
|
4275
|
+
padding: 4px 0;
|
|
4276
|
+
min-height: 28px;
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
.dialog-list-row + .dialog-list-row {
|
|
4280
|
+
border-top: 1px solid var(--sl-color-neutral-100);
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
.dialog-list-row.is-terminal .dialog-list-row-name {
|
|
4284
|
+
opacity: 0.6;
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
.dialog-list-row-icon {
|
|
4288
|
+
flex: 0 0 auto;
|
|
4289
|
+
width: 20px;
|
|
4290
|
+
display: inline-flex;
|
|
4291
|
+
align-items: center;
|
|
4292
|
+
justify-content: center;
|
|
4293
|
+
font-weight: 700;
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
.dialog-list-row-icon.is-success {
|
|
4297
|
+
color: var(--sl-color-success-600);
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
.dialog-list-row-icon.is-failed {
|
|
4301
|
+
color: var(--sl-color-danger-600);
|
|
4302
|
+
}
|
|
4303
|
+
|
|
4304
|
+
.dialog-list-row-name {
|
|
4305
|
+
flex: 0 0 auto;
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4308
|
+
.dialog-list-row-error {
|
|
4309
|
+
color: var(--sl-color-danger-600);
|
|
4310
|
+
font-size: 0.8rem;
|
|
4311
|
+
margin-left: 0.25rem;
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4314
|
+
.dialog-footer {
|
|
4315
|
+
display: flex;
|
|
4316
|
+
justify-content: center;
|
|
4317
|
+
gap: 0.75rem;
|
|
4318
|
+
width: 100%;
|
|
4319
|
+
}
|
|
4320
|
+
|
package/package.json
CHANGED
package/server/app.js
CHANGED
|
@@ -2,17 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import { createHmac, randomUUID } from 'node:crypto';
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
6
8
|
import { fileURLToPath } from 'node:url';
|
|
7
9
|
import express from 'express';
|
|
8
10
|
|
|
9
11
|
import { dbExists, getIssue, listIssues } from './beads-reader.js';
|
|
10
12
|
import { ProcessManager } from './process-manager.js';
|
|
13
|
+
import { scanDirectory } from './project-registry.js';
|
|
11
14
|
import {
|
|
12
15
|
createProjectRoutes,
|
|
13
16
|
createProjectScopedRoutes,
|
|
14
17
|
projectResolver,
|
|
15
18
|
} from './project-routes.js';
|
|
19
|
+
import { discoverSubagents } from './subagents-discovery.js';
|
|
16
20
|
import { getVersionInfo } from './versions.js';
|
|
17
21
|
import { createInbox } from './webhook-inbox.js';
|
|
18
22
|
|
|
@@ -20,6 +24,9 @@ export function createApp(options = {}) {
|
|
|
20
24
|
const app = express();
|
|
21
25
|
const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
|
|
22
26
|
const { settingsPath, worcaDir, projectRoot, prefsDir } = options;
|
|
27
|
+
// subagentDirs is a test-injection seam; production calls omit it and we
|
|
28
|
+
// resolve from homedir() + projectRoot.
|
|
29
|
+
const subagentDirs = options.subagentDirs || null;
|
|
23
30
|
|
|
24
31
|
app.use(express.json());
|
|
25
32
|
|
|
@@ -92,6 +99,31 @@ export function createApp(options = {}) {
|
|
|
92
99
|
|
|
93
100
|
// ─── Unique routes (not in project-scoped router) ──────────────────────
|
|
94
101
|
|
|
102
|
+
// GET /api/subagents — list discoverable subagent types for the dispatch editor.
|
|
103
|
+
// Walks ~/.claude/agents/ (user-global), ~/.claude/plugins/cache/
|
|
104
|
+
// (plugin-cached), and the active project's .claude/agents/ (in single-project
|
|
105
|
+
// mode). Tests inject alternate dirs via createApp({ subagentDirs: {...} }).
|
|
106
|
+
app.get('/api/subagents', (_req, res) => {
|
|
107
|
+
try {
|
|
108
|
+
const userDir =
|
|
109
|
+
subagentDirs?.userDir ?? join(homedir(), '.claude', 'agents');
|
|
110
|
+
const pluginCacheDir =
|
|
111
|
+
subagentDirs?.pluginCacheDir ??
|
|
112
|
+
join(homedir(), '.claude', 'plugins', 'cache');
|
|
113
|
+
const projectAgentsDir =
|
|
114
|
+
subagentDirs?.projectAgentsDir ??
|
|
115
|
+
(projectRoot ? join(projectRoot, '.claude', 'agents') : undefined);
|
|
116
|
+
const subagents = discoverSubagents({
|
|
117
|
+
userDir,
|
|
118
|
+
pluginCacheDir,
|
|
119
|
+
projectAgentsDir,
|
|
120
|
+
});
|
|
121
|
+
res.json({ ok: true, subagents });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
95
127
|
// GET /api/beads/issues
|
|
96
128
|
app.get('/api/beads/issues', (_req, res) => {
|
|
97
129
|
if (!worcaDir)
|
|
@@ -404,6 +436,30 @@ export function createApp(options = {}) {
|
|
|
404
436
|
}
|
|
405
437
|
});
|
|
406
438
|
|
|
439
|
+
// POST /api/scan-directory — scan parent folder for immediate git subdirectories
|
|
440
|
+
app.post('/api/scan-directory', async (req, res) => {
|
|
441
|
+
const { path: dirPath } = req.body || {};
|
|
442
|
+
if (!dirPath || typeof dirPath !== 'string') {
|
|
443
|
+
return res.status(400).json({ ok: false, error: 'path is required' });
|
|
444
|
+
}
|
|
445
|
+
if (!isAbsolute(dirPath)) {
|
|
446
|
+
return res
|
|
447
|
+
.status(400)
|
|
448
|
+
.json({ ok: false, error: 'path must be absolute' });
|
|
449
|
+
}
|
|
450
|
+
if (!existsSync(dirPath)) {
|
|
451
|
+
return res
|
|
452
|
+
.status(400)
|
|
453
|
+
.json({ ok: false, error: `directory does not exist: ${dirPath}` });
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
const subfolders = await scanDirectory(dirPath);
|
|
457
|
+
res.json({ ok: true, subfolders });
|
|
458
|
+
} catch (err) {
|
|
459
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
407
463
|
// GET /api/versions — installed + registry version info
|
|
408
464
|
app.get('/api/versions', async (req, res) => {
|
|
409
465
|
const force = req.query.force === '1';
|
|
@@ -423,7 +479,7 @@ export function createApp(options = {}) {
|
|
|
423
479
|
app.use(
|
|
424
480
|
'/api/projects/:projectId',
|
|
425
481
|
projectResolver({ prefsDir, projectRoot }),
|
|
426
|
-
createProjectScopedRoutes(),
|
|
482
|
+
createProjectScopedRoutes({ prefsDir }),
|
|
427
483
|
);
|
|
428
484
|
}
|
|
429
485
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch-event aggregator — reads pipeline.hook.dispatch_{allowed,blocked}
|
|
3
|
+
* events from a run's events.jsonl and assigns them to the matching iteration
|
|
4
|
+
* in status.json by timestamp range.
|
|
5
|
+
*
|
|
6
|
+
* Works for both live and completed runs because it reads only persisted data
|
|
7
|
+
* (events.jsonl is append-only and survives the pipeline process exiting).
|
|
8
|
+
*
|
|
9
|
+
* Aggregation: events are deduplicated per iteration by (type, subagent_type).
|
|
10
|
+
* A `count` field tracks how many times the same (type, subagent_type) fired
|
|
11
|
+
* in that iteration. The `reason` from the first occurrence is kept (reasons
|
|
12
|
+
* for the same key are deterministic — derived from the denylist/rule check).
|
|
13
|
+
*
|
|
14
|
+
* Output shape per iteration:
|
|
15
|
+
* dispatch_events: [
|
|
16
|
+
* { type, subagent_type, reason?, count }
|
|
17
|
+
* ]
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
|
|
22
|
+
const DISPATCH_EVENT_TYPES = new Set([
|
|
23
|
+
'pipeline.hook.dispatch_allowed',
|
|
24
|
+
'pipeline.hook.dispatch_blocked',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse events.jsonl and return only the dispatch events, with normalised shape.
|
|
29
|
+
* Malformed lines are silently skipped so a corrupt event doesn't break the run view.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} eventsPath — absolute path to events.jsonl
|
|
32
|
+
* @returns {Array<{type, subagent_type, reason?, timestamp}>}
|
|
33
|
+
*/
|
|
34
|
+
export function readDispatchEventsFromJsonl(eventsPath) {
|
|
35
|
+
if (!eventsPath || !existsSync(eventsPath)) return [];
|
|
36
|
+
let content;
|
|
37
|
+
try {
|
|
38
|
+
content = readFileSync(eventsPath, 'utf8');
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const line of content.split('\n')) {
|
|
44
|
+
if (!line.trim()) continue;
|
|
45
|
+
let e;
|
|
46
|
+
try {
|
|
47
|
+
e = JSON.parse(line);
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!DISPATCH_EVENT_TYPES.has(e.event_type)) continue;
|
|
52
|
+
const payload = e.payload || {};
|
|
53
|
+
if (!payload.subagent_type) continue;
|
|
54
|
+
out.push({
|
|
55
|
+
type: e.event_type,
|
|
56
|
+
subagent_type: payload.subagent_type,
|
|
57
|
+
reason: payload.reason,
|
|
58
|
+
timestamp: e.timestamp,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Given a list of dispatch events and a stages map from status.json, return
|
|
66
|
+
* a new stages map where each iteration that overlaps an event's timestamp
|
|
67
|
+
* is enriched with a `dispatch_events` array (deduplicated by type+subagent_type
|
|
68
|
+
* with a count).
|
|
69
|
+
*
|
|
70
|
+
* Non-destructive: input stages object is shallow-copied; iterations get new
|
|
71
|
+
* objects with the extra field. Existing iteration fields are preserved.
|
|
72
|
+
*
|
|
73
|
+
* @param {Array<{type, subagent_type, reason?, timestamp}>} events
|
|
74
|
+
* @param {object} stages — status.stages
|
|
75
|
+
* @returns {object} enriched stages
|
|
76
|
+
*/
|
|
77
|
+
export function assignEventsToIterations(events, stages) {
|
|
78
|
+
if (!stages || typeof stages !== 'object') return stages;
|
|
79
|
+
if (!events || events.length === 0) {
|
|
80
|
+
// Nothing to add — return input unchanged to avoid unnecessary allocation.
|
|
81
|
+
return stages;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Bucket events into iterations first, then aggregate per bucket.
|
|
85
|
+
// Bucket key: `${stageKey}|${iterationNumber}`.
|
|
86
|
+
const buckets = new Map();
|
|
87
|
+
|
|
88
|
+
for (const ev of events) {
|
|
89
|
+
if (!ev.timestamp) continue;
|
|
90
|
+
const eventTime = Date.parse(ev.timestamp);
|
|
91
|
+
if (Number.isNaN(eventTime)) continue;
|
|
92
|
+
|
|
93
|
+
let matched = false;
|
|
94
|
+
for (const [stageKey, stage] of Object.entries(stages)) {
|
|
95
|
+
const iterations = stage?.iterations;
|
|
96
|
+
if (!Array.isArray(iterations)) continue;
|
|
97
|
+
for (const iter of iterations) {
|
|
98
|
+
const start = iter.started_at ? Date.parse(iter.started_at) : NaN;
|
|
99
|
+
if (Number.isNaN(start)) continue;
|
|
100
|
+
// If the iteration hasn't completed, treat end as +infinity so live events land here.
|
|
101
|
+
const end = iter.completed_at
|
|
102
|
+
? Date.parse(iter.completed_at)
|
|
103
|
+
: Number.POSITIVE_INFINITY;
|
|
104
|
+
if (eventTime >= start && eventTime <= end) {
|
|
105
|
+
const key = `${stageKey}|${iter.number}`;
|
|
106
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
107
|
+
buckets.get(key).push(ev);
|
|
108
|
+
matched = true;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (matched) break;
|
|
113
|
+
}
|
|
114
|
+
// If no iteration matched, the event is silently dropped — it falls
|
|
115
|
+
// outside any recorded iteration window (e.g. during stage transitions).
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (buckets.size === 0) return stages;
|
|
119
|
+
|
|
120
|
+
// Aggregate each bucket and build the enriched stages map.
|
|
121
|
+
const enrichedStages = { ...stages };
|
|
122
|
+
for (const [key, bucketEvents] of buckets) {
|
|
123
|
+
const [stageKey, iterNumStr] = key.split('|');
|
|
124
|
+
const iterNum = Number(iterNumStr);
|
|
125
|
+
const stage = enrichedStages[stageKey];
|
|
126
|
+
if (!stage) continue;
|
|
127
|
+
const aggregated = aggregate(bucketEvents);
|
|
128
|
+
const newIterations = stage.iterations.map((iter) =>
|
|
129
|
+
iter.number === iterNum ? { ...iter, dispatch_events: aggregated } : iter,
|
|
130
|
+
);
|
|
131
|
+
enrichedStages[stageKey] = { ...stage, iterations: newIterations };
|
|
132
|
+
}
|
|
133
|
+
return enrichedStages;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Deduplicate an array of dispatch events by (type, subagent_type) and count
|
|
138
|
+
* occurrences. First reason wins for blocked events.
|
|
139
|
+
*
|
|
140
|
+
* @param {Array<{type, subagent_type, reason?}>} events
|
|
141
|
+
* @returns {Array<{type, subagent_type, reason?, count}>}
|
|
142
|
+
*/
|
|
143
|
+
function aggregate(events) {
|
|
144
|
+
const map = new Map();
|
|
145
|
+
for (const ev of events) {
|
|
146
|
+
const key = `${ev.type}|${ev.subagent_type}`;
|
|
147
|
+
const existing = map.get(key);
|
|
148
|
+
if (existing) {
|
|
149
|
+
existing.count += 1;
|
|
150
|
+
} else {
|
|
151
|
+
const entry = {
|
|
152
|
+
type: ev.type,
|
|
153
|
+
subagent_type: ev.subagent_type,
|
|
154
|
+
count: 1,
|
|
155
|
+
};
|
|
156
|
+
if (ev.reason) entry.reason = ev.reason;
|
|
157
|
+
map.set(key, entry);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [...map.values()];
|
|
161
|
+
}
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
unlinkSync,
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from 'node:fs';
|
|
13
|
+
import { readdir } from 'node:fs/promises';
|
|
13
14
|
import { basename, isAbsolute, join } from 'node:path';
|
|
15
|
+
import { checkWorcaInstalled, readProjectWorcaVersion } from './worca-setup.js';
|
|
14
16
|
|
|
15
17
|
export const SLUG_RE = /^[a-z0-9_-]{1,64}$/i;
|
|
16
18
|
const DEFAULT_MAX_PROJECTS = 20;
|
|
@@ -24,6 +26,7 @@ export function slugify(name) {
|
|
|
24
26
|
.toLowerCase()
|
|
25
27
|
.replace(/[^a-z0-9_-]/g, '-')
|
|
26
28
|
.replace(/-{2,}/g, '-')
|
|
29
|
+
.replace(/^-+|-+$/g, '')
|
|
27
30
|
.slice(0, 64);
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -130,6 +133,40 @@ export function synthesizeDefaultProject(projectRoot) {
|
|
|
130
133
|
};
|
|
131
134
|
}
|
|
132
135
|
|
|
136
|
+
const SCAN_MAX_RESULTS = 200;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Scan a directory for immediate child folders that contain a .git subdirectory.
|
|
140
|
+
* Skips dotfiles (names starting with ".") and "node_modules".
|
|
141
|
+
* Returns entries sorted alphabetically by name, capped at SCAN_MAX_RESULTS.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} dirPath - Absolute path to the parent directory
|
|
144
|
+
* @returns {Promise<{ name: string, path: string }[]>}
|
|
145
|
+
*/
|
|
146
|
+
export async function scanDirectory(dirPath) {
|
|
147
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
148
|
+
const results = [];
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (!entry.isDirectory()) continue;
|
|
151
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
152
|
+
const childPath = join(dirPath, entry.name);
|
|
153
|
+
if (existsSync(join(childPath, '.git'))) {
|
|
154
|
+
const installed = checkWorcaInstalled(childPath);
|
|
155
|
+
const worcaVersion = installed
|
|
156
|
+
? readProjectWorcaVersion(childPath)
|
|
157
|
+
: null;
|
|
158
|
+
results.push({
|
|
159
|
+
name: entry.name,
|
|
160
|
+
path: childPath,
|
|
161
|
+
installed,
|
|
162
|
+
worcaVersion,
|
|
163
|
+
});
|
|
164
|
+
if (results.length >= SCAN_MAX_RESULTS) break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return results.sort((a, b) => a.name.localeCompare(b.name));
|
|
168
|
+
}
|
|
169
|
+
|
|
133
170
|
/**
|
|
134
171
|
* Read max projects from {prefsDir}/config.json. Defaults to 20.
|
|
135
172
|
*/
|