@worca/ui 0.32.0 → 0.34.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 +1400 -1357
- package/app/main.bundle.js.map +3 -3
- package/app/styles.css +45 -0
- package/package.json +1 -1
- package/server/app.js +34 -23
- package/server/dispatch-defaults.js +6 -2
- package/server/dispatch-migration.js +39 -1
- package/server/integrations/renderers.js +28 -0
- package/server/project-routes.js +2 -2
- package/server/safe-watch.js +11 -0
- package/server/watcher.js +4 -3
- package/server/workspace-routes.js +170 -2
- 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-modular.js +7 -2
- package/server/ws-status-watcher.js +8 -13
- package/server/ws-workspace-manifest-watcher.js +3 -2
package/app/styles.css
CHANGED
|
@@ -5651,6 +5651,36 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
5651
5651
|
gap: 4px;
|
|
5652
5652
|
}
|
|
5653
5653
|
|
|
5654
|
+
/* Workspace plan upload (existing mode) */
|
|
5655
|
+
.workspace-plan-upload {
|
|
5656
|
+
display: flex;
|
|
5657
|
+
flex-direction: column;
|
|
5658
|
+
gap: 8px;
|
|
5659
|
+
}
|
|
5660
|
+
.workspace-plan-advanced {
|
|
5661
|
+
font-size: 13px;
|
|
5662
|
+
}
|
|
5663
|
+
.workspace-plan-advanced sl-input {
|
|
5664
|
+
margin-top: 4px;
|
|
5665
|
+
}
|
|
5666
|
+
|
|
5667
|
+
/* Per-project plan rows (per-repo mode) */
|
|
5668
|
+
.per-project-plans {
|
|
5669
|
+
display: flex;
|
|
5670
|
+
flex-direction: column;
|
|
5671
|
+
gap: 6px;
|
|
5672
|
+
}
|
|
5673
|
+
.per-project-plan-row {
|
|
5674
|
+
display: flex;
|
|
5675
|
+
align-items: center;
|
|
5676
|
+
gap: 8px;
|
|
5677
|
+
}
|
|
5678
|
+
.per-project-plan-name {
|
|
5679
|
+
min-width: 100px;
|
|
5680
|
+
font-size: 13px;
|
|
5681
|
+
font-weight: 500;
|
|
5682
|
+
}
|
|
5683
|
+
|
|
5654
5684
|
/* Base-branch validation states */
|
|
5655
5685
|
.base-branch-validating {
|
|
5656
5686
|
display: flex;
|
|
@@ -6288,6 +6318,9 @@ sl-dialog.markdown-dialog::part(body) {
|
|
|
6288
6318
|
font-size: 12px;
|
|
6289
6319
|
max-height: 200px;
|
|
6290
6320
|
overflow-y: auto;
|
|
6321
|
+
/* Inherit the tooltip's (light) text color instead of --fg, which is the
|
|
6322
|
+
main-panel foreground and renders dark-on-dark inside the tooltip. */
|
|
6323
|
+
color: inherit;
|
|
6291
6324
|
}
|
|
6292
6325
|
.bead-tooltip-excerpt.markdown-body p {
|
|
6293
6326
|
margin: 0.3em 0;
|
|
@@ -6297,6 +6330,18 @@ sl-dialog.markdown-dialog::part(body) {
|
|
|
6297
6330
|
max-height: 120px;
|
|
6298
6331
|
overflow: auto;
|
|
6299
6332
|
}
|
|
6333
|
+
/* The base markdown code/blockquote/link colors assume the light main panel;
|
|
6334
|
+
re-tint them for the dark tooltip surface so they stay legible. */
|
|
6335
|
+
.bead-tooltip-excerpt.markdown-body code,
|
|
6336
|
+
.bead-tooltip-excerpt.markdown-body pre {
|
|
6337
|
+
background: rgba(255, 255, 255, 0.1);
|
|
6338
|
+
border-color: rgba(255, 255, 255, 0.15);
|
|
6339
|
+
}
|
|
6340
|
+
.bead-tooltip-excerpt.markdown-body blockquote {
|
|
6341
|
+
color: inherit;
|
|
6342
|
+
opacity: 0.8;
|
|
6343
|
+
border-left-color: rgba(255, 255, 255, 0.3);
|
|
6344
|
+
}
|
|
6300
6345
|
|
|
6301
6346
|
.workspace-run-card .workspace-card-root,
|
|
6302
6347
|
.workspace-run-card .workspace-card-name {
|
package/package.json
CHANGED
package/server/app.js
CHANGED
|
@@ -65,6 +65,39 @@ function runWorcaCleanupSubprocess(flag, id) {
|
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export function buildWorkspaceArgs(workspace_root, workspace_id, manifest) {
|
|
69
|
+
const args = [
|
|
70
|
+
'-m',
|
|
71
|
+
'worca.scripts.run_workspace',
|
|
72
|
+
workspace_root,
|
|
73
|
+
'--workspace-id',
|
|
74
|
+
workspace_id,
|
|
75
|
+
];
|
|
76
|
+
if (manifest.work_request?.source) {
|
|
77
|
+
args.push('--source', manifest.work_request.source);
|
|
78
|
+
} else {
|
|
79
|
+
args.push('--prompt', manifest.work_request?.description ?? '');
|
|
80
|
+
}
|
|
81
|
+
if (manifest.branch_template) {
|
|
82
|
+
args.push('--branch', manifest.branch_template);
|
|
83
|
+
}
|
|
84
|
+
if (manifest.skip_integration) args.push('--skip-integration');
|
|
85
|
+
if (manifest.skip_planning) args.push('--skip-planning');
|
|
86
|
+
if (manifest.max_parallel) {
|
|
87
|
+
args.push('--max-parallel', String(manifest.max_parallel));
|
|
88
|
+
}
|
|
89
|
+
for (const p of manifest.guide?.paths || []) {
|
|
90
|
+
args.push('--guide', p);
|
|
91
|
+
}
|
|
92
|
+
if (manifest.workspace_plan_path) {
|
|
93
|
+
args.push('--workspace-plan', manifest.workspace_plan_path);
|
|
94
|
+
}
|
|
95
|
+
for (const [name, path] of Object.entries(manifest.project_plans || {})) {
|
|
96
|
+
args.push('--project-plan', `${name}=${path}`);
|
|
97
|
+
}
|
|
98
|
+
return args;
|
|
99
|
+
}
|
|
100
|
+
|
|
68
101
|
export function createApp(options = {}) {
|
|
69
102
|
const app = express();
|
|
70
103
|
const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
|
|
@@ -733,29 +766,7 @@ export function createApp(options = {}) {
|
|
|
733
766
|
child.unref();
|
|
734
767
|
return;
|
|
735
768
|
}
|
|
736
|
-
const args =
|
|
737
|
-
'-m',
|
|
738
|
-
'worca.scripts.run_workspace',
|
|
739
|
-
workspace_root,
|
|
740
|
-
'--workspace-id',
|
|
741
|
-
workspace_id,
|
|
742
|
-
];
|
|
743
|
-
if (manifest.work_request?.source) {
|
|
744
|
-
args.push('--source', manifest.work_request.source);
|
|
745
|
-
} else {
|
|
746
|
-
args.push('--prompt', manifest.work_request?.description ?? '');
|
|
747
|
-
}
|
|
748
|
-
if (manifest.branch_template) {
|
|
749
|
-
args.push('--branch', manifest.branch_template);
|
|
750
|
-
}
|
|
751
|
-
if (manifest.skip_integration) args.push('--skip-integration');
|
|
752
|
-
if (manifest.skip_planning) args.push('--skip-planning');
|
|
753
|
-
if (manifest.max_parallel) {
|
|
754
|
-
args.push('--max-parallel', String(manifest.max_parallel));
|
|
755
|
-
}
|
|
756
|
-
for (const p of manifest.guide?.paths || []) {
|
|
757
|
-
args.push('--guide', p);
|
|
758
|
-
}
|
|
769
|
+
const args = buildWorkspaceArgs(workspace_root, workspace_id, manifest);
|
|
759
770
|
const child = spawn('python3', args, {
|
|
760
771
|
detached: true,
|
|
761
772
|
stdio: 'ignore',
|
|
@@ -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
|
}
|
|
@@ -453,6 +453,32 @@ function renderWorkspacePlanFailed(envelope) {
|
|
|
453
453
|
return mdMsg(parts.join('\n'), 'error');
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
function renderWorkspacePlanLoaded(envelope) {
|
|
457
|
+
const p = envelope.payload ?? {};
|
|
458
|
+
const parts = [`📋 **Workspace plan loaded:** ${workspaceTitle(envelope)}`];
|
|
459
|
+
parts.push(
|
|
460
|
+
` **Mode:** ${p.mode ?? '?'} (${p.project_count ?? '?'} project${p.project_count === 1 ? '' : 's'})`,
|
|
461
|
+
);
|
|
462
|
+
return mdMsg(parts.join('\n'), 'info');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function renderWorkspacePlanPartial(envelope) {
|
|
466
|
+
const p = envelope.payload ?? {};
|
|
467
|
+
const uncovered = Array.isArray(p.uncovered_projects)
|
|
468
|
+
? p.uncovered_projects
|
|
469
|
+
: [];
|
|
470
|
+
const parts = [
|
|
471
|
+
`⚠ **Workspace plan partial coverage:** ${workspaceTitle(envelope)}`,
|
|
472
|
+
];
|
|
473
|
+
parts.push(
|
|
474
|
+
` **Mode:** ${p.mode ?? '?'} — ${uncovered.length} uncovered project${uncovered.length === 1 ? '' : 's'}`,
|
|
475
|
+
);
|
|
476
|
+
if (uncovered.length > 0) {
|
|
477
|
+
parts.push(` **Uncovered:** ${uncovered.join(', ')}`);
|
|
478
|
+
}
|
|
479
|
+
return mdMsg(parts.join('\n'), 'warning');
|
|
480
|
+
}
|
|
481
|
+
|
|
456
482
|
function renderWorkspaceTierStarted(envelope) {
|
|
457
483
|
const p = envelope.payload ?? {};
|
|
458
484
|
const projects = Array.isArray(p.projects) ? p.projects : [];
|
|
@@ -570,6 +596,8 @@ export const OPT_IN_RENDERERS = {
|
|
|
570
596
|
'workspace.plan.started': renderWorkspacePlanStarted,
|
|
571
597
|
'workspace.plan.completed': renderWorkspacePlanCompleted,
|
|
572
598
|
'workspace.plan.failed': renderWorkspacePlanFailed,
|
|
599
|
+
'workspace.plan.loaded': renderWorkspacePlanLoaded,
|
|
600
|
+
'workspace.plan.partial': renderWorkspacePlanPartial,
|
|
573
601
|
'workspace.tier.started': renderWorkspaceTierStarted,
|
|
574
602
|
'workspace.tier.completed': renderWorkspaceTierCompleted,
|
|
575
603
|
'workspace.integration_test.started': renderWorkspaceIntegrationStarted,
|
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,12 @@
|
|
|
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 { safeWatch } from './safe-watch.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Enrich a status object with dispatch events read from events.jsonl in the
|
|
@@ -366,7 +367,7 @@ export function watchEvents(runDir, callback) {
|
|
|
366
367
|
function startFileWatcher() {
|
|
367
368
|
if (closed || fileWatcher) return;
|
|
368
369
|
try {
|
|
369
|
-
fileWatcher =
|
|
370
|
+
fileWatcher = safeWatch(eventsPath, (eventType) => {
|
|
370
371
|
if (eventType === 'change') {
|
|
371
372
|
processNewContent();
|
|
372
373
|
} else if (eventType === 'rename') {
|
|
@@ -405,7 +406,7 @@ export function watchEvents(runDir, callback) {
|
|
|
405
406
|
// Watch the run directory so we detect events.jsonl being created
|
|
406
407
|
if (existsSync(runDir)) {
|
|
407
408
|
try {
|
|
408
|
-
dirWatcher =
|
|
409
|
+
dirWatcher = safeWatch(
|
|
409
410
|
runDir,
|
|
410
411
|
{ recursive: false },
|
|
411
412
|
(_eventType, filename) => {
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
} from './paths.js';
|
|
30
30
|
|
|
31
31
|
const GUIDE_CAP_BYTES_DEFAULT = 64 * 1024;
|
|
32
|
+
const PROJECT_PLAN_CAP_BYTES = 256 * 1024;
|
|
33
|
+
const WORKSPACE_PLAN_CAP_BYTES = 256 * 1024;
|
|
32
34
|
|
|
33
35
|
const WS_ID_RE = /^ws_\d{12}_[0-9a-f]{1,32}$/;
|
|
34
36
|
|
|
@@ -557,6 +559,105 @@ function parseMultipart(body, contentType) {
|
|
|
557
559
|
return parts;
|
|
558
560
|
}
|
|
559
561
|
|
|
562
|
+
// ─── plan strategy resolution ─────────────────────────────────────────────
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Map a plan_mode to the manifest fields it implies and validate
|
|
566
|
+
* mode-specific preconditions.
|
|
567
|
+
*
|
|
568
|
+
* @param {string} plan_mode - 'master' | 'existing' | 'per-repo' | 'independent'
|
|
569
|
+
* @param {string|null} workspace_plan_path
|
|
570
|
+
* @param {Object|null} project_plans - { projectName: filePath, ... }
|
|
571
|
+
* @param {Object} ws - parsed workspace.json (needs ws.projects)
|
|
572
|
+
* @returns {{ ok: true, fields: object } | { ok: false, status: number, error: string }}
|
|
573
|
+
*/
|
|
574
|
+
export function _resolvePlanStrategy(
|
|
575
|
+
plan_mode,
|
|
576
|
+
workspace_plan_path,
|
|
577
|
+
project_plans,
|
|
578
|
+
ws,
|
|
579
|
+
) {
|
|
580
|
+
const mode = plan_mode || 'master';
|
|
581
|
+
const projectNames = new Set((ws.projects ?? []).map((p) => p.name));
|
|
582
|
+
|
|
583
|
+
if (mode === 'existing') {
|
|
584
|
+
if (!workspace_plan_path) {
|
|
585
|
+
return {
|
|
586
|
+
ok: false,
|
|
587
|
+
status: 400,
|
|
588
|
+
error:
|
|
589
|
+
'existing mode requires a workspace plan (upload or server-side path)',
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
ok: true,
|
|
594
|
+
fields: {
|
|
595
|
+
plan_mode: 'existing',
|
|
596
|
+
workspace_plan_path,
|
|
597
|
+
project_plans: null,
|
|
598
|
+
skip_planning: false,
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (mode === 'per-repo') {
|
|
604
|
+
const plans = project_plans ?? {};
|
|
605
|
+
const planKeys = Object.keys(plans);
|
|
606
|
+
if (planKeys.length === 0) {
|
|
607
|
+
return {
|
|
608
|
+
ok: false,
|
|
609
|
+
status: 400,
|
|
610
|
+
error: 'per-repo mode requires at least one project plan',
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
const unknown = planKeys.filter((name) => !projectNames.has(name));
|
|
614
|
+
if (unknown.length > 0) {
|
|
615
|
+
return {
|
|
616
|
+
ok: false,
|
|
617
|
+
status: 422,
|
|
618
|
+
error: `Unknown project(s) in per-repo plans: ${unknown.join(', ')}`,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
ok: true,
|
|
623
|
+
fields: {
|
|
624
|
+
plan_mode: 'per-repo',
|
|
625
|
+
workspace_plan_path: null,
|
|
626
|
+
project_plans: plans,
|
|
627
|
+
skip_planning: false,
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (mode === 'independent') {
|
|
633
|
+
return {
|
|
634
|
+
ok: true,
|
|
635
|
+
fields: {
|
|
636
|
+
plan_mode: 'independent',
|
|
637
|
+
workspace_plan_path: null,
|
|
638
|
+
project_plans: null,
|
|
639
|
+
skip_planning: true,
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// master (default) — ignore any stray plan inputs. master mode always runs
|
|
645
|
+
// the master planner; the Python CLI infers its mode from which flags are
|
|
646
|
+
// present, so passing --workspace-plan/--project-plan through here would
|
|
647
|
+
// silently run the job as existing/per-repo while the manifest (and badge)
|
|
648
|
+
// still claimed "master". Null them out to keep declared mode and actual
|
|
649
|
+
// behavior in lockstep.
|
|
650
|
+
return {
|
|
651
|
+
ok: true,
|
|
652
|
+
fields: {
|
|
653
|
+
plan_mode: 'master',
|
|
654
|
+
workspace_plan_path: null,
|
|
655
|
+
project_plans: null,
|
|
656
|
+
skip_planning: false,
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
560
661
|
// ─── default injectables ──────────────────────────────────────────────────
|
|
561
662
|
|
|
562
663
|
function defaultValidateBaseBranch(repoPath, branch) {
|
|
@@ -929,6 +1030,8 @@ export function createWorkspaceRouter({
|
|
|
929
1030
|
|
|
930
1031
|
let fields = {};
|
|
931
1032
|
const guideFiles = [];
|
|
1033
|
+
let workspacePlanFileData = null;
|
|
1034
|
+
const projectPlanFiles = {};
|
|
932
1035
|
|
|
933
1036
|
if (isMultipart) {
|
|
934
1037
|
const rawBody = await readRawBody(req);
|
|
@@ -939,7 +1042,21 @@ export function createWorkspaceRouter({
|
|
|
939
1042
|
.json({ ok: false, error: 'Failed to parse multipart body' });
|
|
940
1043
|
}
|
|
941
1044
|
for (const part of parts) {
|
|
942
|
-
if (part.filename != null) {
|
|
1045
|
+
if (part.name === 'workspace_plan_file' && part.filename != null) {
|
|
1046
|
+
workspacePlanFileData = {
|
|
1047
|
+
filename: part.filename,
|
|
1048
|
+
content: part.content,
|
|
1049
|
+
};
|
|
1050
|
+
} else if (
|
|
1051
|
+
part.name?.startsWith('project_plan_') &&
|
|
1052
|
+
part.filename != null
|
|
1053
|
+
) {
|
|
1054
|
+
const projectName = part.name.slice('project_plan_'.length);
|
|
1055
|
+
projectPlanFiles[projectName] = {
|
|
1056
|
+
filename: part.filename,
|
|
1057
|
+
content: part.content,
|
|
1058
|
+
};
|
|
1059
|
+
} else if (part.filename != null) {
|
|
943
1060
|
guideFiles.push({ filename: part.filename, content: part.content });
|
|
944
1061
|
} else if (part.name) {
|
|
945
1062
|
fields[part.name] = part.content.toString('utf8');
|
|
@@ -1035,6 +1152,57 @@ export function createWorkspaceRouter({
|
|
|
1035
1152
|
guideEntry = { paths, bytes: totalBytes, filenames, uploaded: true };
|
|
1036
1153
|
}
|
|
1037
1154
|
|
|
1155
|
+
// ── workspace plan file upload / server-side path ──────────────
|
|
1156
|
+
let workspacePlanPath = null;
|
|
1157
|
+
if (workspacePlanFileData) {
|
|
1158
|
+
if (workspacePlanFileData.content.length > WORKSPACE_PLAN_CAP_BYTES) {
|
|
1159
|
+
return res.status(400).json({
|
|
1160
|
+
ok: false,
|
|
1161
|
+
error: 'Workspace plan exceeds 256 KB limit',
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
workspacePlanPath = join(wsRunDir, 'workspace-plan.json');
|
|
1165
|
+
writeFileSync(workspacePlanPath, workspacePlanFileData.content);
|
|
1166
|
+
} else if (fields.workspace_plan) {
|
|
1167
|
+
if (!existsSync(fields.workspace_plan)) {
|
|
1168
|
+
return res.status(400).json({
|
|
1169
|
+
ok: false,
|
|
1170
|
+
error: `workspace_plan path not found: ${fields.workspace_plan}`,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
workspacePlanPath = fields.workspace_plan;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// ── per-project plan file uploads ──────────────────────────────
|
|
1177
|
+
const projectPlans = {};
|
|
1178
|
+
for (const [name, file] of Object.entries(projectPlanFiles)) {
|
|
1179
|
+
if (file.content.length > PROJECT_PLAN_CAP_BYTES) {
|
|
1180
|
+
return res.status(400).json({
|
|
1181
|
+
ok: false,
|
|
1182
|
+
error: `Project plan for "${name}" exceeds 256 KB limit`,
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
const safeName = sanitizeFilename(name);
|
|
1186
|
+
const plansDir = join(wsRunDir, 'plans');
|
|
1187
|
+
mkdirSync(plansDir, { recursive: true });
|
|
1188
|
+
const planPath = join(plansDir, `${safeName}.md`);
|
|
1189
|
+
writeFileSync(planPath, file.content);
|
|
1190
|
+
projectPlans[name] = planPath;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// ── resolve plan strategy + validate ──────────────────────────
|
|
1194
|
+
const planResult = _resolvePlanStrategy(
|
|
1195
|
+
plan_mode,
|
|
1196
|
+
workspacePlanPath,
|
|
1197
|
+
Object.keys(projectPlans).length > 0 ? projectPlans : null,
|
|
1198
|
+
ws,
|
|
1199
|
+
);
|
|
1200
|
+
if (!planResult.ok) {
|
|
1201
|
+
return res
|
|
1202
|
+
.status(planResult.status)
|
|
1203
|
+
.json({ ok: false, error: planResult.error });
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1038
1206
|
const tiers = computeTiers(ws.projects);
|
|
1039
1207
|
const dagTiers = tiers.map((projects, i) => ({
|
|
1040
1208
|
tier: i,
|
|
@@ -1054,10 +1222,10 @@ export function createWorkspaceRouter({
|
|
|
1054
1222
|
source: source ?? null,
|
|
1055
1223
|
},
|
|
1056
1224
|
guide: guideEntry,
|
|
1225
|
+
...planResult.fields,
|
|
1057
1226
|
branch_template: branch_template ?? 'workspace/{slug}/{project}',
|
|
1058
1227
|
max_parallel: Number(max_parallel) || 5,
|
|
1059
1228
|
skip_integration: false,
|
|
1060
|
-
skip_planning: plan_mode === 'skip',
|
|
1061
1229
|
status: 'planning',
|
|
1062
1230
|
halt_reason: null,
|
|
1063
1231
|
dag: { tiers: dagTiers },
|
|
@@ -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', '');
|
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;
|