@worca/ui 0.43.0 → 0.44.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/styles.css CHANGED
@@ -8170,6 +8170,60 @@ sl-dialog.markdown-dialog::part(body) {
8170
8170
  color: var(--fg);
8171
8171
  }
8172
8172
 
8173
+ .template-action-dialog .dialog-collisions {
8174
+ border-left: 3px solid var(--sl-color-warning-500, #f59e0b);
8175
+ padding: 10px 12px;
8176
+ background: var(--sl-color-warning-50, rgba(245, 158, 11, 0.05));
8177
+ border-radius: 4px;
8178
+ min-width: 0;
8179
+ }
8180
+ .template-action-dialog .dialog-collisions-header {
8181
+ margin-bottom: 6px;
8182
+ }
8183
+ .template-action-dialog .dialog-collision-list {
8184
+ list-style: none;
8185
+ margin: 10px 0 0;
8186
+ padding: 0;
8187
+ display: flex;
8188
+ flex-direction: column;
8189
+ gap: 8px;
8190
+ }
8191
+ .template-action-dialog .dialog-collision-row {
8192
+ display: grid;
8193
+ grid-template-columns: minmax(90px, 140px) minmax(0, 1fr) minmax(0, 1fr);
8194
+ align-items: center;
8195
+ gap: 8px;
8196
+ min-width: 0;
8197
+ }
8198
+ .template-action-dialog .dialog-collision-alias {
8199
+ min-width: 0;
8200
+ overflow: hidden;
8201
+ text-overflow: ellipsis;
8202
+ white-space: nowrap;
8203
+ }
8204
+ .template-action-dialog .dialog-collision-alias code {
8205
+ font-family: var(--sl-font-mono);
8206
+ font-size: 13px;
8207
+ color: var(--fg);
8208
+ }
8209
+ .template-action-dialog .collision-action-select,
8210
+ .template-action-dialog .collision-rename-input {
8211
+ width: 100%;
8212
+ min-width: 0;
8213
+ }
8214
+ .template-action-dialog .dialog-collision-row[data-rename="false"] .collision-action-select {
8215
+ grid-column: 2 / span 2;
8216
+ }
8217
+ .template-action-dialog .dialog-new-aliases-hint {
8218
+ margin-top: 8px;
8219
+ }
8220
+ .template-action-dialog .dialog-new-aliases-hint code {
8221
+ font-family: var(--sl-font-mono);
8222
+ font-size: 12px;
8223
+ color: var(--fg);
8224
+ margin-right: 4px;
8225
+ }
8226
+
8173
8227
  .editor-content {
8174
8228
  flex: 1;
8175
8229
  overflow-y: auto;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
package/server/paths.js CHANGED
@@ -23,6 +23,17 @@ export function worcaHome() {
23
23
  return join(homedir(), '.worca');
24
24
  }
25
25
 
26
+ /**
27
+ * @returns {string} Absolute path to the user-global settings.json
28
+ * (`~/.worca/settings.json` by default, $WORCA_HOME-aware). The Python
29
+ * runtime reads this layer first and deep-merges project settings on top,
30
+ * so any UI surface that needs to mirror runtime resolution (e.g. model
31
+ * alias dropdowns) must layer this in.
32
+ */
33
+ export function globalSettingsPath() {
34
+ return join(worcaHome(), 'settings.json');
35
+ }
36
+
26
37
  /**
27
38
  * @param {string=} override Caller-supplied path that wins over $WORCA_HOME.
28
39
  * @returns {string} Absolute path to the fleet-runs directory.
@@ -29,7 +29,7 @@ import { getDefaultBranch } from './git-helpers.js';
29
29
  import { extractAndStripGlobalKeys } from './global-keys.js';
30
30
  import { LaunchLock } from './launch-lock.js';
31
31
  import { createModelEnvRouter } from './model-env-routes.js';
32
- import { preferencesPath } from './paths.js';
32
+ import { globalSettingsPath, preferencesPath } from './paths.js';
33
33
  import { readPreferences } from './preferences.js';
34
34
  import { ProcessManager } from './process-manager.js';
35
35
  import { countRunningPipelinesAcrossProjects } from './process-registry.js';
@@ -45,6 +45,7 @@ import {
45
45
  import {
46
46
  deepMerge,
47
47
  localPathFor,
48
+ readEffectiveSettings,
48
49
  readLocalSettings,
49
50
  readMergedSettings,
50
51
  } from './settings-merge.js';
@@ -488,6 +489,28 @@ export function createProjectScopedRoutes({
488
489
  }
489
490
  });
490
491
 
492
+ // GET /api/projects/:projectId/effective-settings
493
+ //
494
+ // Returns the fully-layered settings the Python runtime sees: user-global
495
+ // base → user-global .local → project base → project .local. Used by UI
496
+ // surfaces that must mirror runtime alias resolution (e.g. the per-agent
497
+ // model dropdown in the template editor — without this, aliases that live
498
+ // only in user-global ~/.worca/settings.json are invisible to projects).
499
+ router.get('/effective-settings', (req, res) => {
500
+ const { settingsPath } = req.project;
501
+ try {
502
+ const merged = readEffectiveSettings(settingsPath, globalSettingsPath());
503
+ res.json({
504
+ worca: merged.worca || {},
505
+ permissions: merged.permissions || {},
506
+ });
507
+ } catch (err) {
508
+ res
509
+ .status(500)
510
+ .json({ error: { code: 'read_error', message: err.message } });
511
+ }
512
+ });
513
+
491
514
  // POST /api/projects/:projectId/settings
492
515
  router.post('/settings', async (req, res) => {
493
516
  const { settingsPath } = req.project;
@@ -81,3 +81,48 @@ export function readLocalSettings(settingsPath) {
81
81
  return {};
82
82
  }
83
83
  }
84
+
85
+ /**
86
+ * Read the full effective settings stack the way the Python runtime resolves
87
+ * it: user-global base → user-global .local → project base → project .local.
88
+ * Each layer deep-merges over the previous. Missing files are treated as
89
+ * empty objects. Used by UI surfaces (model alias dropdowns, etc.) that must
90
+ * mirror what `worca.utils.settings.resolve_model` sees at run time.
91
+ *
92
+ * `globalSettingsPath` is passed in so callers can substitute it during
93
+ * tests; production callers thread the result of `paths.globalSettingsPath()`.
94
+ */
95
+ export function readEffectiveSettings(projectSettingsPath, globalSettingsPath) {
96
+ const layers = [];
97
+ if (globalSettingsPath) {
98
+ try {
99
+ layers.push(JSON.parse(readFileSync(globalSettingsPath, 'utf8')));
100
+ } catch {
101
+ /* missing or invalid — treat as empty */
102
+ }
103
+ const globalLocal = localPathFor(globalSettingsPath);
104
+ if (existsSync(globalLocal)) {
105
+ try {
106
+ layers.push(JSON.parse(readFileSync(globalLocal, 'utf8')));
107
+ } catch {
108
+ /* invalid — skip */
109
+ }
110
+ }
111
+ }
112
+ if (projectSettingsPath) {
113
+ try {
114
+ layers.push(JSON.parse(readFileSync(projectSettingsPath, 'utf8')));
115
+ } catch {
116
+ /* missing or invalid — treat as empty */
117
+ }
118
+ const projectLocal = localPathFor(projectSettingsPath);
119
+ if (existsSync(projectLocal)) {
120
+ try {
121
+ layers.push(JSON.parse(readFileSync(projectLocal, 'utf8')));
122
+ } catch {
123
+ /* invalid — skip */
124
+ }
125
+ }
126
+ }
127
+ return layers.reduce((acc, layer) => deepMerge(acc, layer), {});
128
+ }
@@ -378,6 +378,50 @@ function exportGist(projectRoot, id) {
378
378
  return match ? match[0] : null;
379
379
  }
380
380
 
381
+ function _parseResolutionsHeader(req) {
382
+ const raw = req.headers['x-resolutions'];
383
+ if (!raw) return null;
384
+ try {
385
+ const parsed = JSON.parse(raw);
386
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
387
+ return parsed;
388
+ }
389
+ } catch {
390
+ /* fall through */
391
+ }
392
+ return null;
393
+ }
394
+
395
+ function _writeResolutionsFile(dir, resolutions) {
396
+ if (!resolutions || Object.keys(resolutions).length === 0) return null;
397
+ const path = join(dir, 'resolutions.json');
398
+ writeFileSync(path, JSON.stringify(resolutions), 'utf8');
399
+ return path;
400
+ }
401
+
402
+ function _baseImportArgs(
403
+ bundlePath,
404
+ dstTier,
405
+ resolutionsPath,
406
+ onModelConflict,
407
+ ) {
408
+ const args = [
409
+ 'import',
410
+ '--from',
411
+ bundlePath,
412
+ '--scope',
413
+ dstTier,
414
+ '--non-interactive',
415
+ ];
416
+ if (resolutionsPath) {
417
+ args.push('--resolutions', resolutionsPath);
418
+ }
419
+ if (onModelConflict) {
420
+ args.push('--on-model-conflict', onModelConflict);
421
+ }
422
+ return args;
423
+ }
424
+
381
425
  function handleZipImport(req, res) {
382
426
  const dstTier = req.query.dst_tier;
383
427
  if (!isValidTier(dstTier)) {
@@ -389,18 +433,18 @@ function handleZipImport(req, res) {
389
433
  if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
390
434
 
391
435
  const zipBuffer = req.body;
392
- const tmpPath = join(
393
- tmpdir(),
394
- `worca-import-${Math.random().toString(36).slice(2)}.zip`,
395
- );
436
+ const resolutions = _parseResolutionsHeader(req);
437
+ const onModelConflict = req.query.on_model_conflict || null;
438
+ const dir = mkdtempSync(join(tmpdir(), 'worca-import-'));
439
+ const tmpPath = join(dir, 'bundle.zip');
396
440
  try {
397
441
  writeFileSync(tmpPath, zipBuffer);
442
+ const resolutionsPath = _writeResolutionsFile(dir, resolutions);
398
443
  const stdout = runWorcaTemplates(
399
444
  req.project.projectRoot,
400
- ['import', '--from', tmpPath, '--scope', dstTier, '--non-interactive'],
445
+ _baseImportArgs(tmpPath, dstTier, resolutionsPath, onModelConflict),
401
446
  { timeout: 60000 },
402
447
  );
403
- // Best-effort summary: the CLI may print imported template ids to stdout
404
448
  const imported = [];
405
449
  for (const line of (stdout || '').split('\n')) {
406
450
  const m = line.match(/imported[:\s]+([a-z0-9_-]+)/i);
@@ -412,16 +456,76 @@ function handleZipImport(req, res) {
412
456
  .status(statusForCliCode(err.cliCode))
413
457
  .json({ ok: false, error: err.message, code: err.cliCode });
414
458
  } finally {
459
+ cleanupTemp(dir);
460
+ }
461
+ }
462
+
463
+ function handleImportPreview(req, res) {
464
+ const dstTier = req.query.dst_tier;
465
+ if (!isValidTier(dstTier)) {
466
+ return res.status(400).json({
467
+ ok: false,
468
+ error: `dst_tier must be one of: ${TIERS.join(', ')}`,
469
+ });
470
+ }
471
+ if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
472
+
473
+ const ctype = req.headers['content-type'] || '';
474
+ const dir = mkdtempSync(join(tmpdir(), 'worca-import-preview-'));
475
+ try {
476
+ let bundlePath;
477
+ if (ctype.startsWith('application/zip')) {
478
+ bundlePath = join(dir, 'bundle.zip');
479
+ writeFileSync(bundlePath, req.body);
480
+ } else {
481
+ const { bundle } = req.body || {};
482
+ if (!bundle || typeof bundle !== 'object') {
483
+ return res
484
+ .status(400)
485
+ .json({ ok: false, error: 'bundle must be a JSON object' });
486
+ }
487
+ bundlePath = join(dir, 'bundle.json');
488
+ writeFileSync(bundlePath, JSON.stringify(bundle), 'utf8');
489
+ }
490
+ const stdout = runWorcaTemplates(
491
+ req.project.projectRoot,
492
+ [
493
+ 'import',
494
+ '--from',
495
+ bundlePath,
496
+ '--scope',
497
+ dstTier,
498
+ '--non-interactive',
499
+ '--preview',
500
+ ],
501
+ { timeout: 30000 },
502
+ );
415
503
  try {
416
- rmSync(tmpPath, { force: true });
504
+ const payload = JSON.parse(stdout);
505
+ res.json({ ok: true, ...payload });
417
506
  } catch {
418
- /* ignore cleanup errors */
507
+ res.status(500).json({
508
+ ok: false,
509
+ error: 'CLI preview output was not valid JSON',
510
+ raw: stdout,
511
+ });
419
512
  }
513
+ } catch (err) {
514
+ res
515
+ .status(statusForCliCode(err.cliCode))
516
+ .json({ ok: false, error: err.message, code: err.cliCode });
517
+ } finally {
518
+ cleanupTemp(dir);
420
519
  }
421
520
  }
422
521
 
423
522
  function handleJsonImport(req, res) {
424
- const { bundle, dst_tier: dstTier } = req.body || {};
523
+ const {
524
+ bundle,
525
+ dst_tier: dstTier,
526
+ resolutions,
527
+ on_model_conflict: onModelConflict,
528
+ } = req.body || {};
425
529
  if (!bundle || typeof bundle !== 'object') {
426
530
  return res
427
531
  .status(400)
@@ -435,7 +539,10 @@ function handleJsonImport(req, res) {
435
539
  }
436
540
  if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
437
541
  try {
438
- const result = importBundle(req.project.projectRoot, bundle, dstTier);
542
+ const result = importBundle(req.project.projectRoot, bundle, dstTier, {
543
+ resolutions,
544
+ onModelConflict,
545
+ });
439
546
  res.json({ ok: true, ...result });
440
547
  } catch (err) {
441
548
  res
@@ -444,17 +551,19 @@ function handleJsonImport(req, res) {
444
551
  }
445
552
  }
446
553
 
447
- function importBundle(projectRoot, bundle, tier) {
554
+ function importBundle(projectRoot, bundle, tier, opts = {}) {
448
555
  if (!bundle || !Array.isArray(bundle.templates)) {
449
556
  throw new Error('Bundle must contain a "templates" array');
450
557
  }
558
+ const { resolutions, onModelConflict } = opts;
451
559
  const dir = mkdtempSync(join(tmpdir(), 'worca-import-'));
452
560
  const bundlePath = join(dir, 'bundle.json');
453
561
  try {
454
562
  writeFileSync(bundlePath, JSON.stringify(bundle), 'utf8');
563
+ const resolutionsPath = _writeResolutionsFile(dir, resolutions);
455
564
  runWorcaTemplates(
456
565
  projectRoot,
457
- ['import', '--from', bundlePath, '--scope', tier, '--non-interactive'],
566
+ _baseImportArgs(bundlePath, tier, resolutionsPath, onModelConflict),
458
567
  { timeout: 60000 },
459
568
  );
460
569
  const targetDir = dirForTier(projectRoot, tier);
@@ -593,6 +702,12 @@ export function createTemplatesRoutes() {
593
702
  },
594
703
  );
595
704
 
705
+ router.post(
706
+ '/templates/import/preview',
707
+ expressRaw({ type: 'application/zip', limit: '1mb' }),
708
+ (req, res) => handleImportPreview(req, res),
709
+ );
710
+
596
711
  /**
597
712
  * POST /api/projects/:projectId/templates/validate
598
713
  * Body: { config }