@polymorphism-tech/morph-spec 9.6.0 → 9.8.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/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/bin/morph-spec.js +13 -6
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +3 -3
- package/framework/docs/CLI.md +53 -16
- package/framework/evals/review-checklist.md +5 -5
- package/framework/hooks/claude-code/core/README.md +4 -0
- package/framework/hooks/claude-code/core/test-budget.js +89 -0
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/heavy-command.js +60 -1
- package/framework/hooks/shared/heavy-slots.js +41 -4
- package/framework/hooks/shared/host-services.js +433 -0
- package/framework/schemas/config.schema.json +54 -2
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +2 -2
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-machine/SKILL.md +1 -1
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +3 -3
- package/framework/skills/core/morph-review/SKILL.md +3 -2
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +2 -1
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +14 -1
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +47 -24
- package/src/commands/observability/cost.js +12 -0
- package/src/commands/project/worktree.js +31 -5
- package/src/commands/state/advance.js +3 -0
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/delete.js +1 -1
- package/src/commands/state/finish.js +46 -2
- package/src/commands/state/report-check.js +36 -1
- package/src/commands/validation/plan-check.js +1 -0
- package/src/commands/validation/verify.js +3 -0
- package/src/lib/e2e/stack-teardown.js +19 -4
- package/src/lib/heavy/gate.js +22 -1
- package/src/lib/review-reports.js +23 -10
- package/src/lib/tasks/test-nodes.js +24 -6
- package/src/lib/tasks/test-runner.js +43 -1
- package/src/lib/telemetry/metrics.js +47 -3
- package/src/lib/validators/packages/package-validator.js +2 -2
- package/src/lib/validators/verify-runner.js +50 -12
- package/src/utils/agents-installer.js +5 -2
- package/src/utils/file-copier.js +26 -4
- package/src/utils/hooks-installer.js +4 -4
|
@@ -51,8 +51,14 @@ function liveHoldersPruningStale(dir, ctx) {
|
|
|
51
51
|
|
|
52
52
|
const kindSuffix = (kind) => (kind ? ` (${kind})` : '');
|
|
53
53
|
|
|
54
|
+
// Two full verify suites never run together; a sub-agent filter or a build only takes a slot.
|
|
55
|
+
export const isVerifySuite = ({ label, node }) => label === 'verify' && node === 'tests';
|
|
56
|
+
|
|
57
|
+
const runningSuite = (holders, ownPath = null) => holders.find((h) => h.path !== ownPath && isVerifySuite(h));
|
|
58
|
+
|
|
54
59
|
function blockedText(blocked, slots, now) {
|
|
55
60
|
const holders = blocked.holders.map((h) => describeHolder(h, now)).join('; ');
|
|
61
|
+
if (blocked.reason === 'suite') return `aguardando o verify de outra feature terminar a suíte de testes (${describeHolder(blocked.holders[0], now)})`;
|
|
56
62
|
if (blocked.reason === 'exclusive') return `aguardando limpeza do servidor de build (${describeHolder(blocked.exclusive, now)})`;
|
|
57
63
|
if (blocked.reason === 'memory') {
|
|
58
64
|
const cause = blocked.holders.length
|
|
@@ -65,13 +71,16 @@ function blockedText(blocked, slots, now) {
|
|
|
65
71
|
|
|
66
72
|
function tryClaim(dir, opts, ctx) {
|
|
67
73
|
const {
|
|
68
|
-
slots, label, feature, node, cwd, minFreeMemGb, freeMem, kind, lease: leaseSpec,
|
|
74
|
+
slots, label, feature, node, cwd, minFreeMemGb, freeMem, kind, suite, lease: leaseSpec,
|
|
69
75
|
} = opts;
|
|
70
76
|
const guarded = withClaimMutex(dir, () => {
|
|
71
77
|
const holders = liveHoldersPruningStale(dir, ctx);
|
|
72
78
|
const exclusive = readExclusive(dir, ctx);
|
|
73
79
|
if (exclusive) return { blocked: { reason: 'exclusive', holders, exclusive } };
|
|
80
|
+
// A full queue is the deeper reason: with no free slot, waiting out the other suite is not enough.
|
|
74
81
|
if (holders.length >= slots) return { blocked: { reason: 'slots', holders } };
|
|
82
|
+
const running = suite ? runningSuite(holders) : null;
|
|
83
|
+
if (running) return { blocked: { reason: 'suite', holders: [running] } };
|
|
75
84
|
const freeGb = freeMem() / GB;
|
|
76
85
|
if (minFreeMemGb > 0 && freeGb < minFreeMemGb) return { blocked: { reason: 'memory', holders, freeGb, minFreeMemGb, kind } };
|
|
77
86
|
|
|
@@ -116,10 +125,18 @@ export function acquireHeavy(opts) {
|
|
|
116
125
|
return { status: 'acquired', lease: null, waitedMs: 0, reentrant: true };
|
|
117
126
|
}
|
|
118
127
|
|
|
128
|
+
return pollClaim(() => tryClaim(dir, claimOpts, ctx), {
|
|
129
|
+
...opts, now, sleep, log, minFreeMemGb: claimOpts.minFreeMemGb,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pollClaim(claim, {
|
|
134
|
+
slots, waitTimeoutMs, minFreeMemGb = 0, kind = null, now, sleep, log,
|
|
135
|
+
}) {
|
|
119
136
|
const start = now();
|
|
120
137
|
let lastReport = -Infinity;
|
|
121
138
|
for (;;) {
|
|
122
|
-
const attempt =
|
|
139
|
+
const attempt = claim();
|
|
123
140
|
if (attempt.lease) return { status: 'acquired', lease: attempt.lease, waitedMs: now() - start };
|
|
124
141
|
|
|
125
142
|
const waited = now() - start;
|
|
@@ -130,8 +147,8 @@ export function acquireHeavy(opts) {
|
|
|
130
147
|
reason: blocked.reason,
|
|
131
148
|
holders: blocked.holders,
|
|
132
149
|
freeGb: blocked.freeGb ?? null,
|
|
133
|
-
minFreeMemGb
|
|
134
|
-
kind
|
|
150
|
+
minFreeMemGb,
|
|
151
|
+
kind,
|
|
135
152
|
waitedMs: waited,
|
|
136
153
|
};
|
|
137
154
|
}
|
|
@@ -143,6 +160,25 @@ export function acquireHeavy(opts) {
|
|
|
143
160
|
}
|
|
144
161
|
}
|
|
145
162
|
|
|
163
|
+
// Keeps the slot held while it waits: releasing it would let a build or filter take the place.
|
|
164
|
+
export function enterVerifySuite(opts) {
|
|
165
|
+
const {
|
|
166
|
+
dir, lease, maxHoldMs,
|
|
167
|
+
now = Date.now, sleep = sleepSync, isAlive = pidAlive, log = (m) => process.stderr.write(`${m}\n`),
|
|
168
|
+
} = opts;
|
|
169
|
+
const ctx = { now, isAlive, maxHoldMs };
|
|
170
|
+
const claim = () => {
|
|
171
|
+
const guarded = withClaimMutex(dir, () => {
|
|
172
|
+
const running = runningSuite(scanHolders(dir, ctx).live, lease.path);
|
|
173
|
+
if (running) return { blocked: { reason: 'suite', holders: [running] } };
|
|
174
|
+
updateLease(lease, { node: 'tests' });
|
|
175
|
+
return { lease };
|
|
176
|
+
}, ctx);
|
|
177
|
+
return guarded ? guarded.value : { blocked: { reason: 'contended', holders: [] } };
|
|
178
|
+
};
|
|
179
|
+
return pollClaim(claim, { ...opts, now, sleep, log });
|
|
180
|
+
}
|
|
181
|
+
|
|
146
182
|
// For a holder whose next node needs more RAM than it was admitted with: it keeps its slot and
|
|
147
183
|
// waits only on memory, since re-claiming would hit the re-entrant path and skip the check.
|
|
148
184
|
export function awaitFreeMemory(opts) {
|
|
@@ -206,6 +242,7 @@ export function describeBusy(result, config) {
|
|
|
206
242
|
const key = result.kind ? `heavy.minFreeMemGbByKind.${result.kind}` : 'heavy.minFreeMemGb';
|
|
207
243
|
return `sem RAM livre há ${waited}s (${result.freeGb?.toFixed(1)} GB < ${floor} GB mínimos${kindSuffix(result.kind)}, ${key})`;
|
|
208
244
|
}
|
|
245
|
+
if (result.reason === 'suite') return `o verify de outra feature segue na suíte de testes após ${waited}s (heavy.waitTimeoutSec)`;
|
|
209
246
|
if (result.reason === 'exclusive') return `limpeza do servidor de build ainda em curso após ${waited}s`;
|
|
210
247
|
return `${result.holders.length}/${config.slots} slots pesados ocupados por ${waited}s (heavy.waitTimeoutSec)`;
|
|
211
248
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"title": "MORPH-SPEC .morph/config/config.json",
|
|
4
|
-
"description": "Describes project.tests[] — the plural declaration of the project's test suites
|
|
4
|
+
"description": "Describes project.tests[] — the plural declaration of the project's test suites —, e2e.hostServices and finishBackup.retention. Everything else in config.json is deliberately left open (additionalProperties: true) so that configs already in the field never break on a key this schema has not heard of. The authorities at runtime are validateTestNodes() in src/lib/tasks/test-nodes.js and resolveBackupRetention() in src/lib/worktree/finish-backup.js: a schema nothing calls is decoration.",
|
|
5
5
|
"type": "object",
|
|
6
6
|
"additionalProperties": true,
|
|
7
7
|
"properties": {
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"expectedCount": {
|
|
58
58
|
"type": "integer",
|
|
59
59
|
"minimum": 0,
|
|
60
|
-
"description": "Recorded baseline. Written ONLY by `morph-spec verify --record-baseline`, from a green run — never typed by hand."
|
|
60
|
+
"description": "Recorded baseline, kept only for `node-test` and `evals` (ignored, and removed by --record-baseline, on the other runners). Written ONLY by `morph-spec verify --record-baseline`, from a green run — never typed by hand."
|
|
61
61
|
},
|
|
62
62
|
"timeoutMs": {
|
|
63
63
|
"type": "integer",
|
|
@@ -119,6 +119,58 @@
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
},
|
|
122
|
+
"e2e": {
|
|
123
|
+
"type": "object",
|
|
124
|
+
"additionalProperties": true,
|
|
125
|
+
"description": "The verification stack of `e2e up` and of the verify e2e node. Only hostServices is described here; the other keys (auth, command, portEnvNames, ...) stay open.",
|
|
126
|
+
"properties": {
|
|
127
|
+
"hostServices": {
|
|
128
|
+
"type": "array",
|
|
129
|
+
"description": "Apps that run as host processes next to the compose stack, when building them inside the Docker VM is expensive: compose keeps only the dependencies. Order on `e2e up` and on the verify e2e node: compose up --wait, then each service in array order (build, start detached, health), then the auth seed. They stay up until `e2e down`, `finish`, `delete` or `worktree remove`. Reused while the code stamp (HEAD + status + diff) matches and health is green; a divergent stamp or --fresh/--fresh-e2e kills, rebuilds and restarts them. A failure (build exit != 0, timeout, health never green) fails `e2e up` (exit 1) and the e2e node with the service name and its log tail, kills what this run started and skips the seed. The authority at runtime is validateServices() in framework/hooks/shared/host-services.js.",
|
|
130
|
+
"items": {
|
|
131
|
+
"type": "object",
|
|
132
|
+
"additionalProperties": false,
|
|
133
|
+
"required": ["name", "start"],
|
|
134
|
+
"properties": {
|
|
135
|
+
"name": {
|
|
136
|
+
"type": "string",
|
|
137
|
+
"pattern": "^[A-Za-z0-9._-]+$",
|
|
138
|
+
"description": "Unique in the array. Names the log file (<git-common-dir>/morph-spec/e2e-host-<project>-<name>.log) and every message about the service."
|
|
139
|
+
},
|
|
140
|
+
"cwd": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"default": ".",
|
|
143
|
+
"description": "Working directory of build and start, relative to the project root. One that does not exist refuses."
|
|
144
|
+
},
|
|
145
|
+
"build": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"description": "Runs before start; a non-zero exit fails the bring-up."
|
|
148
|
+
},
|
|
149
|
+
"start": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"minLength": 1,
|
|
152
|
+
"description": "The process, started detached; its output goes to the service log."
|
|
153
|
+
},
|
|
154
|
+
"env": {
|
|
155
|
+
"type": "object",
|
|
156
|
+
"additionalProperties": { "type": ["string", "number"] },
|
|
157
|
+
"description": "Added on top of process.env and the stack env (port block + e2e.auth.envFile). ${VAR} is resolved from the stack env, then process.env; a VAR with no value (or an empty one) refuses before any build, naming the variable and the service."
|
|
158
|
+
},
|
|
159
|
+
"health": {
|
|
160
|
+
"type": "string",
|
|
161
|
+
"description": "URL polled until it answers with an HTTP status below 500. ${VAR} interpolates like env. Without it, a process still alive 2 s after start counts as ready."
|
|
162
|
+
},
|
|
163
|
+
"timeoutMs": {
|
|
164
|
+
"type": "number",
|
|
165
|
+
"exclusiveMinimum": 0,
|
|
166
|
+
"default": 180000,
|
|
167
|
+
"description": "Ceiling for build + start + health together."
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
122
174
|
"finishBackup": {
|
|
123
175
|
"type": "object",
|
|
124
176
|
"additionalProperties": true,
|
|
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando for iniciar um novo projeto de LP com o template Umbraco genérico
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.8.0"
|
|
8
8
|
stacks:
|
|
9
9
|
- umbraco
|
|
10
10
|
---
|
|
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando o usuário pedir "use /migrate-lp-to-umbraco {projeto}" ou descrever a necessidade de migrar uma LP para Umbraco
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.8.0"
|
|
8
8
|
stacks:
|
|
9
9
|
- umbraco
|
|
10
10
|
---
|
|
@@ -4,7 +4,7 @@ description: Executa as fatias do plano de uma feature morph-spec — dispatch p
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use com o gate do plano aprovado, em "use /morph-apply {feature}" ou "implementar feature"
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-apply
|
|
@@ -129,7 +129,7 @@ config **antes** do verify: o stamp grava o HEAD, e código ou `config.json` com
|
|
|
129
129
|
|
|
130
130
|
Depois `morph-spec verify {feature} --json`, **sem filtro** — nunca recorte o
|
|
131
131
|
verify da feature. `--filter` é do loop do próprio sub-agente; o Gate 3 e o CI rodam a suíte
|
|
132
|
-
inteira. Nó
|
|
132
|
+
inteira. Nó `node-test`/`evals` sem `expectedCount`, ou fatia que apagou teste de propósito (limpeza, `morph-spec tests-audit`): acrescente `--record-baseline` a ESTA rodada — ela mede e
|
|
133
133
|
prova de uma vez; nunca um segundo verify só para gravar a baseline. Vermelho: corrija com `verify.feedback` (direto no código, ou re-despachando a fatia dona)
|
|
134
134
|
e rode de novo; **3 tentativas** sem verde → `AskUserQuestion` ("Verify de {feature} vermelho após 3
|
|
135
135
|
tentativas — aceitar com ressalva, continuar tentando, ou parar?"). Quem conta é o `feature.json`:
|
|
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando chegar "/morph-hotfix {feature}", quando a morph-plan rotear o pedido como workType hotfix, ou quando o usuário disser "produção caiu / hotfix / urgente" a respeito de um incidente
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-hotfix
|
|
@@ -4,7 +4,7 @@ description: Cuida da RAM e da CPU da máquina enquanto o morph-spec trabalha
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando a máquina estiver lenta ou sem RAM, um comando pesado esperou ou foi recusado pela fila, sobrou worktree de sub-agent, ou vários sub-agents pesados vão rodar em paralelo
|
|
6
6
|
user-invocable: false
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-machine
|
|
@@ -4,7 +4,7 @@ description: Conduz a AUTORIA de mídia gerada por IA — escolher o modelo pelo
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando a tarefa for PRODUZIR imagem ou vídeo por IA (catálogo, marketing, asset de LP) e alguém precisar decidir modelo, prompt e aprovação
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.8.0"
|
|
8
8
|
stacks:
|
|
9
9
|
- dotnet
|
|
10
10
|
- nextjs
|