@worca/ui 0.33.0 → 0.35.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 +1044 -1036
- package/app/main.bundle.js.map +3 -3
- package/package.json +1 -1
- package/server/dispatch-defaults.js +6 -2
- package/server/dispatch-migration.js +39 -1
- package/server/project-routes.js +2 -2
- package/server/safe-watch.js +11 -0
- package/server/watcher.js +135 -3
- package/server/ws-beads-watcher.js +3 -2
- package/server/ws-fleet-manifest-watcher.js +3 -2
- package/server/ws-log-watcher.js +5 -4
- package/server/ws-message-router.js +3 -5
- package/server/ws-modular.js +7 -2
- package/server/ws-status-watcher.js +8 -13
- package/server/ws-workspace-manifest-watcher.js +3 -2
package/package.json
CHANGED
|
@@ -64,8 +64,12 @@ export const DISPATCH_DEFAULTS = {
|
|
|
64
64
|
},
|
|
65
65
|
},
|
|
66
66
|
subagents: {
|
|
67
|
-
always_disallowed: [
|
|
68
|
-
|
|
67
|
+
always_disallowed: [],
|
|
68
|
+
// general-purpose spawns an unconstrained full-tool Claude session, so it
|
|
69
|
+
// stays denied under the '*' wildcard — but as default_denied (not
|
|
70
|
+
// always_disallowed) a project can re-allow it per agent by naming it in
|
|
71
|
+
// per_agent_allow.
|
|
72
|
+
default_denied: ['general-purpose'],
|
|
69
73
|
per_agent_allow: { _defaults: ['*'] },
|
|
70
74
|
},
|
|
71
75
|
};
|
|
@@ -60,7 +60,10 @@ function _absorbFlatDispatchKeys(dispatch) {
|
|
|
60
60
|
// Mirror of normalize_dispatch_defaults() in src/worca/hooks/tracking.py.
|
|
61
61
|
// Bumped when a new one-time normalization is added; stamped onto
|
|
62
62
|
// governance.dispatch_migration_version so it runs exactly once per config.
|
|
63
|
-
|
|
63
|
+
// v1: collapse stale Explore-only subagent default; narrow worca-* skills glob.
|
|
64
|
+
// v2: move general-purpose from subagents.always_disallowed to default_denied
|
|
65
|
+
// (still denied by default, but allowable per-agent).
|
|
66
|
+
export const DISPATCH_MIGRATION_VERSION = 2;
|
|
64
67
|
|
|
65
68
|
// Pre-W-054 (W-038-era) shipped subagent default: every pipeline agent capped
|
|
66
69
|
// to Explore-only. coordinator:[] / empty lists fall through to _defaults and
|
|
@@ -151,6 +154,36 @@ export function adoptNarrowedSkillsDenylist(skillsCfg) {
|
|
|
151
154
|
return true;
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Move general-purpose from subagents.always_disallowed to default_denied so
|
|
159
|
+
* it is allowable per-agent (still denied under the '*' wildcard). Only fires
|
|
160
|
+
* on an untouched denylist (exactly `['general-purpose']`); a customized list
|
|
161
|
+
* is left alone. Preserves existing default_denied entries. Returns true if
|
|
162
|
+
* changed. Mirror of adopt_general_purpose_allowable() in tracking.py.
|
|
163
|
+
*
|
|
164
|
+
* @param {object} subagentsCfg
|
|
165
|
+
* @returns {boolean}
|
|
166
|
+
*/
|
|
167
|
+
export function adoptGeneralPurposeAllowable(subagentsCfg) {
|
|
168
|
+
if (!subagentsCfg || typeof subagentsCfg !== 'object') return false;
|
|
169
|
+
const current = subagentsCfg.always_disallowed;
|
|
170
|
+
if (
|
|
171
|
+
!Array.isArray(current) ||
|
|
172
|
+
current.length !== 1 ||
|
|
173
|
+
current[0] !== 'general-purpose'
|
|
174
|
+
) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const denied = Array.isArray(subagentsCfg.default_denied)
|
|
178
|
+
? subagentsCfg.default_denied
|
|
179
|
+
: [];
|
|
180
|
+
subagentsCfg.always_disallowed = [];
|
|
181
|
+
subagentsCfg.default_denied = denied.includes('general-purpose')
|
|
182
|
+
? denied
|
|
183
|
+
: [...denied, 'general-purpose'];
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
154
187
|
/**
|
|
155
188
|
* Apply one-time dispatch-default normalizations, gated by a version stamp.
|
|
156
189
|
* Brings an *untouched* config up to current shipped defaults for the two
|
|
@@ -177,6 +210,11 @@ export function normalizeDispatchDefaults(governanceCfg) {
|
|
|
177
210
|
'governance.dispatch.skills.always_disallowed: narrowed legacy "worca-*" glob to the current must-disallow set',
|
|
178
211
|
);
|
|
179
212
|
}
|
|
213
|
+
if (adoptGeneralPurposeAllowable(dispatch.subagents)) {
|
|
214
|
+
changes.push(
|
|
215
|
+
'governance.dispatch.subagents: moved general-purpose from always_disallowed to default_denied (now allowable per-agent)',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
180
218
|
governanceCfg.dispatch_migration_version = DISPATCH_MIGRATION_VERSION;
|
|
181
219
|
return changes;
|
|
182
220
|
}
|
package/server/project-routes.js
CHANGED
|
@@ -1587,9 +1587,9 @@ export function createProjectScopedRoutes({
|
|
|
1587
1587
|
router.get('/templates', (req, res) => {
|
|
1588
1588
|
const root = req.project.projectRoot;
|
|
1589
1589
|
const tiers = [
|
|
1590
|
-
{ tier: 'worca', dir: join(root, '.claude', 'worca', 'templates') },
|
|
1591
|
-
{ tier: 'project', dir: join(root, '.claude', 'templates') },
|
|
1592
1590
|
{ tier: 'user', dir: templatesDir() },
|
|
1591
|
+
{ tier: 'project', dir: join(root, '.claude', 'templates') },
|
|
1592
|
+
{ tier: 'worca', dir: join(root, '.claude', 'worca', 'templates') },
|
|
1593
1593
|
];
|
|
1594
1594
|
|
|
1595
1595
|
const templates = [];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export function safeWatch(...args) {
|
|
4
|
+
const w = watch(...args);
|
|
5
|
+
w.on('error', (err) => {
|
|
6
|
+
if (err && err.code !== 'EPERM' && err.code !== 'ENOENT') {
|
|
7
|
+
console.error('[safeWatch] watcher error:', err);
|
|
8
|
+
}
|
|
9
|
+
});
|
|
10
|
+
return w;
|
|
11
|
+
}
|
package/server/watcher.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, readdirSync, readFileSync
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { readdir, readFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import {
|
|
6
6
|
assignEventsToIterations,
|
|
7
7
|
readDispatchEventsFromJsonl,
|
|
8
8
|
} from './dispatch-events-aggregator.js';
|
|
9
|
+
import { readPipelineOverlay } from './run-dir-resolver.js';
|
|
10
|
+
import { safeWatch } from './safe-watch.js';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Enrich a status object with dispatch events read from events.jsonl in the
|
|
@@ -45,7 +47,137 @@ function isTerminal(status) {
|
|
|
45
47
|
);
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
const _discoverRunsCache = new Map(); // worcaDir → { ts, runs }
|
|
51
|
+
// TTL defaults to 0 under vitest (NODE_ENV=test) so the cache is a no-op in
|
|
52
|
+
// tests — they build fixture dirs from Date.now() and a shared path could
|
|
53
|
+
// otherwise serve a stale cached scan across tests. Production uses 1500ms;
|
|
54
|
+
// _setDiscoverRunsTtlForTest lets the dedicated cache test exercise real TTL.
|
|
55
|
+
let _discoverRunsTtlMs = process.env.NODE_ENV === 'test' ? 0 : 1500;
|
|
56
|
+
|
|
57
|
+
/** Test hook: override the discoverRuns cache TTL in ms. */
|
|
58
|
+
export function _setDiscoverRunsTtlForTest(ms) {
|
|
59
|
+
_discoverRunsTtlMs = ms;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Cached wrapper around the run-discovery scan. The scan reads + JSON-parses
|
|
64
|
+
* every run's status.json across runs/, results/, and pipelines.d/ worktree
|
|
65
|
+
* overlays — hundreds of ms on a large project. Whole-list callers (list-runs,
|
|
66
|
+
* REST /runs) hit this; a short TTL collapses repeated calls in a burst into a
|
|
67
|
+
* single scan. Per-run handlers use findRun() instead. Live status changes
|
|
68
|
+
* still reach clients via the statusWatcher broadcast, so TTL-window staleness
|
|
69
|
+
* here is invisible in the UI.
|
|
70
|
+
*/
|
|
48
71
|
export function discoverRuns(worcaDir) {
|
|
72
|
+
const cached = _discoverRunsCache.get(worcaDir);
|
|
73
|
+
if (cached && Date.now() - cached.ts < _discoverRunsTtlMs) {
|
|
74
|
+
return cached.runs;
|
|
75
|
+
}
|
|
76
|
+
const runs = _discoverRunsUncached(worcaDir);
|
|
77
|
+
_discoverRunsCache.set(worcaDir, { ts: Date.now(), runs });
|
|
78
|
+
return runs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Clear the discoverRuns TTL cache (tests, or explicit invalidation). */
|
|
82
|
+
export function clearDiscoverRunsCache() {
|
|
83
|
+
_discoverRunsCache.clear();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a SINGLE run by id without scanning every run on disk — the O(1)
|
|
88
|
+
* counterpart to discoverRuns().find(r => r.id === runId), for hot WS handlers
|
|
89
|
+
* (subscribe-run, get-agent-prompt) that need exactly one run. Mirrors
|
|
90
|
+
* discoverRuns' per-source shaping: dispatch-event enrichment for runs/ and
|
|
91
|
+
* worktree sources (not results/), plus the worktree registry fields. The
|
|
92
|
+
* findRun-vs-discoverRuns parity test keeps the two aligned.
|
|
93
|
+
*
|
|
94
|
+
* Falls back to a (TTL-cached) discoverRuns scan for legacy layouts where the
|
|
95
|
+
* on-disk name doesn't equal the computed id (flat `.worca/status.json`, hashed
|
|
96
|
+
* legacy ids), so it never resolves fewer runs than discoverRuns().find().
|
|
97
|
+
*
|
|
98
|
+
* @returns {object|null} a run record shaped like a discoverRuns entry, or null
|
|
99
|
+
*/
|
|
100
|
+
export function findRun(worcaDir, runId) {
|
|
101
|
+
if (!worcaDir || !runId) return null;
|
|
102
|
+
|
|
103
|
+
// 1. Local active: runs/<id>/status.json (enriched)
|
|
104
|
+
const localRunDir = join(worcaDir, 'runs', runId);
|
|
105
|
+
if (existsSync(join(localRunDir, 'status.json'))) {
|
|
106
|
+
return _shapeRunFromFile(join(localRunDir, 'status.json'), {
|
|
107
|
+
enrich: true,
|
|
108
|
+
runDir: localRunDir,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 2. Local archived dir: results/<id>/status.json (not enriched)
|
|
113
|
+
const resultsDirStatus = join(worcaDir, 'results', runId, 'status.json');
|
|
114
|
+
if (existsSync(resultsDirStatus)) {
|
|
115
|
+
return _shapeRunFromFile(resultsDirStatus, { enrich: false });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 2b. Legacy archived file: results/<id>.json (not enriched)
|
|
119
|
+
const legacyFile = join(worcaDir, 'results', `${runId}.json`);
|
|
120
|
+
if (existsSync(legacyFile)) {
|
|
121
|
+
return _shapeRunFromFile(legacyFile, {
|
|
122
|
+
enrich: false,
|
|
123
|
+
requireStartedAt: true,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 3. Worktree overlay: pipelines.d/<id>.json → <worktree>/.worca/runs/<id>
|
|
128
|
+
const reg = readPipelineOverlay(worcaDir, runId);
|
|
129
|
+
if (reg?.worktree_path) {
|
|
130
|
+
const wtRunDir = join(reg.worktree_path, '.worca', 'runs', runId);
|
|
131
|
+
if (existsSync(join(wtRunDir, 'status.json'))) {
|
|
132
|
+
return _shapeRunFromFile(join(wtRunDir, 'status.json'), {
|
|
133
|
+
enrich: true,
|
|
134
|
+
runDir: wtRunDir,
|
|
135
|
+
worktreeReg: reg,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Fallback for legacy layouts where the on-disk name != the computed id
|
|
141
|
+
// (flat .worca/status.json, hashed legacy ids). Rare — pay one (TTL-cached)
|
|
142
|
+
// full scan rather than regress correctness vs discoverRuns().find().
|
|
143
|
+
return discoverRuns(worcaDir).find((r) => r.id === runId) || null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _shapeRunFromFile(
|
|
147
|
+
statusPath,
|
|
148
|
+
{
|
|
149
|
+
enrich = false,
|
|
150
|
+
runDir = null,
|
|
151
|
+
worktreeReg = null,
|
|
152
|
+
requireStartedAt = false,
|
|
153
|
+
} = {},
|
|
154
|
+
) {
|
|
155
|
+
try {
|
|
156
|
+
let status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
157
|
+
if (requireStartedAt && !status.started_at) return null;
|
|
158
|
+
if (enrich && runDir) status = enrichWithDispatchEvents(status, runDir);
|
|
159
|
+
const id = createRunId(status);
|
|
160
|
+
const active = !isTerminal(status) && status.pipeline_status === 'running';
|
|
161
|
+
const base = { id, active, ...status };
|
|
162
|
+
if (worktreeReg) {
|
|
163
|
+
return {
|
|
164
|
+
...base,
|
|
165
|
+
worktree_worca_dir: join(worktreeReg.worktree_path, '.worca'),
|
|
166
|
+
is_worktree_run: true,
|
|
167
|
+
head_branch: worktreeReg.branch || null,
|
|
168
|
+
fleet_id: worktreeReg.fleet_id || null,
|
|
169
|
+
workspace_id: worktreeReg.workspace_id || null,
|
|
170
|
+
group_type: worktreeReg.group_type || null,
|
|
171
|
+
target_branch: worktreeReg.target_branch || null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return base;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _discoverRunsUncached(worcaDir) {
|
|
49
181
|
const runs = [];
|
|
50
182
|
const seenIds = new Set();
|
|
51
183
|
|
|
@@ -366,7 +498,7 @@ export function watchEvents(runDir, callback) {
|
|
|
366
498
|
function startFileWatcher() {
|
|
367
499
|
if (closed || fileWatcher) return;
|
|
368
500
|
try {
|
|
369
|
-
fileWatcher =
|
|
501
|
+
fileWatcher = safeWatch(eventsPath, (eventType) => {
|
|
370
502
|
if (eventType === 'change') {
|
|
371
503
|
processNewContent();
|
|
372
504
|
} else if (eventType === 'rename') {
|
|
@@ -405,7 +537,7 @@ export function watchEvents(runDir, callback) {
|
|
|
405
537
|
// Watch the run directory so we detect events.jsonl being created
|
|
406
538
|
if (existsSync(runDir)) {
|
|
407
539
|
try {
|
|
408
|
-
dirWatcher =
|
|
540
|
+
dirWatcher = safeWatch(
|
|
409
541
|
runDir,
|
|
410
542
|
{ recursive: false },
|
|
411
543
|
(_eventType, filename) => {
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
* because fs.watch on macOS misses SQLite WAL writes done via mmap.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, statSync, unwatchFile,
|
|
7
|
+
import { existsSync, statSync, unwatchFile, watchFile } from 'node:fs';
|
|
8
8
|
import { join, resolve } from 'node:path';
|
|
9
9
|
import {
|
|
10
10
|
countIssuesByRunLabel,
|
|
11
11
|
enrichIssuesWithDeps,
|
|
12
12
|
listIssuesShallow,
|
|
13
13
|
} from './beads-reader.js';
|
|
14
|
+
import { safeWatch } from './safe-watch.js';
|
|
14
15
|
|
|
15
16
|
const BEADS_DEBOUNCE_MS = 500;
|
|
16
17
|
const BEADS_POLL_MS = 2000;
|
|
@@ -132,7 +133,7 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
|
|
|
132
133
|
if (existsSync(beadsDir)) {
|
|
133
134
|
// fs.watch for directory-level events (checkpoint writes to main db)
|
|
134
135
|
try {
|
|
135
|
-
fsWatcher =
|
|
136
|
+
fsWatcher = safeWatch(beadsDir, (_event, filename) => {
|
|
136
137
|
if (filename?.startsWith('beads.db')) scheduleBeadsRefresh();
|
|
137
138
|
});
|
|
138
139
|
} catch {
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* Emits fleet-update WS events when a fleet manifest is written (§13.5).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync, readFileSync
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { effectiveFleetStatus } from './fleet-routes.js';
|
|
9
9
|
import { fleetRunsDir as resolveFleetRunsDir } from './paths.js';
|
|
10
|
+
import { safeWatch } from './safe-watch.js';
|
|
10
11
|
|
|
11
12
|
const FLEET_DEBOUNCE_MS = 200;
|
|
12
13
|
|
|
@@ -100,7 +101,7 @@ export function createFleetManifestWatcher({
|
|
|
100
101
|
|
|
101
102
|
try {
|
|
102
103
|
if (existsSync(fleetRunsDir)) {
|
|
103
|
-
fsWatcher =
|
|
104
|
+
fsWatcher = safeWatch(
|
|
104
105
|
fleetRunsDir,
|
|
105
106
|
{ persistent: false },
|
|
106
107
|
(_event, filename) => {
|
package/server/ws-log-watcher.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Owns logWatchers map and logLineCounts tracking.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync, readdirSync, statSync
|
|
6
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import {
|
|
9
9
|
fileByteLength,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
readNewLines,
|
|
14
14
|
resolveLogPath,
|
|
15
15
|
} from './log-tailer.js';
|
|
16
|
+
import { safeWatch } from './safe-watch.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* @param {{
|
|
@@ -69,7 +70,7 @@ export function createLogWatcher({
|
|
|
69
70
|
if (!existsSync(filePath)) return;
|
|
70
71
|
logByteOffsets.set(key, fileByteLength(filePath));
|
|
71
72
|
const watcherRunId = explicitRunId || currentActiveRunId();
|
|
72
|
-
const watcher =
|
|
73
|
+
const watcher = safeWatch(filePath, (eventType) => {
|
|
73
74
|
if (eventType === 'change') {
|
|
74
75
|
try {
|
|
75
76
|
const prevOffset = logByteOffsets.get(key) || 0;
|
|
@@ -109,7 +110,7 @@ export function createLogWatcher({
|
|
|
109
110
|
const dirKey = _watcherKey(explicitRunId, stage, null, '__dir');
|
|
110
111
|
if (logWatchers.has(dirKey)) return;
|
|
111
112
|
try {
|
|
112
|
-
const dirWatcher =
|
|
113
|
+
const dirWatcher = safeWatch(stageDir, (_eventType, filename) => {
|
|
113
114
|
if (filename && /^iter-\d+\.log$/.test(filename)) {
|
|
114
115
|
const iterNum = parseInt(filename.match(/\d+/)[0], 10);
|
|
115
116
|
const iterPath = join(stageDir, filename);
|
|
@@ -167,7 +168,7 @@ export function createLogWatcher({
|
|
|
167
168
|
if (logWatchers.has(dirKey)) return;
|
|
168
169
|
if (!existsSync(logsDir)) return;
|
|
169
170
|
try {
|
|
170
|
-
const dirWatcher =
|
|
171
|
+
const dirWatcher = safeWatch(logsDir, (_eventType, filename) => {
|
|
171
172
|
if (!filename) return;
|
|
172
173
|
if (filename.endsWith('.log')) {
|
|
173
174
|
const stage = filename.replace('.log', '');
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
} from './process-manager.js';
|
|
34
34
|
import { resolveRunDir } from './run-dir-resolver.js';
|
|
35
35
|
import { readSettings } from './settings-reader.js';
|
|
36
|
-
import { discoverRuns } from './watcher.js';
|
|
36
|
+
import { discoverRuns, findRun } from './watcher.js';
|
|
37
37
|
import { resolveBeadsCounts } from './ws-beads-watcher.js';
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -165,8 +165,7 @@ export function createMessageRouter({
|
|
|
165
165
|
}
|
|
166
166
|
_adoptProjectFromPayload(ws, req.payload);
|
|
167
167
|
const proj = resolveProject(ws, req.payload);
|
|
168
|
-
const
|
|
169
|
-
const run = runs.find((r) => r.id === runId);
|
|
168
|
+
const run = findRun(proj.worcaDir, runId);
|
|
170
169
|
if (!run) {
|
|
171
170
|
ws.send(
|
|
172
171
|
JSON.stringify(makeError(req, 'NOT_FOUND', `Run ${runId} not found`)),
|
|
@@ -321,8 +320,7 @@ export function createMessageRouter({
|
|
|
321
320
|
}
|
|
322
321
|
const s = clientManager.ensureSubs(ws);
|
|
323
322
|
s.runId = runId;
|
|
324
|
-
const
|
|
325
|
-
const run = runs.find((r) => r.id === runId);
|
|
323
|
+
const run = findRun(proj.worcaDir, runId);
|
|
326
324
|
if (run) {
|
|
327
325
|
if (
|
|
328
326
|
run.pipeline_status !== undefined &&
|
package/server/ws-modular.js
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* Supports dynamic project add/remove via fs.watch on projects.d/.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { WebSocketServer } from 'ws';
|
|
12
12
|
import { fleetRunsDir, workspaceRunsDir } from './paths.js';
|
|
13
13
|
import { readProjects, synthesizeDefaultProject } from './project-registry.js';
|
|
14
|
+
import { safeWatch } from './safe-watch.js';
|
|
14
15
|
import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
|
|
15
16
|
import { readProjectWorcaVersion } from './worca-setup.js';
|
|
16
17
|
import { peekBeadsCounts } from './ws-beads-watcher.js';
|
|
@@ -40,6 +41,10 @@ export function attachWsServer(httpServer, config) {
|
|
|
40
41
|
} = config;
|
|
41
42
|
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
|
42
43
|
|
|
44
|
+
// WSS created with an external server does not auto-close when the server
|
|
45
|
+
// closes — explicitly bridge the lifecycle so watchers are torn down.
|
|
46
|
+
httpServer.on('close', () => wss.close());
|
|
47
|
+
|
|
43
48
|
// 1. Client manager — owns subs WeakMap and heartbeat
|
|
44
49
|
const clientManager = createClientManager({ wss });
|
|
45
50
|
|
|
@@ -115,7 +120,7 @@ export function attachWsServer(httpServer, config) {
|
|
|
115
120
|
const projectsDir = join(prefsDir, 'projects.d');
|
|
116
121
|
try {
|
|
117
122
|
if (existsSync(projectsDir)) {
|
|
118
|
-
dirWatcher =
|
|
123
|
+
dirWatcher = safeWatch(projectsDir, { persistent: false }, () => {
|
|
119
124
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
120
125
|
debounceTimer = setTimeout(() => {
|
|
121
126
|
debounceTimer = null;
|
|
@@ -3,14 +3,9 @@
|
|
|
3
3
|
* Owns refresh scheduling, lastPipelineStatus tracking, and the status/runsDirWatcher FSWatchers.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
existsSync,
|
|
8
|
-
mkdirSync,
|
|
9
|
-
readdirSync,
|
|
10
|
-
readFileSync,
|
|
11
|
-
watch,
|
|
12
|
-
} from 'node:fs';
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
13
7
|
import { join } from 'node:path';
|
|
8
|
+
import { safeWatch } from './safe-watch.js';
|
|
14
9
|
import { readSettings } from './settings-reader.js';
|
|
15
10
|
import { discoverRunsAsync } from './watcher.js';
|
|
16
11
|
|
|
@@ -225,7 +220,7 @@ export function createStatusWatcher({
|
|
|
225
220
|
const wtRunsDir = join(reg.worktree_path, '.worca', 'runs');
|
|
226
221
|
if (!existsSync(wtRunsDir)) continue;
|
|
227
222
|
try {
|
|
228
|
-
const w =
|
|
223
|
+
const w = safeWatch(
|
|
229
224
|
wtRunsDir,
|
|
230
225
|
{ recursive: true },
|
|
231
226
|
(_eventType, filename) => {
|
|
@@ -322,7 +317,7 @@ export function createStatusWatcher({
|
|
|
322
317
|
// rename-over) replace the inode. After one 'rename' event the
|
|
323
318
|
// watcher goes dead because it tracked the old inode. We
|
|
324
319
|
// re-establish the watcher on the new file after a short delay.
|
|
325
|
-
statusWatcher =
|
|
320
|
+
statusWatcher = safeWatch(statusFile, (eventType) => {
|
|
326
321
|
scheduleRefresh();
|
|
327
322
|
if (eventType === 'rename') {
|
|
328
323
|
// File replaced (atomic write) — re-watch the new inode
|
|
@@ -338,7 +333,7 @@ export function createStatusWatcher({
|
|
|
338
333
|
} else if (existsSync(runDir)) {
|
|
339
334
|
// status.json doesn't exist yet — watch the directory for its creation,
|
|
340
335
|
// then switch to watching the file once it appears.
|
|
341
|
-
statusWatcher =
|
|
336
|
+
statusWatcher = safeWatch(
|
|
342
337
|
runDir,
|
|
343
338
|
{ recursive: false },
|
|
344
339
|
(_eventType, filename) => {
|
|
@@ -373,7 +368,7 @@ export function createStatusWatcher({
|
|
|
373
368
|
// Watch worcaDir for legacy status.json changes
|
|
374
369
|
try {
|
|
375
370
|
if (existsSync(worcaDir)) {
|
|
376
|
-
activeRunWatcher =
|
|
371
|
+
activeRunWatcher = safeWatch(
|
|
377
372
|
worcaDir,
|
|
378
373
|
{ recursive: false },
|
|
379
374
|
(_eventType, filename) => {
|
|
@@ -395,7 +390,7 @@ export function createStatusWatcher({
|
|
|
395
390
|
const runsDir = join(worcaDir, 'runs');
|
|
396
391
|
try {
|
|
397
392
|
if (existsSync(runsDir)) {
|
|
398
|
-
runsDirWatcher =
|
|
393
|
+
runsDirWatcher = safeWatch(
|
|
399
394
|
runsDir,
|
|
400
395
|
{ recursive: true },
|
|
401
396
|
(_eventType, filename) => {
|
|
@@ -414,7 +409,7 @@ export function createStatusWatcher({
|
|
|
414
409
|
const pipelinesDirPath = join(worcaDir, 'multi', 'pipelines.d');
|
|
415
410
|
try {
|
|
416
411
|
mkdirSync(pipelinesDirPath, { recursive: true });
|
|
417
|
-
pipelinesDirWatcher =
|
|
412
|
+
pipelinesDirWatcher = safeWatch(
|
|
418
413
|
pipelinesDirPath,
|
|
419
414
|
{ recursive: false },
|
|
420
415
|
(_eventType, filename) => {
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* Separate from fleet-update per W-040 §13.5 — never multiplexed.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, readFileSync
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { workspaceRunsDir as resolveWorkspaceRunsDir } from './paths.js';
|
|
12
|
+
import { safeWatch } from './safe-watch.js';
|
|
12
13
|
|
|
13
14
|
const WS_DEBOUNCE_MS = 200;
|
|
14
15
|
|
|
@@ -102,7 +103,7 @@ export function createWorkspaceManifestWatcher({
|
|
|
102
103
|
|
|
103
104
|
try {
|
|
104
105
|
if (existsSync(workspaceRunsDir)) {
|
|
105
|
-
fsWatcher =
|
|
106
|
+
fsWatcher = safeWatch(
|
|
106
107
|
workspaceRunsDir,
|
|
107
108
|
{ persistent: false },
|
|
108
109
|
(_event, filename) => {
|