@polymorphism-tech/morph-spec 9.5.0 → 9.7.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.
Files changed (80) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +13 -5
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/agents.json +3 -3
  7. package/framework/docs/CLI.md +43 -11
  8. package/framework/evals/review-checklist.md +2 -2
  9. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  10. package/framework/hooks/claude-code/core/README.md +4 -0
  11. package/framework/hooks/claude-code/core/heavy-gate.js +5 -3
  12. package/framework/hooks/claude-code/core/test-budget.js +89 -0
  13. package/framework/hooks/claude-code/statusline.sh +11 -11
  14. package/framework/hooks/shared/e2e-check.js +0 -0
  15. package/framework/hooks/shared/feature-locator.js +7 -5
  16. package/framework/hooks/shared/heavy-command.js +72 -1
  17. package/framework/hooks/shared/heavy-config.js +28 -1
  18. package/framework/hooks/shared/heavy-slots.js +41 -5
  19. package/framework/hooks/shared/host-services.js +433 -0
  20. package/framework/schemas/config.schema.json +65 -2
  21. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  22. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  23. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  24. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  25. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  26. package/framework/skills/core/morph-media/SKILL.md +1 -1
  27. package/framework/skills/core/morph-plan/SKILL.md +3 -3
  28. package/framework/skills/core/morph-review/SKILL.md +3 -2
  29. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  30. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  31. package/framework/standards/STANDARDS.json +2 -1
  32. package/framework/standards/infrastructure/docker/local-compose-isolation.md +15 -1
  33. package/framework/templates/IDE-SNIPPETS.md +262 -262
  34. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  35. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  36. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  37. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  38. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  39. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  40. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  41. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  42. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  43. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  44. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  45. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  46. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  47. package/framework/templates/state.template.json +17 -17
  48. package/framework/templates/ui/design-system.css +226 -226
  49. package/package.json +1 -1
  50. package/src/commands/e2e/e2e.js +50 -24
  51. package/src/commands/observability/cost.js +12 -0
  52. package/src/commands/project/env.js +3 -3
  53. package/src/commands/project/heavy.js +10 -3
  54. package/src/commands/project/worktree.js +9 -4
  55. package/src/commands/state/advance.js +3 -0
  56. package/src/commands/state/create.js +3 -3
  57. package/src/commands/state/delete.js +1 -1
  58. package/src/commands/state/finish.js +20 -4
  59. package/src/commands/validation/mutate.js +2 -2
  60. package/src/commands/validation/plan-check.js +1 -0
  61. package/src/commands/validation/verify.js +3 -0
  62. package/src/dashboard/app/main.jsx +13 -13
  63. package/src/dashboard/vite.config.js +24 -24
  64. package/src/lib/e2e/compose-env.js +26 -3
  65. package/src/lib/e2e/stack-teardown.js +19 -4
  66. package/src/lib/heavy/gate.js +23 -4
  67. package/src/lib/heavy/live-stacks.js +64 -0
  68. package/src/lib/review-reports.js +21 -10
  69. package/src/lib/risk-detector.js +9 -1
  70. package/src/lib/tasks/test-runner.js +3 -0
  71. package/src/lib/telemetry/metrics.js +47 -3
  72. package/src/lib/validators/packages/package-validator.js +2 -2
  73. package/src/lib/worktree/docker-audit.js +7 -3
  74. package/src/lib/worktree/env-allocator.js +27 -5
  75. package/src/lib/worktree/ignored-triage.js +8 -0
  76. package/src/utils/agents-installer.js +4 -1
  77. package/src/utils/file-copier.js +26 -4
  78. package/src/utils/hooks-installer.js +4 -4
  79. package/framework/templates/.idea/morph-templates.xml +0 -92
  80. package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
@@ -7,6 +7,7 @@ export const HEAVY_DEFAULTS = Object.freeze({
7
7
  enabled: true,
8
8
  slots: 2,
9
9
  minFreeMemGb: 4,
10
+ minFreeMemGbByKind: Object.freeze({ unit: 1.5, build: 4, e2e: 4 }),
10
11
  waitTimeoutSec: 600,
11
12
  maxHoldMinutes: 90,
12
13
  shutdownBuildServer: true,
@@ -14,11 +15,16 @@ export const HEAVY_DEFAULTS = Object.freeze({
14
15
  gateBash: false,
15
16
  });
16
17
 
18
+ export const HEAVY_KINDS = Object.freeze(['unit', 'build', 'e2e']);
19
+
17
20
  const isBool = (v) => typeof v === 'boolean';
21
+ const isGb = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
22
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
18
23
  const VALID = {
19
24
  enabled: isBool,
20
25
  slots: (v) => Number.isInteger(v) && v >= 1,
21
- minFreeMemGb: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
26
+ minFreeMemGb: isGb,
27
+ minFreeMemGbByKind: isPlainObject,
22
28
  waitTimeoutSec: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
23
29
  maxHoldMinutes: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0,
24
30
  shutdownBuildServer: isBool,
@@ -40,6 +46,7 @@ export function resolveHeavyConfig(config, env = process.env) {
40
46
  const out = { ...HEAVY_DEFAULTS };
41
47
  const warnings = [];
42
48
  const raw = config?.heavy;
49
+ let byKind = {};
43
50
 
44
51
  if (raw !== undefined) {
45
52
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
@@ -47,12 +54,15 @@ export function resolveHeavyConfig(config, env = process.env) {
47
54
  } else {
48
55
  for (const [key, value] of Object.entries(raw)) {
49
56
  if (!(key in VALID)) warnings.push(`heavy.${key} não existe; ignorada`);
57
+ else if (key === 'minFreeMemGbByKind' && VALID[key](value)) byKind = value;
50
58
  else if (VALID[key](value)) out[key] = value;
51
59
  else warnings.push(`heavy.${key} inválida (${JSON.stringify(value)}); usando ${JSON.stringify(HEAVY_DEFAULTS[key])}`);
52
60
  }
53
61
  }
54
62
  }
55
63
 
64
+ out.minFreeMemGbByKind = resolveByKind(byKind, isGb(raw?.minFreeMemGb) ? raw.minFreeMemGb : null, warnings);
65
+
56
66
  if (/^(off|0|false)$/i.test(env.MORPH_HEAVY ?? '')) out.enabled = false;
57
67
  const envSlots = Number(env.MORPH_HEAVY_SLOTS);
58
68
  if (env.MORPH_HEAVY_SLOTS !== undefined && VALID.slots(envSlots)) out.slots = envSlots;
@@ -60,6 +70,23 @@ export function resolveHeavyConfig(config, env = process.env) {
60
70
  return { ...out, warnings };
61
71
  }
62
72
 
73
+ // An explicit `minFreeMemGb` without a per-kind entry still governs that kind:
74
+ // a project that set 0 (RAM check off) or a stricter floor keeps it after the split.
75
+ function resolveByKind(byKind, explicitFloor, warnings) {
76
+ const out = {};
77
+ for (const kind of HEAVY_KINDS) out[kind] = explicitFloor ?? HEAVY_DEFAULTS.minFreeMemGbByKind[kind];
78
+ for (const [kind, value] of Object.entries(byKind)) {
79
+ if (!HEAVY_KINDS.includes(kind)) warnings.push(`heavy.minFreeMemGbByKind.${kind} não existe; use ${HEAVY_KINDS.join(', ')}`);
80
+ else if (isGb(value)) out[kind] = value;
81
+ else warnings.push(`heavy.minFreeMemGbByKind.${kind} inválida (${JSON.stringify(value)}); usando ${out[kind]}`);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ export function minFreeMemFor(config, kind) {
87
+ return config.minFreeMemGbByKind?.[kind] ?? config.minFreeMemGb;
88
+ }
89
+
63
90
  export function readHeavyConfig(projectPath, env = process.env) {
64
91
  return resolveHeavyConfig(readProjectConfig(projectPath), env);
65
92
  }
@@ -49,6 +49,8 @@ function liveHoldersPruningStale(dir, ctx) {
49
49
  return live;
50
50
  }
51
51
 
52
+ const kindSuffix = (kind) => (kind ? ` (${kind})` : '');
53
+
52
54
  function blockedText(blocked, slots, now) {
53
55
  const holders = blocked.holders.map((h) => describeHolder(h, now)).join('; ');
54
56
  if (blocked.reason === 'exclusive') return `aguardando limpeza do servidor de build (${describeHolder(blocked.exclusive, now)})`;
@@ -56,14 +58,14 @@ function blockedText(blocked, slots, now) {
56
58
  const cause = blocked.holders.length
57
59
  ? `slots em uso: ${holders}`
58
60
  : 'nenhum slot em uso: a pressão vem de fora do semáforo (vmmem do WSL, IDE, outros processos)';
59
- return `aguardando RAM livre: ${blocked.freeGb.toFixed(1)} GB < ${blocked.minFreeMemGb} GB mínimos — ${cause}`;
61
+ return `aguardando RAM livre${kindSuffix(blocked.kind)}: ${blocked.freeGb.toFixed(1)} GB < ${blocked.minFreeMemGb} GB mínimos — ${cause}`;
60
62
  }
61
63
  return `aguardando slot pesado (${blocked.holders.length}/${slots}): ${holders}`;
62
64
  }
63
65
 
64
66
  function tryClaim(dir, opts, ctx) {
65
67
  const {
66
- slots, label, feature, node, cwd, minFreeMemGb, freeMem, lease: leaseSpec,
68
+ slots, label, feature, node, cwd, minFreeMemGb, freeMem, kind, lease: leaseSpec,
67
69
  } = opts;
68
70
  const guarded = withClaimMutex(dir, () => {
69
71
  const holders = liveHoldersPruningStale(dir, ctx);
@@ -71,7 +73,7 @@ function tryClaim(dir, opts, ctx) {
71
73
  if (exclusive) return { blocked: { reason: 'exclusive', holders, exclusive } };
72
74
  if (holders.length >= slots) return { blocked: { reason: 'slots', holders } };
73
75
  const freeGb = freeMem() / GB;
74
- if (minFreeMemGb > 0 && freeGb < minFreeMemGb) return { blocked: { reason: 'memory', holders, freeGb, minFreeMemGb } };
76
+ if (minFreeMemGb > 0 && freeGb < minFreeMemGb) return { blocked: { reason: 'memory', holders, freeGb, minFreeMemGb, kind } };
75
77
 
76
78
  for (let i = 0; i < slots + holders.length + 1; i++) {
77
79
  const path = join(dir, `slot-${i}.lock`);
@@ -124,7 +126,13 @@ export function acquireHeavy(opts) {
124
126
  const { blocked } = attempt;
125
127
  if (waited >= waitTimeoutMs) {
126
128
  return {
127
- status: 'busy', reason: blocked.reason, holders: blocked.holders, freeGb: blocked.freeGb ?? null, waitedMs: waited,
129
+ status: 'busy',
130
+ reason: blocked.reason,
131
+ holders: blocked.holders,
132
+ freeGb: blocked.freeGb ?? null,
133
+ minFreeMemGb: claimOpts.minFreeMemGb,
134
+ kind: opts.kind ?? null,
135
+ waitedMs: waited,
128
136
  };
129
137
  }
130
138
  if (blocked.reason !== 'contended' && now() - lastReport >= REPORT_EVERY_MS) {
@@ -135,6 +143,32 @@ export function acquireHeavy(opts) {
135
143
  }
136
144
  }
137
145
 
146
+ // For a holder whose next node needs more RAM than it was admitted with: it keeps its slot and
147
+ // waits only on memory, since re-claiming would hit the re-entrant path and skip the check.
148
+ export function awaitFreeMemory(opts) {
149
+ const {
150
+ minFreeMemGb, waitTimeoutMs, kind = null,
151
+ now = Date.now, sleep = sleepSync, freeMem = freemem, log = (m) => process.stderr.write(`${m}\n`),
152
+ } = opts;
153
+ const start = now();
154
+ let lastReport = -Infinity;
155
+ for (;;) {
156
+ const freeGb = freeMem() / GB;
157
+ const waited = now() - start;
158
+ if (!(minFreeMemGb > 0) || freeGb >= minFreeMemGb) return { status: 'acquired', waitedMs: waited };
159
+ if (waited >= waitTimeoutMs) {
160
+ return {
161
+ status: 'busy', reason: 'memory', holders: [], freeGb, minFreeMemGb, kind, waitedMs: waited,
162
+ };
163
+ }
164
+ if (now() - lastReport >= REPORT_EVERY_MS) {
165
+ log(`aguardando RAM livre${kindSuffix(kind)}: ${freeGb.toFixed(1)} GB < ${minFreeMemGb} GB mínimos, segurando o slot já tomado`);
166
+ lastReport = now();
167
+ }
168
+ sleep(Math.min(POLL_MS, Math.max(1, waitTimeoutMs - waited)));
169
+ }
170
+ }
171
+
138
172
  export function updateLease(lease, patch) {
139
173
  if (!lease) return;
140
174
  try {
@@ -168,7 +202,9 @@ export function releaseLeaseById(dir, id) {
168
202
  export function describeBusy(result, config) {
169
203
  const waited = Math.round(result.waitedMs / 1000);
170
204
  if (result.reason === 'memory') {
171
- return `sem RAM livre há ${waited}s (${result.freeGb?.toFixed(1)} GB < ${config.minFreeMemGb} GB mínimos, heavy.minFreeMemGb)`;
205
+ const floor = result.minFreeMemGb ?? config.minFreeMemGb;
206
+ const key = result.kind ? `heavy.minFreeMemGbByKind.${result.kind}` : 'heavy.minFreeMemGb';
207
+ return `sem RAM livre há ${waited}s (${result.freeGb?.toFixed(1)} GB < ${floor} GB mínimos${kindSuffix(result.kind)}, ${key})`;
172
208
  }
173
209
  if (result.reason === 'exclusive') return `limpeza do servidor de build ainda em curso após ${waited}s`;
174
210
  return `${result.holders.length}/${config.slots} slots pesados ocupados por ${waited}s (heavy.waitTimeoutSec)`;
@@ -0,0 +1,433 @@
1
+ // Host processes that the e2e stack runs next to compose (api, portal), one PID file per project.
2
+ // Nothing here imports e2e-check.js: it imports this module, and the timing constants mirror its own.
3
+
4
+ import { execFileSync, spawn, spawnSync } from 'child_process';
5
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
6
+ import { join, resolve } from 'path';
7
+
8
+ import { tokenize } from './command-tokens.js';
9
+ import { pidAlive } from './process-alive.js';
10
+
11
+ export const DEFAULT_HOST_TIMEOUT = 180_000;
12
+
13
+ const HEALTH_POLL_MS = 2_000;
14
+
15
+ const OUTPUT_TAIL_LINES = 20;
16
+
17
+ const NO_HEALTH_GRACE_MS = 2_000;
18
+
19
+ const KILL_WAIT_MS = 5_000;
20
+
21
+ const VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
22
+
23
+ export function hostStatePath(gitCommonDir, projectName) {
24
+ return join(gitCommonDir, 'morph-spec', `e2e-host-${projectName}.json`);
25
+ }
26
+
27
+ export function hostLogPath(gitCommonDir, projectName, name) {
28
+ return join(gitCommonDir, 'morph-spec', `e2e-host-${projectName}-${name}.log`);
29
+ }
30
+
31
+ /** Process creation time as the OS reports it, or null when the PID is gone. */
32
+ export function readProcessCreatedAt(pid, platform = process.platform) {
33
+ try {
34
+ const out = platform === 'win32'
35
+ ? execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command',
36
+ `(Get-Process -Id ${Number(pid)}).StartTime.ToFileTimeUtc()`],
37
+ { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
38
+ : execFileSync('ps', ['-o', 'lstart=', '-p', String(Number(pid))],
39
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
40
+ return out.trim() || null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** Resolves `${VAR}` in `env` and `health`: stack env first, then `process.env`. */
47
+ export function interpolateEnv(service, stackEnv = {}) {
48
+ const missing = new Set();
49
+ const sub = (text) => String(text).replace(VAR_RE, (_, name) => {
50
+ const value = stackEnv?.[name] ?? process.env[name];
51
+ if (value === undefined || value === null || value === '') {
52
+ missing.add(name);
53
+ return '';
54
+ }
55
+ return String(value);
56
+ });
57
+ const env = {};
58
+ for (const [key, value] of Object.entries(service.env ?? {})) env[key] = sub(value);
59
+ const health = service.health ? sub(service.health) : null;
60
+ return { env, health, missing: [...missing] };
61
+ }
62
+
63
+ const SERVICE_KEYS = new Set(['name', 'cwd', 'build', 'start', 'env', 'health', 'timeoutMs']);
64
+
65
+ function validateServices(services) {
66
+ if (!Array.isArray(services)) return 'e2e.hostServices must be an array';
67
+ const seen = new Set();
68
+ for (const [i, svc] of services.entries()) {
69
+ const label = typeof svc?.name === 'string' && svc.name ? `"${svc.name}"` : `#${i}`;
70
+ if (!svc || typeof svc !== 'object' || Array.isArray(svc)) return `e2e.hostServices[${i}] must be an object`;
71
+ if (typeof svc.name !== 'string' || !/^[A-Za-z0-9._-]+$/.test(svc.name)) {
72
+ return `e2e.hostServices[${i}].name must be a non-empty string of [A-Za-z0-9._-]`;
73
+ }
74
+ if (seen.has(svc.name)) return `e2e.hostServices has two services named "${svc.name}"`;
75
+ seen.add(svc.name);
76
+ const unknown = Object.keys(svc).find((key) => !SERVICE_KEYS.has(key));
77
+ if (unknown) return `hostService ${label}: unknown field "${unknown}"`;
78
+ if (typeof svc.start !== 'string' || !svc.start.trim()) return `hostService ${label}: "start" must be a non-empty string`;
79
+ for (const field of ['cwd', 'build', 'health']) {
80
+ if (svc[field] !== undefined && typeof svc[field] !== 'string') return `hostService ${label}: "${field}" must be a string`;
81
+ }
82
+ if (svc.env !== undefined) {
83
+ if (!svc.env || typeof svc.env !== 'object' || Array.isArray(svc.env)) return `hostService ${label}: "env" must be an object`;
84
+ for (const [key, value] of Object.entries(svc.env)) {
85
+ if (typeof value !== 'string' && typeof value !== 'number') {
86
+ return `hostService ${label}: env.${key} must be a string or number`;
87
+ }
88
+ }
89
+ }
90
+ if (svc.timeoutMs !== undefined && !(Number.isFinite(svc.timeoutMs) && svc.timeoutMs > 0)) {
91
+ return `hostService ${label}: "timeoutMs" must be a positive number`;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function readState(gitCommonDir, projectName) {
98
+ try {
99
+ const parsed = JSON.parse(readFileSync(hostStatePath(gitCommonDir, projectName), 'utf8').replace(/^/, ''));
100
+ return Array.isArray(parsed?.services) ? parsed.services : null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ function writeState(gitCommonDir, projectName, records) {
107
+ const target = hostStatePath(gitCommonDir, projectName);
108
+ if (records.length === 0) {
109
+ try { unlinkSync(target); } catch { /* already gone */ }
110
+ return;
111
+ }
112
+ mkdirSync(join(gitCommonDir, 'morph-spec'), { recursive: true });
113
+ const tmp = `${target}.tmp.${process.pid}`;
114
+ writeFileSync(tmp, `${JSON.stringify({ version: 1, project: projectName, services: records }, null, 2)}\n`, 'utf8');
115
+ renameSync(tmp, target);
116
+ }
117
+
118
+ function sleep(ms) {
119
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
120
+ }
121
+
122
+ const PROBE_SCRIPT =
123
+ 'fetch(process.argv[1],{signal:AbortSignal.timeout(Number(process.argv[2]))})' +
124
+ '.then(r=>process.exit(r.status<500?0:1),()=>process.exit(1))';
125
+
126
+ function probeHealth(url, timeoutMs = 1_500) {
127
+ const r = spawnSync(process.execPath, ['-e', PROBE_SCRIPT, url, String(timeoutMs)], {
128
+ timeout: timeoutMs + 1_000,
129
+ windowsHide: true,
130
+ stdio: 'ignore',
131
+ });
132
+ return r.status === 0;
133
+ }
134
+
135
+ // Only the recorded identity may be killed: a recycled PID belongs to someone else.
136
+ function killRecord(record, readCreatedAt) {
137
+ if (!running(record.pid)) {
138
+ return { killed: false, warning: `hostService "${record.name}": PID ${record.pid} was no longer running; record dropped` };
139
+ }
140
+ const current = readCreatedAt(record.pid);
141
+ if (!record.createdAt || current !== record.createdAt) {
142
+ return {
143
+ killed: false,
144
+ warning: `hostService "${record.name}": PID ${record.pid} is alive but its creation time differs ` +
145
+ `from the recorded one (recycled PID); not killed, record dropped`,
146
+ };
147
+ }
148
+ if (process.platform === 'win32') killWindowsTree(record.pid);
149
+ else killPosixGroup(record.pid);
150
+ return { killed: true };
151
+ }
152
+
153
+ function waitUntil(predicate) {
154
+ const deadline = Date.now() + KILL_WAIT_MS;
155
+ while (!predicate() && Date.now() < deadline) sleep(100);
156
+ return predicate();
157
+ }
158
+
159
+ // The process that spawned the shell blocks in a sync loop and never reaps it: a zombie is not alive.
160
+ function running(pid) {
161
+ if (!pidAlive(pid)) return false;
162
+ if (process.platform === 'win32') return true;
163
+ const r = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8' });
164
+ return r.status !== 0 || !String(r.stdout).trim().startsWith('Z');
165
+ }
166
+
167
+ function groupAlive(pgid) {
168
+ const r = spawnSync('ps', ['-A', '-o', 'pgid=,stat='], { encoding: 'utf8' });
169
+ if (r.status !== 0) {
170
+ try {
171
+ process.kill(-pgid, 0);
172
+ return true;
173
+ } catch (err) {
174
+ return err?.code === 'EPERM';
175
+ }
176
+ }
177
+ return String(r.stdout).split('\n').some((line) => {
178
+ const [group, stat = ''] = line.trim().split(/\s+/);
179
+ return Number(group) === pgid && !stat.startsWith('Z');
180
+ });
181
+ }
182
+
183
+ // The port belongs to the grandchild, not the shell: wait on the whole group.
184
+ function killPosixGroup(pgid) {
185
+ try { process.kill(-pgid, 'SIGTERM'); } catch { /* group already gone */ }
186
+ if (!waitUntil(() => !groupAlive(pgid))) {
187
+ try { process.kill(-pgid, 'SIGKILL'); } catch { /* group already gone */ }
188
+ waitUntil(() => !groupAlive(pgid));
189
+ }
190
+ }
191
+
192
+ const WIN_SNAPSHOT =
193
+ 'Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ p = $_.ProcessId; pp = $_.ParentProcessId; ' +
194
+ 't = if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 } } } | ConvertTo-Json -Compress';
195
+
196
+ function windowsSnapshot() {
197
+ try {
198
+ const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', WIN_SNAPSHOT],
199
+ { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 });
200
+ const parsed = JSON.parse(out.trim() || '[]');
201
+ return Array.isArray(parsed) ? parsed : [parsed];
202
+ } catch {
203
+ return [];
204
+ }
205
+ }
206
+
207
+ // Windows keeps a dead parent's PID in ParentProcessId, so a child only counts when born after its parent.
208
+ function windowsDescendants(rows, roots) {
209
+ const byPid = new Map(rows.map((r) => [r.p, r]));
210
+ const found = [];
211
+ const queue = [...roots];
212
+ while (queue.length > 0) {
213
+ const parent = byPid.get(queue.shift());
214
+ if (!parent) continue;
215
+ for (const row of rows) {
216
+ if (row.pp === parent.p && row.p !== parent.p && row.t >= parent.t && !found.includes(row.p) && !roots.includes(row.p)) {
217
+ found.push(row.p);
218
+ queue.push(row.p);
219
+ }
220
+ }
221
+ }
222
+ return found;
223
+ }
224
+
225
+ // Every PID goes through process.kill: killing via a console tool is banned in hooks (doctor-procs canary).
226
+ // The rescan catches a child spawned between the snapshot and its parent's death.
227
+ function killWindowsTree(rootPid) {
228
+ const first = windowsSnapshot();
229
+ const killed = [rootPid, ...windowsDescendants(first, [rootPid])];
230
+ for (const pid of killed) {
231
+ try { process.kill(pid); } catch { /* already gone */ }
232
+ }
233
+ const killedRows = first.filter((r) => killed.includes(r.p));
234
+ const rescan = windowsSnapshot().filter((r) => !killed.includes(r.p));
235
+ const stragglers = windowsDescendants([...killedRows, ...rescan], killed);
236
+ for (const pid of stragglers) {
237
+ try { process.kill(pid); } catch { /* already gone */ }
238
+ }
239
+ waitUntil(() => [...killed, ...stragglers].every((pid) => !pidAlive(pid)));
240
+ }
241
+
242
+ function tailLines(text, n = OUTPUT_TAIL_LINES) {
243
+ return String(text || '').trimEnd().split(/\r?\n/).slice(-n).join('\n');
244
+ }
245
+
246
+ function readLogTail(path) {
247
+ try { return tailLines(readFileSync(path, 'utf8')); } catch { return ''; }
248
+ }
249
+
250
+ /** @returns {{ killed: string[], skipped: string[], warnings: string[] }} */
251
+ export function stopHostServices(gitCommonDir, projectName, { readCreatedAt = readProcessCreatedAt } = {}) {
252
+ const result = { killed: [], skipped: [], warnings: [] };
253
+ if (!gitCommonDir) return result;
254
+ const records = readState(gitCommonDir, projectName);
255
+ if (!records) return result;
256
+ for (const record of records) {
257
+ const outcome = killRecord(record, readCreatedAt);
258
+ if (outcome.killed) result.killed.push(record.name);
259
+ else {
260
+ result.skipped.push(record.name);
261
+ result.warnings.push(outcome.warning);
262
+ }
263
+ }
264
+ writeState(gitCommonDir, projectName, []);
265
+ return result;
266
+ }
267
+
268
+ /** Liveness by PID only: the creation-time read is too slow for a poll. */
269
+ export function hostServicesStatus(gitCommonDir, projectName) {
270
+ const records = readState(gitCommonDir, projectName) ?? [];
271
+ return records.map((r) => {
272
+ const alive = pidAlive(r.pid);
273
+ return { name: r.name, pid: r.pid, alive, health: r.health ? alive && probeHealth(r.health) : null };
274
+ });
275
+ }
276
+
277
+ // A detached cmd.exe has no console, so the console apps it starts get a fresh one and drop the
278
+ // log handles; a detached node holding the shell keeps them inherited down the tree.
279
+ const WIN_LAUNCHER =
280
+ "const c=require('child_process').spawn(process.argv[1],{shell:true,stdio:'inherit',windowsHide:true});" +
281
+ 'c.on(\'exit\',(k)=>process.exit(k??1))';
282
+
283
+ function launch(svc, ctx, deadline, exec) {
284
+ const fail = (reason, output = '') => ({ ok: false, reason: `hostService "${svc.name}": ${reason}`, output });
285
+ const remaining = () => deadline - Date.now();
286
+
287
+ if (svc.build) {
288
+ const [file, ...args] = tokenize(svc.build);
289
+ const built = exec(file, args, { cwd: svc.dir, env: svc.childEnv, timeout: remaining() });
290
+ // A build killed at its timeout comes back as a plain non-zero exit: the deadline decides first.
291
+ if (remaining() <= 0) return fail(`timed out after ${svc.timeoutMs} ms during build`, tailLines(built.output));
292
+ if (built.code !== 0) return fail(`build failed (${svc.build}, exit ${built.code})`, tailLines(built.output));
293
+ }
294
+
295
+ const log = hostLogPath(ctx.gitCommonDir, ctx.projectName, svc.name);
296
+ mkdirSync(join(ctx.gitCommonDir, 'morph-spec'), { recursive: true });
297
+ const fd = openSync(log, 'w');
298
+ let child;
299
+ try {
300
+ // The CLI ends with process.exit: the child must own its group and hold none of our pipes.
301
+ const win = process.platform === 'win32';
302
+ child = spawn(win ? process.execPath : svc.start, win ? ['-e', WIN_LAUNCHER, svc.start] : [], {
303
+ cwd: svc.dir,
304
+ env: { ...process.env, ...svc.childEnv },
305
+ shell: !win,
306
+ detached: true,
307
+ windowsHide: true,
308
+ stdio: ['ignore', fd, fd],
309
+ });
310
+ child.on('error', () => {});
311
+ child.unref();
312
+ } finally {
313
+ closeSync(fd);
314
+ }
315
+ if (!child?.pid) return fail(`could not start (${svc.start})`, readLogTail(log));
316
+
317
+ const record = {
318
+ name: svc.name,
319
+ pid: child.pid,
320
+ createdAt: ctx.readCreatedAt(child.pid),
321
+ stamp: ctx.stamp ?? null,
322
+ log,
323
+ health: svc.health,
324
+ };
325
+
326
+ if (!svc.health) {
327
+ sleep(Math.min(NO_HEALTH_GRACE_MS, Math.max(remaining(), 0)));
328
+ if (!running(child.pid)) return { ...fail('exited right after start', readLogTail(log)), record };
329
+ return { ok: true, record };
330
+ }
331
+
332
+ while (true) {
333
+ if (probeHealth(svc.health)) return { ok: true, record };
334
+ if (!running(child.pid)) return { ...fail('exited before its health check went green', readLogTail(log)), record };
335
+ if (remaining() <= 0) {
336
+ return { ...fail(`health ${svc.health} not green within ${svc.timeoutMs} ms`, readLogTail(log)), record };
337
+ }
338
+ sleep(Math.min(HEALTH_POLL_MS, remaining()));
339
+ }
340
+ }
341
+
342
+ function isReusable(record, svc, ctx) {
343
+ if (!record || !running(record.pid)) return false;
344
+ if (ctx.readCreatedAt(record.pid) !== record.createdAt) return false;
345
+ return svc.health ? probeHealth(svc.health) : true;
346
+ }
347
+
348
+ /** @returns {{ status: 'pass'|'fail'|'skip', reused: boolean, services: object[], warnings: string[], reason?: string, output?: string }} */
349
+ export function startHostServices(cwd, options = {}, exec) {
350
+ const {
351
+ services,
352
+ env: stackEnv = {},
353
+ gitCommonDir,
354
+ projectName,
355
+ stamp = null,
356
+ forceFresh = false,
357
+ readCreatedAt = readProcessCreatedAt,
358
+ } = options;
359
+
360
+ if (services === undefined || (Array.isArray(services) && services.length === 0)) {
361
+ return { status: 'skip', reused: false, services: [], warnings: [], reason: 'no e2e.hostServices configured' };
362
+ }
363
+ const invalid = validateServices(services);
364
+ if (invalid) return { status: 'fail', reused: false, services: [], warnings: [], reason: invalid };
365
+
366
+ const resolved = [];
367
+ for (const svc of services) {
368
+ const { env, health, missing } = interpolateEnv(svc, stackEnv);
369
+ if (missing.length > 0) {
370
+ return {
371
+ status: 'fail',
372
+ reused: false,
373
+ services: [],
374
+ warnings: [],
375
+ reason: `hostService "${svc.name}": no value for ${missing.map((v) => `\${${v}}`).join(', ')} ` +
376
+ '(looked in the e2e stack env and the process env)',
377
+ };
378
+ }
379
+ const dir = resolve(cwd, svc.cwd ?? '.');
380
+ if (!existsSync(dir)) {
381
+ return { status: 'fail', reused: false, services: [], warnings: [], reason: `hostService "${svc.name}": cwd ${dir} does not exist` };
382
+ }
383
+ resolved.push({
384
+ name: svc.name,
385
+ dir,
386
+ build: svc.build,
387
+ start: svc.start,
388
+ health,
389
+ timeoutMs: svc.timeoutMs ?? DEFAULT_HOST_TIMEOUT,
390
+ childEnv: { ...stackEnv, ...env },
391
+ });
392
+ }
393
+
394
+ const ctx = { gitCommonDir, projectName, stamp, readCreatedAt };
395
+ const previous = readState(gitCommonDir, projectName) ?? [];
396
+ const sameSet = previous.length === resolved.length && resolved.every((s) => previous.some((r) => r.name === s.name));
397
+ const divergent = forceFresh || !sameSet || previous.some((r) => r.stamp !== stamp);
398
+ const warnings = divergent && previous.length > 0
399
+ ? stopHostServices(gitCommonDir, projectName, { readCreatedAt }).warnings
400
+ : [];
401
+ const prior = divergent ? [] : previous;
402
+
403
+ const records = [];
404
+ const startedNow = [];
405
+ const out = [];
406
+ for (const [i, svc] of resolved.entries()) {
407
+ const old = prior.find((r) => r.name === svc.name);
408
+ if (isReusable(old, svc, ctx)) {
409
+ records.push(old);
410
+ out.push({ name: svc.name, pid: old.pid, reused: true, log: old.log });
411
+ continue;
412
+ }
413
+ if (old) {
414
+ const killed = killRecord(old, readCreatedAt);
415
+ if (killed.warning) warnings.push(killed.warning);
416
+ }
417
+
418
+ const result = launch(svc, ctx, Date.now() + svc.timeoutMs, exec);
419
+ if (!result.ok) {
420
+ for (const r of [...startedNow, result.record].filter(Boolean)) killRecord(r, readCreatedAt);
421
+ const unreached = resolved.slice(i + 1).map((s) => prior.find((r) => r.name === s.name)).filter(Boolean);
422
+ const kept = [...records.filter((r) => !startedNow.includes(r)), ...unreached];
423
+ writeState(gitCommonDir, projectName, kept);
424
+ return { status: 'fail', reused: false, services: out, warnings, reason: result.reason, output: result.output };
425
+ }
426
+ records.push(result.record);
427
+ startedNow.push(result.record);
428
+ writeState(gitCommonDir, projectName, records);
429
+ out.push({ name: svc.name, pid: result.record.pid, reused: false, log: result.record.log });
430
+ }
431
+
432
+ return { status: 'pass', reused: startedNow.length === 0, services: out, warnings };
433
+ }