@yemi33/minions 0.1.2071 → 0.1.2073

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.
@@ -76,7 +76,9 @@ function getPipelineRuns() {
76
76
  function getActiveRun(pipelineId) {
77
77
  const runs = getPipelineRuns();
78
78
  const pipelineRuns = runs[pipelineId] || [];
79
- return pipelineRuns.find(r => r.status === PIPELINE_STATUS.RUNNING || r.status === PIPELINE_STATUS.PAUSED);
79
+ return pipelineRuns.find(r => r.status === PIPELINE_STATUS.RUNNING
80
+ || r.status === PIPELINE_STATUS.PAUSED
81
+ || r.status === PIPELINE_STATUS.WAITING_HUMAN);
80
82
  }
81
83
 
82
84
  function startRun(pipelineId, pipeline) {
@@ -110,6 +112,12 @@ function startRun(pipelineId, pipeline) {
110
112
  }
111
113
 
112
114
  function updateRunStage(pipelineId, runId, stageId, updates) {
115
+ if (updates && Object.prototype.hasOwnProperty.call(updates, 'status')) {
116
+ const validStatuses = Object.values(PIPELINE_STATUS);
117
+ if (!validStatuses.includes(updates.status)) {
118
+ throw new Error(`updateRunStage: invalid status '${updates.status}' (expected one of: ${validStatuses.join('|')})`);
119
+ }
120
+ }
113
121
  mutateJsonFileLocked(PIPELINE_RUNS_PATH, (data) => {
114
122
  const runs = data[pipelineId] || [];
115
123
  const run = runs.find(r => r.runId === runId);
@@ -301,6 +301,30 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
301
301
  // PR-context vars on non-PR tasks (implement/explore/etc.)
302
302
  'pr_id', 'pr_number', 'pr_title', 'pr_branch', 'pr_author', 'pr_url',
303
303
  'reviewer',
304
+ // P-e6b3c2d8 — QA Session template vars. session_id / target_kind /
305
+ // flows_raw / managed_spawn_name are required (declared in
306
+ // PLAYBOOK_REQUIRED_VARS['qa-session-setup']); these target_* sub-fields
307
+ // are conditional on target.kind so only one is populated per session.
308
+ // runner_hint / capture / target_json / session_mode / session_phase are
309
+ // surfaced for completeness but legitimately empty when not specified.
310
+ 'target_pr_id', // populated only for target.kind === 'pr'
311
+ 'target_branch', // populated only for target.kind === 'branch'
312
+ 'target_sha', // populated only for target.kind === 'commit'
313
+ 'target_worktree', // populated only for target.kind === 'current'
314
+ 'target_json', // JSON-encoded target spec (always present when session_id is)
315
+ 'runner_hint', // explicit runner name; empty = auto-detect
316
+ 'capture', // 'video,screenshots,logs' summary
317
+ 'session_mode', // 'confirm' | 'auto'
318
+ 'session_phase', // 'setup' | 'draft' | 'execute'
319
+ // P-f9a2e1b4 — Runner adapter briefs. engine.js calls the registered
320
+ // runner's generateBrief()/executeBrief() at render time and surfaces the
321
+ // resulting markdown strings here. Required for the DRAFT/EXECUTE
322
+ // playbooks (declared in PLAYBOOK_REQUIRED_VARS below); empty in the
323
+ // SETUP phase, which is why they live in the optional set — we don't
324
+ // want SETUP renders to emit unresolved-var warnings.
325
+ 'runner_brief', // generateBrief() output — used by qa-session-draft
326
+ 'runner_execute_brief', // executeBrief() output — used by qa-session-execute
327
+ 'test_file', // session.testFile — set after DRAFT, used by EXECUTE
304
328
  ]);
305
329
 
306
330
  const PLAYBOOK_REQUIRED_VARS = {
@@ -319,6 +343,21 @@ const PLAYBOOK_REQUIRED_VARS = {
319
343
  'docs': ['item_id', 'item_name'],
320
344
  'setup': ['item_id', 'item_name', 'project_path'],
321
345
  'qa-validate': ['item_id', 'item_name', 'qa_run_id'],
346
+ // P-e6b3c2d8 — QA Session SETUP phase. Required vars are session
347
+ // identification + target kind + flows + the deterministic managed-spawn
348
+ // name the engine joins back to the session on. Conditional target_*
349
+ // sub-fields (target_pr_id / target_branch / target_sha / target_worktree)
350
+ // are optional — only one is populated per target.kind.
351
+ 'qa-session-setup': ['session_id', 'target_kind', 'flows_raw', 'managed_spawn_name'],
352
+ // P-f9a2e1b4 — QA Session DRAFT phase. Required vars are the session id +
353
+ // flows + the live managed-spawn name + the runner adapter's
354
+ // generateBrief() output. The brief is computed at render time by
355
+ // engine.js (lazy-required qa-sessions + qa-runners + managed-spawn).
356
+ 'qa-session-draft': ['session_id', 'flows_raw', 'managed_spawn_name', 'runner_brief'],
357
+ // P-f9a2e1b4 — QA Session EXECUTE phase. Required vars are session id +
358
+ // live managed-spawn name + executeBrief() output + the qa-runs record id
359
+ // (the agent stamps it on the result sidecar so lifecycle.js can ingest it).
360
+ 'qa-session-execute': ['session_id', 'managed_spawn_name', 'runner_execute_brief', 'qa_run_id'],
322
361
  'work-item': ['item_id', 'item_name'],
323
362
  'meeting-investigate': ['meeting_title', 'agenda'],
324
363
  'meeting-debate': ['meeting_title', 'agenda'],
@@ -62,15 +62,16 @@ function _readJsonArrayFallback(scope) {
62
62
  }
63
63
  }
64
64
 
65
- // Track (mtime, size) per scope so back-to-back writes inside the same
66
- // ms tick still get detected as external edits (mtime-only checks miss
67
- // them on Windows because NTFS reports ms-rounded mtimeMs).
68
- const _lastMirrorByScope = new Map();
65
+ // Content-hash fingerprint per scope: same-length swaps (timestamps,
66
+ // reviewStatus enums) collide on (mtime, size) but never on SHA-1.
67
+ const _lastMirrorHashByScope = new Map();
69
68
 
70
- function _statFingerprint(filePath) {
69
+ const crypto = require('crypto');
70
+
71
+ function _fileContentHash(filePath) {
71
72
  try {
72
- const st = fs.statSync(filePath);
73
- return { mtime: st.mtimeMs, size: st.size };
73
+ const buf = fs.readFileSync(filePath);
74
+ return crypto.createHash('sha1').update(buf).digest('hex');
74
75
  }
75
76
  catch { return null; }
76
77
  }
@@ -105,23 +106,19 @@ function _hydrateScopeFromJson(db, scope) {
105
106
 
106
107
  function _resyncScopeIfJsonDiverged(db, scope) {
107
108
  const jsonPath = _filePathForScope(scope);
108
- const current = _statFingerprint(jsonPath);
109
- const lastMirror = _lastMirrorByScope.get(scope);
110
- if (current == null) return;
111
- // In-sync iff BOTH mtime AND size match. Size catches same-ms-tick
112
- // external writes that mtime alone misses. Resync is idempotent.
113
- if (lastMirror != null
114
- && current.mtime === lastMirror.mtime
115
- && current.size === lastMirror.size) return;
116
- if (lastMirror == null) {
109
+ const currentHash = _fileContentHash(jsonPath);
110
+ const lastHash = _lastMirrorHashByScope.get(scope);
111
+ if (currentHash == null) return;
112
+ if (lastHash != null && currentHash === lastHash) return;
113
+ if (lastHash == null) {
117
114
  const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
118
115
  if (sqlHas) {
119
- _lastMirrorByScope.set(scope, current);
116
+ _lastMirrorHashByScope.set(scope, currentHash);
120
117
  return;
121
118
  }
122
119
  }
123
120
  _hydrateScopeFromJson(db, scope);
124
- _lastMirrorByScope.set(scope, current);
121
+ _lastMirrorHashByScope.set(scope, currentHash);
125
122
  }
126
123
 
127
124
  function readPullRequestsForScope(scope) {
@@ -269,12 +266,23 @@ function applyPullRequestsMutation(scope, mutator) {
269
266
  function _mirrorJsonFromSql(scope, filePath) {
270
267
  try {
271
268
  const shared = require('./shared');
272
- const items = readPullRequestsForScope(scope);
273
- for (const pr of items) { if (pr && pr._scope) delete pr._scope; }
269
+ const { getDb } = require('./db');
270
+ // Read SQL directly for this scope bypass JSON fallback so a
271
+ // mutation that empties SQL for the scope doesn't resurrect stale
272
+ // JSON content.
273
+ const db = getDb();
274
+ const rows = db.prepare('SELECT data FROM pull_requests WHERE scope = ? ORDER BY rowid').all(scope);
275
+ const items = [];
276
+ for (const row of rows) {
277
+ const pr = _parseRow(row);
278
+ if (!pr) continue;
279
+ if (pr._scope) delete pr._scope;
280
+ items.push(pr);
281
+ }
274
282
  const target = filePath || _filePathForScope(scope);
275
283
  shared.safeWrite(target, items);
276
- const fp = _statFingerprint(target);
277
- if (fp != null) _lastMirrorByScope.set(scope, fp);
284
+ const h = _fileContentHash(target);
285
+ if (h != null) _lastMirrorHashByScope.set(scope, h);
278
286
  } catch {
279
287
  // Mirror failures are non-fatal — SQL has already committed.
280
288
  }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * engine/qa-runners/maestro.js — P-c4a9e7f3
3
+ *
4
+ * Maestro built-in runner adapter for QA Sessions. Registered at boot by
5
+ * engine/qa-runners.js (_registerBuiltins). Priority 80 — higher than the
6
+ * Playwright safe default so a project with `.maestro/` wins on tie.
7
+ *
8
+ * Detection: project.localPath (or target.worktree for kind='current') has
9
+ * a `.maestro/` directory. Explicit `runner: 'maestro'` is honoured directly
10
+ * by qa-runners.js#detectRunner before detect() runs, so it does not need
11
+ * a branch here.
12
+ *
13
+ * Hooks: see engine/qa-runners.js header for the contract. We return
14
+ * markdown strings from generateBrief / executeBrief — those flow into the
15
+ * qa-session-draft.md / qa-session-execute.md playbooks as {{runner_brief}}
16
+ * and {{runner_execute_brief}} (wired in P-f9a2e1b4).
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const NAME = 'maestro';
23
+ const PRIORITY = 80;
24
+ const INSTALL_HINT =
25
+ 'Maestro CLI is required. Install via Homebrew (`brew tap mobile-dev-inc/tap && brew install maestro`) ' +
26
+ 'on macOS/Linux, or follow https://maestro.mobile.dev/getting-started/installing-maestro for Windows / manual install. ' +
27
+ 'Confirm with `maestro --version` before re-running the session.';
28
+
29
+ function _resolveProbeRoot(target, project) {
30
+ if (target && target.kind === 'current' && typeof target.worktree === 'string' && target.worktree) {
31
+ return target.worktree;
32
+ }
33
+ if (project && typeof project.localPath === 'string' && project.localPath) {
34
+ return project.localPath;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function detect(target, project) {
40
+ const root = _resolveProbeRoot(target, project);
41
+ if (!root) return false;
42
+ try {
43
+ const stat = fs.statSync(path.join(root, '.maestro'));
44
+ return stat.isDirectory();
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function _spawnUrl(spawnInfo) {
51
+ if (!spawnInfo) return '';
52
+ if (spawnInfo.attrs && typeof spawnInfo.attrs.base_url === 'string') return spawnInfo.attrs.base_url;
53
+ if (Array.isArray(spawnInfo.ports) && spawnInfo.ports.length > 0) {
54
+ return `http://localhost:${spawnInfo.ports[0]}`;
55
+ }
56
+ return '';
57
+ }
58
+
59
+ function generateBrief(opts) {
60
+ opts = opts || {};
61
+ const baseUrl = _spawnUrl(opts.spawnInfo);
62
+ const flows = opts.flowsRaw || '(no flows provided)';
63
+ const sessionId = (opts.session && opts.session.id) || (opts.sessionId) || '<session-id>';
64
+ const capture = opts.capture || {};
65
+ const captureSummary = Object.entries(capture).filter(([, v]) => !!v).map(([k]) => k).join(', ') || '(none requested)';
66
+
67
+ return [
68
+ '## Maestro test draft',
69
+ '',
70
+ `Write a single Maestro flow file at \`engine/qa-tests/${sessionId}/flow.yaml\` that exercises:`,
71
+ '',
72
+ flows.trim(),
73
+ '',
74
+ '### Required structure',
75
+ '',
76
+ '- Top-level `appId:` set to the package id Maestro should drive (Android package or iOS bundle).',
77
+ '- A sequence of Maestro commands (`launchApp`, `tapOn`, `inputText`, `assertVisible`, …) that maps 1:1 to the flow above.',
78
+ baseUrl
79
+ ? `- For any HTTP step, use the live target base URL \`${baseUrl}\` (resolved from the managed-spawn).`
80
+ : '- The managed-spawn target did not advertise a base URL; rely on the app id alone.',
81
+ '- Keep the flow under 50 lines — split into named sub-flows (`runFlow:`) when the user described independent journeys.',
82
+ '',
83
+ '### Capture configuration',
84
+ '',
85
+ `Requested artifacts: ${captureSummary}. Surface them via the standard Maestro recording flags ` +
86
+ '(`maestro record` or `maestro test --output …`) in the EXECUTE phase; do NOT enable them inside the flow YAML itself.',
87
+ '',
88
+ 'See https://maestro.mobile.dev/api-reference/commands for the command vocabulary.',
89
+ ].join('\n');
90
+ }
91
+
92
+ function executeBrief(opts) {
93
+ opts = opts || {};
94
+ const sessionId = (opts.session && opts.session.id) || (opts.sessionId) || '<session-id>';
95
+ const testFile = opts.testFile || `engine/qa-tests/${sessionId}/flow.yaml`;
96
+ const capture = opts.capture || {};
97
+ const outputDir = `engine/qa-artifacts/${sessionId}`;
98
+
99
+ const cmdFlags = [`--output ${outputDir}`];
100
+ if (capture.logs) cmdFlags.push(`--debug-output ${outputDir}/maestro-debug`);
101
+ // `maestro test` does not support video; surface the limitation in the
102
+ // brief body rather than silently dropping the capture request.
103
+
104
+ return [
105
+ '## Maestro execute',
106
+ '',
107
+ 'Run the drafted flow against the live managed-spawn:',
108
+ '',
109
+ '```bash',
110
+ `maestro test ${testFile} ${cmdFlags.join(' ')}`,
111
+ '```',
112
+ '',
113
+ `Write the result sidecar to \`agents/<your-id>/qa-run-result.json\` referencing artifacts under \`${outputDir}/\`.`,
114
+ capture.video
115
+ ? '\nVideo capture was requested — note in the result summary that `maestro test` does not record video and the human will need a separate `maestro record` pass if they want it.'
116
+ : '',
117
+ capture.screenshots
118
+ ? '\nScreenshot capture was requested — add `takeScreenshot:` steps at the key flow boundaries; Maestro writes them under the --output dir.'
119
+ : '',
120
+ ].filter(Boolean).join('\n');
121
+ }
122
+
123
+ function validateOutputDir(dir) {
124
+ const errors = [];
125
+ if (typeof dir !== 'string' || dir === '') {
126
+ return { ok: false, errors: ['dir must be a non-empty string'] };
127
+ }
128
+ let entries;
129
+ try { entries = fs.readdirSync(dir); }
130
+ catch (err) {
131
+ return { ok: false, errors: [`qa-tests dir ${dir} unreadable: ${err.message}`] };
132
+ }
133
+ const yamlFiles = entries.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
134
+ if (yamlFiles.length === 0) {
135
+ errors.push(`no Maestro flow file (*.yaml) found in ${dir}`);
136
+ }
137
+ return { ok: errors.length === 0, errors };
138
+ }
139
+
140
+ module.exports = {
141
+ name: NAME,
142
+ spec: {
143
+ priority: PRIORITY,
144
+ label: 'Maestro',
145
+ description: 'Mobile-first NL flows. Detects when the target project has a .maestro/ directory.',
146
+ installHint: INSTALL_HINT,
147
+ detect,
148
+ generateBrief,
149
+ executeBrief,
150
+ validateOutputDir,
151
+ },
152
+ };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * engine/qa-runners/playwright.js — P-c4a9e7f3
3
+ *
4
+ * Playwright built-in runner adapter for QA Sessions. Registered at boot by
5
+ * engine/qa-runners.js (_registerBuiltins). Priority 50 — the safe default
6
+ * for any HTTP target. Maestro (priority 80) wins on detect tie when the
7
+ * project has `.maestro/`.
8
+ *
9
+ * Detection: always true. Playwright is the safe-default web runner, and
10
+ * QA-session targets are overwhelmingly web apps with an HTTP spawn (PR
11
+ * preview, branch dev server, current worktree dev server, etc.). The
12
+ * priority gate above lets Maestro win when present; explicit
13
+ * `runner: '...'` overrides win before detect() runs at all.
14
+ *
15
+ * Hooks: see engine/qa-runners.js header for the contract.
16
+ */
17
+
18
+ const fs = require('fs');
19
+
20
+ const NAME = 'playwright';
21
+ const PRIORITY = 50;
22
+ const INSTALL_HINT =
23
+ 'Playwright is required. From the target project run `npm i -D @playwright/test` and ' +
24
+ '`npx playwright install` (the second command installs the browser binaries). ' +
25
+ 'Confirm with `npx playwright --version`.';
26
+
27
+ function detect(/* target, project */) {
28
+ return true;
29
+ }
30
+
31
+ function _spawnUrl(spawnInfo) {
32
+ if (!spawnInfo) return '';
33
+ if (spawnInfo.attrs && typeof spawnInfo.attrs.base_url === 'string') return spawnInfo.attrs.base_url;
34
+ if (Array.isArray(spawnInfo.ports) && spawnInfo.ports.length > 0) {
35
+ return `http://localhost:${spawnInfo.ports[0]}`;
36
+ }
37
+ return '';
38
+ }
39
+
40
+ function _portsLabel(spawnInfo) {
41
+ if (!spawnInfo || !Array.isArray(spawnInfo.ports) || spawnInfo.ports.length === 0) return '(no ports)';
42
+ return spawnInfo.ports.join(', ');
43
+ }
44
+
45
+ function generateBrief(opts) {
46
+ opts = opts || {};
47
+ const baseUrl = _spawnUrl(opts.spawnInfo);
48
+ const ports = _portsLabel(opts.spawnInfo);
49
+ const flows = opts.flowsRaw || '(no flows provided)';
50
+ const sessionId = (opts.session && opts.session.id) || (opts.sessionId) || '<session-id>';
51
+ const capture = opts.capture || {};
52
+ const captureSummary = Object.entries(capture).filter(([, v]) => !!v).map(([k]) => k).join(', ') || '(none requested)';
53
+
54
+ return [
55
+ '## Playwright test draft',
56
+ '',
57
+ `Write a single Playwright test at \`engine/qa-tests/${sessionId}/test.spec.js\` that exercises:`,
58
+ '',
59
+ flows.trim(),
60
+ '',
61
+ '### Required structure',
62
+ '',
63
+ '```js',
64
+ "const { test, expect } = require('@playwright/test');",
65
+ '',
66
+ "test('qa-session flow', async ({ page }) => {",
67
+ baseUrl
68
+ ? ` await page.goto('${baseUrl}');`
69
+ : ' // Managed-spawn did not advertise base_url; build the URL from the spawn port.',
70
+ ' // … translate each flow step into Playwright actions (locator + assertion) …',
71
+ '});',
72
+ '```',
73
+ '',
74
+ baseUrl
75
+ ? `- Live target: \`${baseUrl}\` (ports: ${ports}). Anchor every \`page.goto\` to this URL.`
76
+ : `- Live target ports: ${ports}. Build the URL from the port the dev server bound to.`,
77
+ '- Keep selectors resilient — prefer `getByRole`, `getByText`, `getByTestId` over CSS where possible.',
78
+ '- Each step the user described becomes one or more Playwright actions + a matching `expect(...).toBeVisible()` / `.toHaveURL()` / `.toHaveText()`.',
79
+ '',
80
+ '### Capture configuration',
81
+ '',
82
+ `Requested artifacts: ${captureSummary}. The EXECUTE phase wires \`--video\`, \`--screenshot\`, and trace flags ` +
83
+ 'into the `npx playwright test` invocation. The test file itself should NOT hard-code recording (let the CLI flags drive it).',
84
+ ].join('\n');
85
+ }
86
+
87
+ function executeBrief(opts) {
88
+ opts = opts || {};
89
+ const sessionId = (opts.session && opts.session.id) || (opts.sessionId) || '<session-id>';
90
+ const testFile = opts.testFile || `engine/qa-tests/${sessionId}/test.spec.js`;
91
+ const capture = opts.capture || {};
92
+ const outputDir = `engine/qa-artifacts/${sessionId}`;
93
+
94
+ const flags = [`--output ${outputDir}`, '--reporter=json'];
95
+ if (capture.video) flags.push('--video=on');
96
+ if (capture.screenshots) flags.push('--screenshot=on');
97
+ if (capture.logs) flags.push('--trace=on');
98
+
99
+ const artifactTypes = [];
100
+ if (capture.video) artifactTypes.push('`video` (webm)');
101
+ if (capture.screenshots) artifactTypes.push('`screenshot` (png)');
102
+ if (capture.logs) artifactTypes.push('`log` (trace.zip + stdout)');
103
+ if (artifactTypes.length === 0) artifactTypes.push('`log` (test stdout/stderr only — no capture flags requested)');
104
+
105
+ return [
106
+ '## Playwright execute',
107
+ '',
108
+ 'Run the drafted test against the live managed-spawn:',
109
+ '',
110
+ '```bash',
111
+ `npx playwright test ${testFile} ${flags.join(' ')}`,
112
+ '```',
113
+ '',
114
+ `Write the result sidecar to \`agents/<your-id>/qa-run-result.json\` referencing artifacts under \`${outputDir}/\`.`,
115
+ '',
116
+ `Artifact types: ${artifactTypes.join(', ')}`,
117
+ ].join('\n');
118
+ }
119
+
120
+ function validateOutputDir(dir) {
121
+ const errors = [];
122
+ if (typeof dir !== 'string' || dir === '') {
123
+ return { ok: false, errors: ['dir must be a non-empty string'] };
124
+ }
125
+ let entries;
126
+ try { entries = fs.readdirSync(dir); }
127
+ catch (err) {
128
+ return { ok: false, errors: [`qa-tests dir ${dir} unreadable: ${err.message}`] };
129
+ }
130
+ const specFiles = entries.filter(f => /\.spec\.(js|ts|mjs|cjs)$/.test(f));
131
+ if (specFiles.length === 0) {
132
+ errors.push(`no Playwright test file (*.spec.{js,ts,mjs,cjs}) found in ${dir}`);
133
+ }
134
+ return { ok: errors.length === 0, errors };
135
+ }
136
+
137
+ module.exports = {
138
+ name: NAME,
139
+ spec: {
140
+ priority: PRIORITY,
141
+ label: 'Playwright',
142
+ description: 'Safe-default web runner. Wins for any HTTP target unless a higher-priority runner detects.',
143
+ installHint: INSTALL_HINT,
144
+ detect,
145
+ generateBrief,
146
+ executeBrief,
147
+ validateOutputDir,
148
+ },
149
+ };