@worca/ui 0.28.0 → 0.30.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.
@@ -0,0 +1,234 @@
1
+ import { execFileSync, spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, realpathSync, rmSync, statSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { isAbsolute, join } from 'node:path';
6
+
7
+ // Mirror of _GRAPHIFY_DEFAULTS in src/worca/utils/graphify.py — keep in sync.
8
+ const GRAPHIFY_DEFAULTS = {
9
+ enabled: false,
10
+ mode: 'structural',
11
+ backend: null,
12
+ model_profile: null,
13
+ out_dir: 'graphify-out',
14
+ update_on: { preflight: true, guardian_post_commit: true },
15
+ min_repo_files: 100,
16
+ version_range: '>=0.8.16,<1',
17
+ preflight_timeout_seconds: 300,
18
+ freshness: 'clean_only',
19
+ };
20
+
21
+ // Mirror of effective_graphify_config() in src/worca/utils/graphify.py.
22
+ // Enablement is project-level: the project opts in via graphify.enabled. Global
23
+ // graphify.enabled is purely a kill-switch — an EXPLICIT global `false` disables
24
+ // everywhere; `true`/unset defer to the project. These rules MUST match the
25
+ // Python implementation; the parity is guarded by graphify-status.test.js
26
+ // ("effective-config parity with Python"). Update both together.
27
+ export function _effectiveConfig(globalSettings, projectSettings) {
28
+ const gGraphify = globalSettings?.worca?.graphify ?? {};
29
+ const pGraphify = projectSettings?.worca?.graphify ?? {};
30
+
31
+ // Only an explicit global `enabled: false` disables; `true`/unset defer.
32
+ if (gGraphify.enabled === false) {
33
+ return { ...GRAPHIFY_DEFAULTS, enabled: false, reason: 'global-off' };
34
+ }
35
+
36
+ const projectEnabled = pGraphify.enabled ?? false;
37
+ if (!projectEnabled) {
38
+ return { ...GRAPHIFY_DEFAULTS, enabled: false, reason: 'project-off' };
39
+ }
40
+
41
+ const merged = { ...GRAPHIFY_DEFAULTS };
42
+ for (const [k, v] of Object.entries(gGraphify)) {
43
+ if (v != null || k === 'enabled') merged[k] = v;
44
+ }
45
+ for (const [k, v] of Object.entries(pGraphify)) {
46
+ if (v != null || k === 'enabled') merged[k] = v;
47
+ }
48
+
49
+ return {
50
+ enabled: true,
51
+ mode: merged.mode,
52
+ backend: merged.backend,
53
+ model_profile: merged.model_profile,
54
+ out_dir: merged.out_dir,
55
+ update_on: merged.update_on,
56
+ min_repo_files: merged.min_repo_files,
57
+ version_range: merged.version_range,
58
+ preflight_timeout_seconds: merged.preflight_timeout_seconds,
59
+ freshness: merged.freshness,
60
+ reason: null,
61
+ };
62
+ }
63
+
64
+ // ─── Per-commit cache resolution (mirrors utils/paths.py + utils/git.py) ────
65
+
66
+ export function cacheDir() {
67
+ if (process.env.WORCA_CACHE) return process.env.WORCA_CACHE;
68
+ const home = process.env.WORCA_HOME || join(homedir(), '.worca');
69
+ return join(home, 'cache');
70
+ }
71
+
72
+ export function repoId(projectRoot) {
73
+ try {
74
+ const common = execFileSync(
75
+ 'git',
76
+ ['-C', projectRoot, 'rev-parse', '--git-common-dir'],
77
+ { encoding: 'utf-8' },
78
+ ).trim();
79
+ if (!common) return null;
80
+ const abs = isAbsolute(common) ? common : join(projectRoot, common);
81
+ const real = realpathSync(abs);
82
+ return createHash('sha256').update(real).digest('hex').slice(0, 12);
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ export function headSha(projectRoot) {
89
+ try {
90
+ return execFileSync('git', ['-C', projectRoot, 'rev-parse', 'HEAD'], {
91
+ encoding: 'utf-8',
92
+ }).trim();
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /** Absolute snapshot dir for the project's current HEAD, or null. */
99
+ export function snapshotDir(projectRoot) {
100
+ const rid = repoId(projectRoot);
101
+ const sha = headSha(projectRoot);
102
+ if (!rid || !sha) return null;
103
+ return join(cacheDir(), 'ast', rid, sha);
104
+ }
105
+
106
+ /** The per-project cache dir (<cache>/ast/<repo-id>/), or null if not a repo. */
107
+ export function repoCacheDir(projectRoot) {
108
+ const rid = repoId(projectRoot);
109
+ if (!rid) return null;
110
+ return join(cacheDir(), 'ast', rid);
111
+ }
112
+
113
+ /** Remove all cached snapshots for the project's repo. Returns the path or null. */
114
+ export function clearRepoCache(projectRoot) {
115
+ const repoCache = repoCacheDir(projectRoot);
116
+ if (!repoCache) return null;
117
+ rmSync(repoCache, { recursive: true, force: true });
118
+ return repoCache;
119
+ }
120
+
121
+ /** Stats for a per-commit snapshot dir, or null if not complete/present. */
122
+ export function _graphStats(snapDir) {
123
+ if (!snapDir || !existsSync(join(snapDir, '.complete'))) return null;
124
+ const reportPath = join(snapDir, 'graphify', 'GRAPH_REPORT.md');
125
+ if (!existsSync(reportPath)) return null;
126
+
127
+ const stat = statSync(reportPath);
128
+ const ageSeconds = Math.max(0, (Date.now() - stat.mtimeMs) / 1000);
129
+ const htmlPath = join(snapDir, 'graphify', 'graph.html');
130
+ const graphJsonPath = join(snapDir, 'graphify', 'graph.json');
131
+
132
+ return {
133
+ report_path: reportPath,
134
+ // The queryable dataset for humans: `graphify query … --graph <path>`.
135
+ // null when the snapshot lacks graph.json (older/partial builds).
136
+ graph_json_path: existsSync(graphJsonPath) ? graphJsonPath : null,
137
+ snapshot_dir: snapDir,
138
+ age_seconds: ageSeconds,
139
+ size_bytes: stat.size,
140
+ has_html: existsSync(htmlPath),
141
+ };
142
+ }
143
+
144
+ function defaultDetect() {
145
+ return new Promise((resolve) => {
146
+ const child = spawn(
147
+ 'python3',
148
+ [
149
+ '-c',
150
+ 'import json; from worca.utils.graphify import detect_graphify; d = detect_graphify(); print(json.dumps({"installed": d.installed, "version": d.version, "compatible": d.compatible, "backend_env_present": d.backend_env_present, "error": d.error}))',
151
+ ],
152
+ { stdio: ['ignore', 'pipe', 'pipe'] },
153
+ );
154
+
155
+ let stdout = '';
156
+ let stderr = '';
157
+ child.stdout.on('data', (chunk) => {
158
+ stdout += chunk.toString();
159
+ });
160
+ child.stderr.on('data', (chunk) => {
161
+ stderr += chunk.toString();
162
+ });
163
+ child.on('error', () => {
164
+ resolve({
165
+ installed: false,
166
+ version: null,
167
+ compatible: false,
168
+ backend_env_present: [],
169
+ error: 'python3 not available',
170
+ });
171
+ });
172
+ child.on('exit', (code) => {
173
+ if (code === 0 && stdout.trim()) {
174
+ try {
175
+ resolve(JSON.parse(stdout.trim()));
176
+ return;
177
+ } catch {
178
+ // fall through
179
+ }
180
+ }
181
+ resolve({
182
+ installed: false,
183
+ version: null,
184
+ compatible: false,
185
+ backend_env_present: [],
186
+ error: stderr.trim() || `detect exited ${code}`,
187
+ });
188
+ });
189
+ });
190
+ }
191
+
192
+ const DEFAULT_TTL_MS = 60_000;
193
+
194
+ export function createGraphifyStatus(opts = {}) {
195
+ const detectFn = opts.detectFn || defaultDetect;
196
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
197
+
198
+ let cached = null;
199
+ let cachedAt = 0;
200
+
201
+ async function detect() {
202
+ const now = Date.now();
203
+ if (cached && now - cachedAt < ttlMs) return cached;
204
+ cached = await detectFn();
205
+ cachedAt = Date.now();
206
+ return cached;
207
+ }
208
+
209
+ function invalidate() {
210
+ cached = null;
211
+ cachedAt = 0;
212
+ }
213
+
214
+ async function getStatus({ globalSettings, projectSettings, projectRoot }) {
215
+ const effective = _effectiveConfig(globalSettings, projectSettings);
216
+ const detection = await detect();
217
+ const graphStats = effective.enabled
218
+ ? _graphStats(snapshotDir(projectRoot))
219
+ : null;
220
+ return {
221
+ ok: true,
222
+ effective,
223
+ detection,
224
+ graph_stats: graphStats,
225
+ // The cache path is a pure function of the repo location (it's null only
226
+ // when projectRoot isn't a git repo), so resolve it regardless of whether
227
+ // graphify is enabled. This lets the UI show the path immediately when
228
+ // the user toggles graphify on in-memory, before the setting is saved.
229
+ cache_path: repoCacheDir(projectRoot),
230
+ };
231
+ }
232
+
233
+ return { detect, invalidate, getStatus };
234
+ }
@@ -21,6 +21,9 @@ const VALID_LOOPS = [
21
21
  'restart_planning',
22
22
  'plan_review',
23
23
  ];
24
+ const VALID_EFFORT_RUNGS = ['low', 'medium', 'high', 'xhigh', 'max'];
25
+ const VALID_AUTO_MODES = ['disabled', 'reactive', 'adaptive'];
26
+ const VALID_EFFORT_KEYS = ['auto_mode', 'auto_cap'];
24
27
  const VALID_MILESTONES = ['plan_approval', 'pr_approval', 'deploy_approval'];
25
28
  const VALID_GUARDS = [
26
29
  'block_rm_rf',
@@ -92,6 +95,52 @@ export function validateSettingsPayload(body, options = {}) {
92
95
  );
93
96
  }
94
97
  }
98
+ if (cfg.effort !== undefined) {
99
+ if (
100
+ typeof cfg.effort !== 'string' ||
101
+ !VALID_EFFORT_RUNGS.includes(cfg.effort)
102
+ ) {
103
+ details.push(
104
+ `Invalid effort "${cfg.effort}" for agent "${name}". Must be one of: ${VALID_EFFORT_RUNGS.join(', ')}`,
105
+ );
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ // effort
113
+ if (w.effort !== undefined) {
114
+ if (
115
+ typeof w.effort !== 'object' ||
116
+ w.effort === null ||
117
+ Array.isArray(w.effort)
118
+ ) {
119
+ details.push('effort must be an object');
120
+ } else {
121
+ const ef = w.effort;
122
+ for (const key of Object.keys(ef)) {
123
+ if (!VALID_EFFORT_KEYS.includes(key)) {
124
+ details.push(`Unknown effort key: "${key}"`);
125
+ }
126
+ }
127
+ if (
128
+ ef.auto_mode !== undefined &&
129
+ (typeof ef.auto_mode !== 'string' ||
130
+ !VALID_AUTO_MODES.includes(ef.auto_mode))
131
+ ) {
132
+ details.push(
133
+ `effort.auto_mode must be one of: ${VALID_AUTO_MODES.join(', ')}`,
134
+ );
135
+ }
136
+ if (
137
+ ef.auto_cap !== undefined &&
138
+ (typeof ef.auto_cap !== 'string' ||
139
+ !VALID_EFFORT_RUNGS.includes(ef.auto_cap))
140
+ ) {
141
+ details.push(
142
+ `effort.auto_cap must be one of: ${VALID_EFFORT_RUNGS.join(', ')}`,
143
+ );
95
144
  }
96
145
  }
97
146
  }
@@ -388,6 +437,16 @@ export function validateSettingsPayload(body, options = {}) {
388
437
  );
389
438
  }
390
439
  }
440
+ if (g.dispatch_migration_version !== undefined) {
441
+ if (
442
+ !Number.isInteger(g.dispatch_migration_version) ||
443
+ g.dispatch_migration_version < 0
444
+ ) {
445
+ details.push(
446
+ 'dispatch_migration_version must be a non-negative integer',
447
+ );
448
+ }
449
+ }
391
450
  if (g.dispatch !== undefined) {
392
451
  if (
393
452
  typeof g.dispatch !== 'object' ||