@galda/cli 0.10.82 → 0.10.83

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.
Files changed (2) hide show
  1. package/engine/server.mjs +40 -8
  2. package/package.json +1 -1
package/engine/server.mjs CHANGED
@@ -2982,12 +2982,22 @@ const testRunQueue = createSerialQueue();
2982
2982
  // Manager-run tests (never trust the worker's "all green" claim): run the
2983
2983
  // project's own `npm test` in the goal's worktree and parse node --test's
2984
2984
  // "ℹ pass N / ℹ fail N" (or TAP "# pass/# fail"). Returns {ran,passed,failed,ok}.
2985
- function runTestsOnce(dir) {
2985
+ function testNodePath(dir, dependencySourceDir = null) {
2986
+ // node_modules is intentionally absent from git worktrees. Reuse the base
2987
+ // checkout's installed dependencies before declaring every server/UI test a
2988
+ // product regression; verification must never silently depend on `npm install`.
2989
+ const sep = process.platform === 'win32' ? ';' : ':';
2990
+ const local = [join(dir, 'node_modules'), dependencySourceDir && join(dependencySourceDir, 'node_modules'), join(ROOT, 'node_modules')]
2991
+ .filter(Boolean).filter(existsSync);
2992
+ return [...new Set([...local, ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(sep) : [])])].join(sep);
2993
+ }
2994
+ function runTestsOnce(dir, dependencySourceDir = null) {
2986
2995
  // The timeout is env-overridable so tests can force the killed-run path fast;
2987
2996
  // production keeps the 4-minute default.
2988
2997
  const runTimeout = Number(process.env.MANAGER_TEST_TIMEOUT_MS) || 240000;
2989
2998
  return testRunQueue.run(() => new Promise((res) => {
2990
- execFile('npm', ['test'], { cwd: dir, timeout: runTimeout, env: { ...process.env }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
2999
+ const nodePath = testNodePath(dir, dependencySourceDir);
3000
+ execFile('npm', ['test'], { cwd: dir, timeout: runTimeout, env: { ...process.env, ...(nodePath ? { NODE_PATH: nodePath } : {}) }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
2991
3001
  const text = `${out}\n${err}`;
2992
3002
  const n = (re) => { const m = text.match(re); return m ? Number(m[1]) : null; };
2993
3003
  const passed = n(/ℹ pass (\d+)/) ?? n(/# pass (\d+)/) ?? n(/(\d+) passing/) ?? 0;
@@ -3019,17 +3029,17 @@ function runTestsOnce(dir) {
3019
3029
  });
3020
3030
  }));
3021
3031
  }
3022
- async function runProjectTests(dir) {
3032
+ async function runProjectTests(dir, dependencySourceDir = null) {
3023
3033
  let hasTest = false;
3024
3034
  try { hasTest = !!JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).scripts?.test; } catch { /* no package.json */ }
3025
3035
  if (!hasTest) return { ran: false };
3026
- let r = await runTestsOnce(dir);
3036
+ let r = await runTestsOnce(dir, dependencySourceDir);
3027
3037
  // Flake resistance: the integration suite spawns real servers; under load a
3028
3038
  // startup can time out. Re-run once and take the fewer failures — a real
3029
3039
  // failure fails both runs, a flake clears on the retry — so a good goal is
3030
3040
  // never blocked on a flaky test.
3031
3041
  if (r.failed > 0) {
3032
- const r2 = await runTestsOnce(dir);
3042
+ const r2 = await runTestsOnce(dir, dependencySourceDir);
3033
3043
  const kept = r2.failed <= r.failed ? r2 : r; // the run that saw fewer failures
3034
3044
  r = {
3035
3045
  passed: Math.max(r.passed, r2.passed),
@@ -3144,7 +3154,7 @@ async function runRetestJob(goal, token) {
3144
3154
  } else {
3145
3155
  for (let i = 0; i < RETEST_RUNS; i++) {
3146
3156
  if (!live()) return; // cancelled (UNDO) / superseded / deleted → discard
3147
- const r = await runTestsOnce(wdir);
3157
+ const r = await runTestsOnce(wdir, project.dir);
3148
3158
  if (!live()) return; // cancelled while this run was in flight
3149
3159
  results.push({ ran: true, passed: r.passed, failed: r.failed });
3150
3160
  goal.retest = { state: 'running', runs: results.length, total: RETEST_RUNS, startedAt: goal.retest?.startedAt };
@@ -3509,7 +3519,7 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3509
3519
  const testGateOn = resolveTestGate({ reviewDefinition, env: process.env, force: forceTests });
3510
3520
  if (testGateOn && hasTestRelevantChanges(changedFiles)) {
3511
3521
  goalAct(`Running project tests in the goal worktree (${changedFiles.length} changed file${changedFiles.length === 1 ? '' : 's'}).`);
3512
- goal.testResult = await runProjectTests(wdir);
3522
+ goal.testResult = await runProjectTests(wdir, project.dir);
3513
3523
  goalAct(`Project tests finished: ${goal.testResult.failed ?? 0} failed, ${goal.testResult.passed ?? 0} passed.`);
3514
3524
  } else if (!testGateOn) {
3515
3525
  goal.testResult = { ran: false, skipped: true, skippedReason: 'test gate off (opt-in: requireTests / MANAGER_VERIFY_GATE=on)' };
@@ -4185,6 +4195,16 @@ const server = createServer(async (req, res) => {
4185
4195
  for (const t of removedTasks) logAppend(JSON.stringify({ kind: 'task', id: t.id, deleted: true }));
4186
4196
  projects.splice(idx, 1);
4187
4197
  queues.delete(project.id);
4198
+ // A deleted project's goals must not outlive it: project ids are derived
4199
+ // from the name (uniqueProjectId), so a later "+ New project" click can
4200
+ // regenerate the exact same id and — without this purge — inherit the
4201
+ // old project's leftover goals as if they belonged to the brand-new one
4202
+ // (Masa report 2026-07-27: new project shows Review items on creation).
4203
+ for (const g of goals.filter((g) => g.projectId === project.id)) {
4204
+ goals = goals.filter((x) => x.id !== g.id);
4205
+ logAppend(JSON.stringify({ kind: 'goal', id: g.id, deleted: true }));
4206
+ sendGoalEvent({ ev: 'goal-deleted', id: g.id }, g);
4207
+ }
4188
4208
  saveProjects();
4189
4209
  send({ ev: 'projects', projects, deletedProjectId: project.id });
4190
4210
  return json(res, 200, { ok: true, projects, deletedProjectId: project.id });
@@ -5418,7 +5438,19 @@ async function answerGoalQuestion(goal, question) {
5418
5438
  if (upMatch) {
5419
5439
  const file = join(logDir, 'uploads', upMatch[1]);
5420
5440
  if (!existsSync(file)) { res.writeHead(404); return res.end('not found'); }
5421
- res.writeHead(200, { 'content-type': file.endsWith('.gif') ? 'image/gif' : file.endsWith('.jpg') || file.endsWith('.jpeg') ? 'image/jpeg' : 'image/png' });
5441
+ // Worker-declared Open it artifacts and image attachments share `/upload`.
5442
+ // Never guess that an unknown artifact is a PNG: it turns valid HTML, PDF,
5443
+ // or text into a broken-image page. Known preview formats get their real
5444
+ // browser type; an unknown file downloads safely instead of being misread.
5445
+ const ext = (file.match(/\.([a-z0-9]+)$/i)?.[1] || '').toLowerCase();
5446
+ const types = {
5447
+ html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
5448
+ css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
5449
+ json: 'application/json; charset=utf-8', txt: 'text/plain; charset=utf-8', md: 'text/markdown; charset=utf-8', csv: 'text/csv; charset=utf-8',
5450
+ svg: 'image/svg+xml', png: 'image/png', gif: 'image/gif', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', ico: 'image/x-icon',
5451
+ pdf: 'application/pdf', mp4: 'video/mp4', webm: 'video/webm', wasm: 'application/wasm',
5452
+ };
5453
+ res.writeHead(200, { 'content-type': types[ext] || 'application/octet-stream' });
5422
5454
  return res.end(readFileSync(file));
5423
5455
  }
5424
5456
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.82",
3
+ "version": "0.10.83",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {