amalgm 0.1.125 → 0.1.126

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.125",
3
+ "version": "0.1.126",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -17,7 +17,7 @@
17
17
  "sync-runtime": "node ../../scripts/sync-npm-package-runtime.mjs",
18
18
  "prepack": "node ../../scripts/sync-npm-package-runtime.mjs",
19
19
  "pack:dry": "npm pack --dry-run",
20
- "check": "node --check bin/amalgm.js && node --check lib/app-chat.js && node --check lib/react.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
20
+ "check": "node --check bin/amalgm.js && node --check lib/app-chat.js && node --check lib/react.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/amalgm-mcp/events/pr-check-runner.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
21
21
  },
22
22
  "engines": {
23
23
  "node": ">=20"
@@ -291,6 +291,35 @@ function externalMcpToolIdsForLoadout(toolIds, tools) {
291
291
  .map((tool) => tool.id);
292
292
  }
293
293
 
294
+ function agentEntryId(entry) {
295
+ if (!isObject(entry)) return '';
296
+ const sourceAgentId = typeof entry.sourceAgentId === 'string' ? entry.sourceAgentId.trim() : '';
297
+ if (sourceAgentId) return sourceAgentId;
298
+ return typeof entry.agent?.sourceAgentId === 'string' ? entry.agent.sourceAgentId.trim() : '';
299
+ }
300
+
301
+ function referencedSubagentIds(entry) {
302
+ if (!isObject(entry)) return [];
303
+ const subagents = Array.isArray(entry.config?.subagents) ? entry.config.subagents : [];
304
+ return subagents
305
+ .map((subagent) => (typeof subagent?.agentId === 'string' ? subagent.agentId.trim() : ''))
306
+ .filter(Boolean);
307
+ }
308
+
309
+ function assertClosedAgentBundleGraph(bundle) {
310
+ const agentIds = new Set(bundle.agents.map(agentEntryId).filter(Boolean));
311
+ const missing = new Set();
312
+ for (const entry of bundle.agents) {
313
+ for (const agentId of referencedSubagentIds(entry)) {
314
+ if (!agentIds.has(agentId)) missing.add(agentId);
315
+ }
316
+ }
317
+
318
+ if (missing.size > 0) {
319
+ throw new Error(`Bundle is missing shared subagents: ${Array.from(missing).join(', ')}`);
320
+ }
321
+ }
322
+
294
323
  function validateBundle(bundle) {
295
324
  if (!isObject(bundle)) throw new Error('bundle must be an object');
296
325
  if (bundle.kind !== BUNDLE_KIND) throw new Error(`Unsupported bundle kind: ${bundle.kind || 'unknown'}`);
@@ -300,6 +329,7 @@ function validateBundle(bundle) {
300
329
  if (!Array.isArray(bundle.agents) || bundle.agents.length === 0) {
301
330
  throw new Error('bundle must include at least one agent');
302
331
  }
332
+ assertClosedAgentBundleGraph(bundle);
303
333
  return bundle;
304
334
  }
305
335
 
@@ -4,8 +4,10 @@ const path = require('path');
4
4
 
5
5
  const DESKTOP_RELEASE_AUTOMATION_ID = 'internal-amalgm-desktop-release';
6
6
  const DESKTOP_RELEASE_TRIGGER_ID = 'internal-amalgm-desktop-release-github-push';
7
+ const PR_CHECK_TRIGGER_ID = 'internal-amalgm-pr-check-github-pull-request';
7
8
  const DESKTOP_RELEASE_WORKFLOW_ID = 'internal-amalgm-desktop-release-workflow';
8
9
  const NPM_RELEASE_WORKFLOW_ID = 'internal-amalgm-npm-release-workflow';
10
+ const PR_CHECK_WORKFLOW_ID = 'internal-amalgm-pr-check-workflow';
9
11
 
10
12
  function desktopReleaseRunnerPath() {
11
13
  return path.resolve(__dirname, '../events/desktop-release-runner.js');
@@ -15,6 +17,10 @@ function npmReleaseRunnerPath() {
15
17
  return path.resolve(__dirname, '../events/npm-release-runner.js');
16
18
  }
17
19
 
20
+ function prCheckRunnerPath() {
21
+ return path.resolve(__dirname, '../events/pr-check-runner.js');
22
+ }
23
+
18
24
  function shellQuote(value) {
19
25
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
20
26
  }
@@ -339,11 +345,108 @@ function npmReleaseWorkflow(runnerPath = npmReleaseRunnerPath()) {
339
345
  })`;
340
346
  }
341
347
 
348
+ function prCheckWorkflow(runnerPath = prCheckRunnerPath()) {
349
+ const escapedRunnerCommand = JSON.stringify(`node ${shellQuote(runnerPath)}`);
350
+ const escapedRunnerDir = JSON.stringify(path.dirname(runnerPath));
351
+ return `export default workflow({
352
+ trigger: event("github.pull_request"),
353
+
354
+ allowlist: {
355
+ localCompute: true,
356
+ secrets: [
357
+ "GH_TOKEN",
358
+ "GITHUB_TOKEN",
359
+ "AMALGM_PR_CHECK_CHECKOUT_ROOT",
360
+ "AMALGM_PR_CHECK_ENV_FILE",
361
+ "AMALGM_PR_CHECK_NODE_BIN",
362
+ "AMALGM_PR_CHECK_PATH_PREFIX",
363
+ "AMALGM_PR_CHECK_PYTHON",
364
+ "AMALGM_PR_CHECK_NPM_CACHE",
365
+ "AMALGM_PR_CHECK_ENGINE_COMMAND",
366
+ "AMALGM_PR_CHECK_UI_COMMAND",
367
+ "AMALGM_PR_CHECK_STATUS_CONTEXT",
368
+ "AMALGM_ENGINE_REPO_URL",
369
+ "AMALGM_UI_REPO_URL"
370
+ ]
371
+ },
372
+
373
+ limits: {
374
+ maxConcurrentRuns: 2,
375
+ queueLimit: 20,
376
+ cellTimeoutMs: 1800000,
377
+ codeTimeoutMs: 15000
378
+ },
379
+
380
+ cells: [
381
+ code("filter_pull_request", async ({ payload, headers, stop }) => {
382
+ const repository = String(payload?.repository?.full_name || "").trim()
383
+ const action = String(payload?.action || "").trim()
384
+ const pull = payload?.pull_request || {}
385
+ const baseBranch = String(pull?.base?.ref || "").trim()
386
+ const headSha = String(pull?.head?.sha || "").trim()
387
+ const headRef = String(pull?.head?.ref || "").trim()
388
+ const headRepository = String(pull?.head?.repo?.full_name || "").trim()
389
+ const number = Number(payload?.number || pull?.number || 0)
390
+ const allowedRepositories = ["amalgm-inc/amalgm-engine", "amalgm-inc/amalgm-ui"]
391
+ const allowedActions = ["opened", "synchronize", "reopened", "ready_for_review"]
392
+
393
+ if (!allowedRepositories.includes(repository)) stop()
394
+ if (!allowedActions.includes(action)) stop()
395
+ if (!["main", "preview"].includes(baseBranch)) stop()
396
+ if (!Number.isSafeInteger(number) || number <= 0) stop()
397
+ if (!/^[a-f0-9]{40}$/i.test(headSha)) stop()
398
+ if (pull?.draft) stop()
399
+ if (headRepository && headRepository !== repository) stop()
400
+
401
+ return {
402
+ repository,
403
+ action,
404
+ number,
405
+ baseBranch,
406
+ headRef,
407
+ headRepository,
408
+ headSha,
409
+ delivery: String(headers?.["x-github-delivery"] || "")
410
+ }
411
+ }),
412
+
413
+ cli("run_pr_check", {
414
+ cwd: ${escapedRunnerDir},
415
+ command: ${escapedRunnerCommand},
416
+ env: ({ cells, secrets }) => ({
417
+ GH_TOKEN: secrets.GH_TOKEN || secrets.GITHUB_TOKEN || "",
418
+ GITHUB_TOKEN: secrets.GITHUB_TOKEN || secrets.GH_TOKEN || "",
419
+ AMALGM_PR_CHECK_CHECKOUT_ROOT: secrets.AMALGM_PR_CHECK_CHECKOUT_ROOT || "",
420
+ AMALGM_PR_CHECK_ENV_FILE: secrets.AMALGM_PR_CHECK_ENV_FILE || "",
421
+ AMALGM_PR_CHECK_NODE_BIN: secrets.AMALGM_PR_CHECK_NODE_BIN || "",
422
+ AMALGM_PR_CHECK_PATH_PREFIX: secrets.AMALGM_PR_CHECK_PATH_PREFIX || "",
423
+ AMALGM_PR_CHECK_PYTHON: secrets.AMALGM_PR_CHECK_PYTHON || "",
424
+ AMALGM_PR_CHECK_NPM_CACHE: secrets.AMALGM_PR_CHECK_NPM_CACHE || "",
425
+ AMALGM_PR_CHECK_ENGINE_COMMAND: secrets.AMALGM_PR_CHECK_ENGINE_COMMAND || "",
426
+ AMALGM_PR_CHECK_UI_COMMAND: secrets.AMALGM_PR_CHECK_UI_COMMAND || "",
427
+ AMALGM_PR_CHECK_STATUS_CONTEXT: secrets.AMALGM_PR_CHECK_STATUS_CONTEXT || "",
428
+ AMALGM_ENGINE_REPO_URL: secrets.AMALGM_ENGINE_REPO_URL || "",
429
+ AMALGM_UI_REPO_URL: secrets.AMALGM_UI_REPO_URL || "",
430
+ AMALGM_PR_CHECK_SOURCE_REPO: cells.filter_pull_request.output.repository,
431
+ AMALGM_PR_CHECK_ACTION: cells.filter_pull_request.output.action,
432
+ AMALGM_PR_CHECK_NUMBER: String(cells.filter_pull_request.output.number),
433
+ AMALGM_PR_CHECK_BASE_BRANCH: cells.filter_pull_request.output.baseBranch,
434
+ AMALGM_PR_CHECK_HEAD_REF: cells.filter_pull_request.output.headRef,
435
+ AMALGM_PR_CHECK_HEAD_REPO: cells.filter_pull_request.output.headRepository,
436
+ AMALGM_PR_CHECK_HEAD_SHA: cells.filter_pull_request.output.headSha,
437
+ AMALGM_PR_CHECK_DELIVERY: cells.filter_pull_request.output.delivery
438
+ }),
439
+ timeoutMs: 1800000
440
+ })
441
+ ]
442
+ })`;
443
+ }
444
+
342
445
  function desktopReleaseAutomationSeed() {
343
446
  return {
344
447
  id: DESKTOP_RELEASE_AUTOMATION_ID,
345
448
  name: 'Run Amalgm release automation',
346
- description: 'Internal workflow: publish npm runtime packages and signed macOS desktop releases from GitHub push webhooks.',
449
+ description: 'Internal workflow: run PR checks and publish npm/runtime releases from GitHub webhooks.',
347
450
  enabled: true,
348
451
  internal: true,
349
452
  triggers: [{
@@ -355,6 +458,15 @@ function desktopReleaseAutomationSeed() {
355
458
  event: 'push',
356
459
  sourceUrl: 'https://github.com/amalgm-inc',
357
460
  enabled: true,
461
+ }, {
462
+ id: PR_CHECK_TRIGGER_ID,
463
+ kind: 'event',
464
+ name: 'GitHub pull request',
465
+ description: 'GitHub pull_request webhook for amalgm-engine and amalgm-ui PR checks.',
466
+ source: 'github',
467
+ event: 'pull_request',
468
+ sourceUrl: 'https://github.com/amalgm-inc',
469
+ enabled: true,
358
470
  }],
359
471
  workflows: [{
360
472
  id: DESKTOP_RELEASE_WORKFLOW_ID,
@@ -457,6 +569,37 @@ function desktopReleaseAutomationSeed() {
457
569
  cellTimeoutMs: 1800000,
458
570
  codeTimeoutMs: 15000,
459
571
  },
572
+ }, {
573
+ id: PR_CHECK_WORKFLOW_ID,
574
+ name: 'Run PR checks',
575
+ description: 'Runs deterministic pre-merge checks for pull requests into main and preview.',
576
+ enabled: true,
577
+ internal: true,
578
+ workflowText: prCheckWorkflow(),
579
+ allowlist: {
580
+ localCompute: true,
581
+ secrets: [
582
+ 'GH_TOKEN',
583
+ 'GITHUB_TOKEN',
584
+ 'AMALGM_PR_CHECK_CHECKOUT_ROOT',
585
+ 'AMALGM_PR_CHECK_ENV_FILE',
586
+ 'AMALGM_PR_CHECK_NODE_BIN',
587
+ 'AMALGM_PR_CHECK_PATH_PREFIX',
588
+ 'AMALGM_PR_CHECK_PYTHON',
589
+ 'AMALGM_PR_CHECK_NPM_CACHE',
590
+ 'AMALGM_PR_CHECK_ENGINE_COMMAND',
591
+ 'AMALGM_PR_CHECK_UI_COMMAND',
592
+ 'AMALGM_PR_CHECK_STATUS_CONTEXT',
593
+ 'AMALGM_ENGINE_REPO_URL',
594
+ 'AMALGM_UI_REPO_URL',
595
+ ],
596
+ },
597
+ limits: {
598
+ maxConcurrentRuns: 2,
599
+ queueLimit: 20,
600
+ cellTimeoutMs: 1800000,
601
+ codeTimeoutMs: 15000,
602
+ },
460
603
  }],
461
604
  };
462
605
  }
@@ -471,12 +614,16 @@ function internalAutomationSeeds() {
471
614
  module.exports = {
472
615
  DESKTOP_RELEASE_AUTOMATION_ID,
473
616
  DESKTOP_RELEASE_TRIGGER_ID,
617
+ PR_CHECK_TRIGGER_ID,
474
618
  DESKTOP_RELEASE_WORKFLOW_ID,
475
619
  NPM_RELEASE_WORKFLOW_ID,
620
+ PR_CHECK_WORKFLOW_ID,
476
621
  desktopReleaseAutomationSeed,
477
622
  desktopReleaseRunnerPath,
478
623
  desktopReleaseWorkflow,
479
624
  internalAutomationSeeds,
480
625
  npmReleaseRunnerPath,
481
626
  npmReleaseWorkflow,
627
+ prCheckRunnerPath,
628
+ prCheckWorkflow,
482
629
  };
@@ -0,0 +1,344 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ const ENGINE_REPO_FULL_NAME = 'amalgm-inc/amalgm-engine';
9
+ const UI_REPO_FULL_NAME = 'amalgm-inc/amalgm-ui';
10
+ const DEFAULT_ENGINE_REPO_URL = 'https://github.com/amalgm-inc/amalgm-engine.git';
11
+ const DEFAULT_UI_REPO_URL = 'https://github.com/amalgm-inc/amalgm-ui.git';
12
+ const DEFAULT_CHECKOUT_ROOT = path.join(os.homedir(), '.amalgm', 'workflow-checkouts', 'amalgm-pr-check');
13
+ const DEFAULT_ENV_FILE = path.join(os.homedir(), '.amalgm', 'pr-check.env');
14
+ const DEFAULT_STATUS_CONTEXT = 'amalgm/pr-check';
15
+
16
+ function cleanString(value) {
17
+ return typeof value === 'string' ? value.trim() : '';
18
+ }
19
+
20
+ function parseEnvFile(text) {
21
+ const env = {};
22
+ for (const line of String(text || '').split(/\r?\n/)) {
23
+ const trimmed = line.trim();
24
+ if (!trimmed || trimmed.startsWith('#')) continue;
25
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
26
+ if (!match) continue;
27
+ let value = match[2] || '';
28
+ const quote = value[0];
29
+ if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
30
+ value = value.slice(1, -1);
31
+ }
32
+ env[match[1]] = value;
33
+ }
34
+ return env;
35
+ }
36
+
37
+ function applyPrCheckEnv(env = process.env) {
38
+ const candidates = [
39
+ cleanString(env.AMALGM_PR_CHECK_ENV_FILE),
40
+ DEFAULT_ENV_FILE,
41
+ ].filter(Boolean);
42
+
43
+ for (const filePath of candidates) {
44
+ if (!fs.existsSync(filePath)) continue;
45
+ const values = parseEnvFile(fs.readFileSync(filePath, 'utf8'));
46
+ for (const [key, value] of Object.entries(values)) {
47
+ if (!cleanString(env[key])) env[key] = value;
48
+ }
49
+ }
50
+
51
+ return env;
52
+ }
53
+
54
+ function prependPath(env, dir) {
55
+ const cleanDir = cleanString(dir);
56
+ if (!cleanDir) return env.PATH || '';
57
+ const parts = String(env.PATH || '').split(path.delimiter).filter(Boolean);
58
+ return [cleanDir, ...parts.filter((part) => part !== cleanDir)].join(path.delimiter);
59
+ }
60
+
61
+ function prCheckToolEnv(baseEnv = process.env) {
62
+ const env = { ...baseEnv };
63
+ applyPrCheckEnv(env);
64
+ const nodeBin = cleanString(env.AMALGM_PR_CHECK_NODE_BIN);
65
+ if (nodeBin && fs.existsSync(nodeBin)) {
66
+ env.PATH = prependPath(env, path.dirname(nodeBin));
67
+ }
68
+ const pathPrefix = cleanString(env.AMALGM_PR_CHECK_PATH_PREFIX);
69
+ if (pathPrefix) env.PATH = prependPath(env, pathPrefix);
70
+ env.npm_config_python = cleanString(env.AMALGM_PR_CHECK_PYTHON)
71
+ || cleanString(env.npm_config_python)
72
+ || '/usr/bin/python3';
73
+ env.npm_config_cache = cleanString(env.AMALGM_PR_CHECK_NPM_CACHE)
74
+ || cleanString(env.npm_config_cache)
75
+ || path.join(os.tmpdir(), 'amalgm-pr-check-npm-cache');
76
+ env.npm_config_prefer_online = 'true';
77
+ env.npm_config_audit = 'false';
78
+ env.npm_config_fund = 'false';
79
+ if (!cleanString(env.GH_TOKEN) && cleanString(env.GITHUB_TOKEN)) {
80
+ env.GH_TOKEN = cleanString(env.GITHUB_TOKEN);
81
+ }
82
+ if (!cleanString(env.GITHUB_TOKEN) && cleanString(env.GH_TOKEN)) {
83
+ env.GITHUB_TOKEN = cleanString(env.GH_TOKEN);
84
+ }
85
+ return env;
86
+ }
87
+
88
+ function parsePayloadFromEnv(env = process.env) {
89
+ const payloadFile = cleanString(env.AMALGM_PR_CHECK_PAYLOAD_FILE);
90
+ const payloadJson = payloadFile ? fs.readFileSync(payloadFile, 'utf8') : cleanString(env.AMALGM_PR_CHECK_PAYLOAD_JSON);
91
+ if (!payloadJson) return null;
92
+ return JSON.parse(payloadJson);
93
+ }
94
+
95
+ function requestFromPayload(payload = {}, env = process.env) {
96
+ const pull = payload.pull_request || {};
97
+ const base = pull.base || {};
98
+ const head = pull.head || {};
99
+ const repository = cleanString(env.AMALGM_PR_CHECK_SOURCE_REPO || payload.repository?.full_name);
100
+ const baseBranch = cleanString(env.AMALGM_PR_CHECK_BASE_BRANCH || base.ref);
101
+ const headSha = cleanString(env.AMALGM_PR_CHECK_HEAD_SHA || head.sha);
102
+ const headRef = cleanString(env.AMALGM_PR_CHECK_HEAD_REF || head.ref);
103
+ const headRepository = cleanString(env.AMALGM_PR_CHECK_HEAD_REPO || head.repo?.full_name);
104
+ const number = Number(env.AMALGM_PR_CHECK_NUMBER || payload.number || pull.number || 0);
105
+ return {
106
+ payload,
107
+ action: cleanString(env.AMALGM_PR_CHECK_ACTION || payload.action),
108
+ repository,
109
+ number,
110
+ baseBranch,
111
+ headRef,
112
+ headSha,
113
+ headRepository,
114
+ draft: env.AMALGM_PR_CHECK_DRAFT
115
+ ? ['1', 'true', 'yes', 'on'].includes(cleanString(env.AMALGM_PR_CHECK_DRAFT).toLowerCase())
116
+ : Boolean(pull.draft),
117
+ delivery: cleanString(env.AMALGM_PR_CHECK_DELIVERY),
118
+ dryRun: ['1', 'true', 'yes', 'on'].includes(cleanString(env.AMALGM_PR_CHECK_DRY_RUN).toLowerCase()),
119
+ };
120
+ }
121
+
122
+ function requestFromEnv(env = process.env) {
123
+ return requestFromPayload(parsePayloadFromEnv(env) || {}, env);
124
+ }
125
+
126
+ function isFullSha(value) {
127
+ return /^[a-f0-9]{40}$/i.test(cleanString(value));
128
+ }
129
+
130
+ function repoShortName(fullName) {
131
+ return cleanString(fullName).split('/').pop() || 'repo';
132
+ }
133
+
134
+ function repoUrlForRequest(request, env = process.env) {
135
+ if (request.repository === ENGINE_REPO_FULL_NAME) {
136
+ return cleanString(env.AMALGM_ENGINE_REPO_URL) || DEFAULT_ENGINE_REPO_URL;
137
+ }
138
+ if (request.repository === UI_REPO_FULL_NAME) {
139
+ return cleanString(env.AMALGM_UI_REPO_URL) || DEFAULT_UI_REPO_URL;
140
+ }
141
+ return '';
142
+ }
143
+
144
+ function shouldHandleRequest(request) {
145
+ const allowedActions = new Set(['opened', 'synchronize', 'reopened', 'ready_for_review']);
146
+ if (![ENGINE_REPO_FULL_NAME, UI_REPO_FULL_NAME].includes(request.repository)) {
147
+ return { ok: false, reason: `Ignored repository "${request.repository || '(missing)'}".` };
148
+ }
149
+ if (!allowedActions.has(request.action)) {
150
+ return { ok: false, reason: `Ignored pull_request action "${request.action || '(missing)'}".` };
151
+ }
152
+ if (!['main', 'preview'].includes(request.baseBranch)) {
153
+ return { ok: false, reason: `Ignored base branch "${request.baseBranch || '(missing)'}".` };
154
+ }
155
+ if (!Number.isSafeInteger(request.number) || request.number <= 0) {
156
+ return { ok: false, reason: 'Ignored pull request without a valid number.' };
157
+ }
158
+ if (!isFullSha(request.headSha)) {
159
+ return { ok: false, reason: 'Ignored pull request without a full head SHA.' };
160
+ }
161
+ if (request.draft) {
162
+ return { ok: false, reason: 'Ignored draft pull request.' };
163
+ }
164
+ if (request.headRepository && request.headRepository !== request.repository) {
165
+ return { ok: false, reason: `Ignored forked pull request from "${request.headRepository}".` };
166
+ }
167
+ return { ok: true };
168
+ }
169
+
170
+ function commandForRequest(request, env = process.env) {
171
+ if (request.repository === ENGINE_REPO_FULL_NAME) {
172
+ return cleanString(env.AMALGM_PR_CHECK_ENGINE_COMMAND) || 'npm ci && npm run ci:pr';
173
+ }
174
+ if (request.repository === UI_REPO_FULL_NAME) {
175
+ return cleanString(env.AMALGM_PR_CHECK_UI_COMMAND) || 'npm ci && npm run ci:pr';
176
+ }
177
+ throw new Error(`Unsupported PR check repository: ${request.repository}`);
178
+ }
179
+
180
+ function run(command, args, options = {}) {
181
+ const printable = [command, ...args].join(' ');
182
+ console.log(`$ ${printable}`);
183
+ const result = spawnSync(command, args, {
184
+ cwd: options.cwd,
185
+ env: options.env || process.env,
186
+ encoding: 'utf8',
187
+ maxBuffer: Number(options.maxBuffer) || 200 * 1024 * 1024,
188
+ stdio: ['ignore', 'pipe', 'pipe'],
189
+ });
190
+ if (result.stdout) process.stdout.write(result.stdout);
191
+ if (result.stderr) process.stderr.write(result.stderr);
192
+ if (result.error) throw result.error;
193
+ if (result.status !== 0) {
194
+ const error = new Error(`${printable} failed with exit code ${result.status ?? result.signal ?? 'unknown'}`);
195
+ error.stdout = result.stdout || '';
196
+ error.stderr = result.stderr || '';
197
+ throw error;
198
+ }
199
+ return result.stdout || '';
200
+ }
201
+
202
+ function runShell(command, options = {}) {
203
+ return run('/bin/sh', ['-lc', command], options);
204
+ }
205
+
206
+ function checkoutPullRequest(request, env = process.env) {
207
+ const checkoutRoot = cleanString(env.AMALGM_PR_CHECK_CHECKOUT_ROOT) || DEFAULT_CHECKOUT_ROOT;
208
+ const checkoutDir = path.join(checkoutRoot, repoShortName(request.repository), String(request.number));
209
+ const repoUrl = repoUrlForRequest(request, env);
210
+ if (!repoUrl) throw new Error(`No clone URL configured for ${request.repository}`);
211
+
212
+ fs.rmSync(checkoutDir, { recursive: true, force: true });
213
+ fs.mkdirSync(path.dirname(checkoutDir), { recursive: true });
214
+ run('git', ['clone', '--no-checkout', repoUrl, checkoutDir], { env });
215
+ run('git', [
216
+ 'fetch',
217
+ '--no-tags',
218
+ 'origin',
219
+ `+refs/pull/${request.number}/head:refs/remotes/origin/pr-${request.number}`,
220
+ ], { cwd: checkoutDir, env });
221
+ const fetchedSha = cleanString(run('git', ['rev-parse', `refs/remotes/origin/pr-${request.number}`], { cwd: checkoutDir, env }));
222
+ if (fetchedSha !== request.headSha) {
223
+ throw new Error(`Fetched PR head ${fetchedSha} did not match webhook head ${request.headSha}`);
224
+ }
225
+ run('git', ['checkout', '--detach', request.headSha], { cwd: checkoutDir, env });
226
+ return checkoutDir;
227
+ }
228
+
229
+ function ghToken(env = process.env) {
230
+ return cleanString(env.GH_TOKEN) || cleanString(env.GITHUB_TOKEN);
231
+ }
232
+
233
+ function truncateDescription(value) {
234
+ const text = cleanString(value).replace(/\s+/g, ' ');
235
+ return text.length > 140 ? `${text.slice(0, 137)}...` : text;
236
+ }
237
+
238
+ function statusPayload(request, state, description, env = process.env) {
239
+ const context = cleanString(env.AMALGM_PR_CHECK_STATUS_CONTEXT) || DEFAULT_STATUS_CONTEXT;
240
+ const targetUrl = cleanString(env.AMALGM_PR_CHECK_TARGET_URL)
241
+ || `https://github.com/${request.repository}/pull/${request.number}`;
242
+ return {
243
+ state,
244
+ context,
245
+ description: truncateDescription(description),
246
+ targetUrl,
247
+ };
248
+ }
249
+
250
+ function postGithubStatus(request, state, description, env = process.env) {
251
+ const token = ghToken(env);
252
+ if (!token) {
253
+ console.warn('Missing GH_TOKEN/GITHUB_TOKEN; skipping GitHub status update.');
254
+ return false;
255
+ }
256
+ const payload = statusPayload(request, state, description, env);
257
+ const args = [
258
+ 'api',
259
+ '-X',
260
+ 'POST',
261
+ `repos/${request.repository}/statuses/${request.headSha}`,
262
+ '-f',
263
+ `state=${payload.state}`,
264
+ '-f',
265
+ `context=${payload.context}`,
266
+ '-f',
267
+ `description=${payload.description}`,
268
+ '-f',
269
+ `target_url=${payload.targetUrl}`,
270
+ ];
271
+ run('gh', args, { env: { ...env, GH_TOKEN: token, GITHUB_TOKEN: token } });
272
+ return true;
273
+ }
274
+
275
+ function runPrCheck(request, options = {}) {
276
+ applyPrCheckEnv(process.env);
277
+ const checkEnv = prCheckToolEnv(process.env);
278
+ const decision = shouldHandleRequest(request);
279
+ if (!decision.ok) {
280
+ console.log(decision.reason);
281
+ return { ok: true, skipped: true, reason: decision.reason };
282
+ }
283
+
284
+ const command = commandForRequest(request, checkEnv);
285
+ if (request.dryRun || options.dryRun) {
286
+ return {
287
+ ok: true,
288
+ dryRun: true,
289
+ repository: request.repository,
290
+ number: request.number,
291
+ baseBranch: request.baseBranch,
292
+ headSha: request.headSha,
293
+ command,
294
+ status: statusPayload(request, 'pending', 'PR check would run.', checkEnv),
295
+ };
296
+ }
297
+
298
+ postGithubStatus(request, 'pending', 'Amalgm PR check is running.', checkEnv);
299
+ const checkoutDir = checkoutPullRequest(request, checkEnv);
300
+ try {
301
+ runShell(command, { cwd: checkoutDir, env: checkEnv });
302
+ postGithubStatus(request, 'success', 'Amalgm PR check passed.', checkEnv);
303
+ return {
304
+ ok: true,
305
+ skipped: false,
306
+ repository: request.repository,
307
+ number: request.number,
308
+ baseBranch: request.baseBranch,
309
+ headSha: request.headSha,
310
+ checkoutDir,
311
+ command,
312
+ };
313
+ } catch (error) {
314
+ const detail = cleanString(error.stderr) || cleanString(error.stdout) || error.message;
315
+ postGithubStatus(request, 'failure', `Amalgm PR check failed: ${detail}`, checkEnv);
316
+ throw error;
317
+ }
318
+ }
319
+
320
+ function main() {
321
+ const request = requestFromEnv(process.env);
322
+ const result = runPrCheck(request);
323
+ console.log(JSON.stringify(result, null, 2));
324
+ }
325
+
326
+ if (require.main === module) {
327
+ try {
328
+ main();
329
+ } catch (error) {
330
+ console.error(error.stack || error.message || error);
331
+ process.exit(1);
332
+ }
333
+ }
334
+
335
+ module.exports = {
336
+ ENGINE_REPO_FULL_NAME,
337
+ UI_REPO_FULL_NAME,
338
+ commandForRequest,
339
+ requestFromEnv,
340
+ requestFromPayload,
341
+ runPrCheck,
342
+ shouldHandleRequest,
343
+ statusPayload,
344
+ };
@@ -135,3 +135,33 @@ test('installing an agent bundle creates local copies and rewrites subagent refs
135
135
  assert.notEqual(installedRootConfig.subagents[0].agentId, child.id);
136
136
  assert.equal(Boolean(resolveAgent(installedRootConfig.subagents[0].agentId)), true);
137
137
  });
138
+
139
+ test('installing an agent bundle rejects missing shared subagents', () => {
140
+ assert.throws(
141
+ () => installAgentBundle({
142
+ kind: 'amalgm.agent.bundle',
143
+ schemaVersion: 1,
144
+ rootAgentId: 'missing-child-root',
145
+ title: 'Missing child root',
146
+ agents: [{
147
+ sourceAgentId: 'missing-child-root',
148
+ agent: {
149
+ name: 'Missing child root',
150
+ baseHarnessId: 'codex',
151
+ baseModelId: 'openai/gpt-5.5',
152
+ },
153
+ config: {
154
+ instructions: 'Root',
155
+ loadout: { toolIds: [] },
156
+ subagents: [{
157
+ id: 'missing-child-ref',
158
+ agentId: 'missing-child',
159
+ name: 'Missing child',
160
+ enabled: true,
161
+ }],
162
+ },
163
+ }],
164
+ }),
165
+ /missing shared subagents: missing-child/,
166
+ );
167
+ });
@@ -27,10 +27,13 @@ test('automations store seeds the internal release webhook automation', () => {
27
27
  assert.deepEqual(automation.workflows.map((workflow) => workflow.id), [
28
28
  'internal-amalgm-desktop-release-workflow',
29
29
  'internal-amalgm-npm-release-workflow',
30
+ 'internal-amalgm-pr-check-workflow',
30
31
  ]);
31
32
  assert.equal(automation.workflows[0].status, 'active');
32
33
  assert.equal(automation.workflows[1].status, 'active');
34
+ assert.equal(automation.workflows[2].status, 'active');
33
35
  assert.equal(automation.workflows[1].name, 'Publish npm runtime release');
36
+ assert.equal(automation.workflows[2].name, 'Run PR checks');
34
37
  assert.match(automation.workflows[0].workflowText, /String\(secrets\.CSC_LINK \|\| ""\)\.trim\(\)/);
35
38
  assert.doesNotMatch(automation.workflows[0].workflowText, /CSC_LINK: secrets\.CSC_LINK \|\| ""/);
36
39
  assert.match(automation.workflows[0].workflowText, /AMALGM_DESKTOP_RELEASE_PROVIDER/);
@@ -55,4 +58,12 @@ test('automations store seeds the internal release webhook automation', () => {
55
58
  assert.equal(trigger.event, 'push');
56
59
  assert.equal(trigger.webhookUrl, 'https://desktop-release.example/events');
57
60
  assert.match(trigger.secret, /^[a-f0-9]{32}$/);
61
+
62
+ const prTrigger = listTriggers().find((item) => item.id === 'internal-amalgm-pr-check-github-pull-request');
63
+ assert.ok(prTrigger);
64
+ assert.equal(prTrigger.kind, 'event');
65
+ assert.equal(prTrigger.source, 'github');
66
+ assert.equal(prTrigger.event, 'pull_request');
67
+ assert.equal(prTrigger.webhookUrl, 'https://desktop-release.example/events');
68
+ assert.match(prTrigger.secret, /^[a-f0-9]{32}$/);
58
69
  });
@@ -10,10 +10,12 @@ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-s
10
10
  process.env.DATA_ROOT = tempRoot;
11
11
 
12
12
  const {
13
+ cleanupReleaseLane,
13
14
  createServer,
14
15
  latestArtifactName,
15
16
  latestDownloadTarget,
16
17
  parseDownloadPath,
18
+ releaseKeyForArtifact,
17
19
  } = require(path.resolve(__dirname, '../../../../infra/desktop-release-server/server.js'));
18
20
 
19
21
  test.after(() => {
@@ -26,6 +28,29 @@ function writeReleaseMetadata(lane, artifacts) {
26
28
  fs.writeFileSync(path.join(laneDir, 'desktop-release.json'), `${JSON.stringify({ lane, artifacts }, null, 2)}\n`);
27
29
  }
28
30
 
31
+ function writeLaneFile(lane, fileName, mtimeOffsetMs = 0) {
32
+ const laneDir = path.join(tempRoot, lane);
33
+ fs.mkdirSync(laneDir, { recursive: true });
34
+ const filePath = path.join(laneDir, fileName);
35
+ fs.writeFileSync(filePath, fileName);
36
+ const when = new Date(Date.now() + mtimeOffsetMs);
37
+ fs.utimesSync(filePath, when, when);
38
+ return filePath;
39
+ }
40
+
41
+ function writeReleaseFiles(lane, version, mtimeOffsetMs = 0) {
42
+ const prefix = lane === 'preview' ? 'amalgm-preview' : 'amalgm';
43
+ const zipSuffix = lane === 'preview' ? 'arm64.zip' : 'arm64-mac.zip';
44
+ const files = [
45
+ `${prefix}-${version}-${zipSuffix}`,
46
+ `${prefix}-${version}-${zipSuffix}.blockmap`,
47
+ `${prefix}-${version}-arm64.dmg`,
48
+ `${prefix}-${version}-arm64.dmg.blockmap`,
49
+ ];
50
+ for (const fileName of files) writeLaneFile(lane, fileName, mtimeOffsetMs);
51
+ return files;
52
+ }
53
+
29
54
  test('desktop release server resolves stable bridge download targets from metadata', () => {
30
55
  const artifacts = [
31
56
  'amalgm-0.1.1-arm64-mac.zip',
@@ -47,6 +72,45 @@ test('desktop release server resolves stable bridge download targets from metada
47
72
  });
48
73
  });
49
74
 
75
+ test('desktop release server groups versioned release artifacts', () => {
76
+ assert.equal(
77
+ releaseKeyForArtifact('amalgm-0.1.139881837-arm64-mac.zip.blockmap'),
78
+ 'amalgm@0.1.139881837',
79
+ );
80
+ assert.equal(
81
+ releaseKeyForArtifact('amalgm-preview-0.1.139882282-preview.1-arm64.dmg'),
82
+ 'amalgm-preview@0.1.139882282-preview.1',
83
+ );
84
+ assert.equal(releaseKeyForArtifact('latest-mac.yml'), '');
85
+ });
86
+
87
+ test('desktop release server prunes stale lane artifacts after publish', () => {
88
+ const oldFiles = writeReleaseFiles('main', '0.1.100', -40_000);
89
+ const previousFiles = writeReleaseFiles('main', '0.1.101', -30_000);
90
+ const newestFiles = writeReleaseFiles('main', '0.1.102', -10_000);
91
+ const currentFiles = writeReleaseFiles('main', '0.1.099', -50_000);
92
+ writeLaneFile('main', 'latest-mac.yml', -5_000);
93
+ writeLaneFile('main', 'notes.txt', -5_000);
94
+ writeReleaseMetadata('main', [...currentFiles, 'latest-mac.yml']);
95
+
96
+ const uploadDir = path.join(tempRoot, '.uploads', 'main');
97
+ fs.mkdirSync(uploadDir, { recursive: true });
98
+ fs.writeFileSync(path.join(uploadDir, 'amalgm-0.1.103-arm64-mac.zip.part'), 'partial');
99
+
100
+ const result = cleanupReleaseLane('main', { keepReleaseCount: 1 });
101
+
102
+ assert.equal(result.ok, true);
103
+ assert.equal(result.uploadsDeleted, 1);
104
+ for (const fileName of oldFiles) assert.equal(fs.existsSync(path.join(tempRoot, 'main', fileName)), false);
105
+ for (const fileName of previousFiles) assert.equal(fs.existsSync(path.join(tempRoot, 'main', fileName)), false);
106
+ for (const fileName of newestFiles) assert.equal(fs.existsSync(path.join(tempRoot, 'main', fileName)), true);
107
+ for (const fileName of currentFiles) assert.equal(fs.existsSync(path.join(tempRoot, 'main', fileName)), true);
108
+ assert.equal(fs.existsSync(path.join(tempRoot, 'main', 'latest-mac.yml')), true);
109
+ assert.equal(fs.existsSync(path.join(tempRoot, 'main', 'desktop-release.json')), true);
110
+ assert.equal(fs.existsSync(path.join(tempRoot, 'main', 'notes.txt')), true);
111
+ assert.equal(fs.readdirSync(uploadDir).length, 0);
112
+ });
113
+
50
114
  test('desktop release server redirects public bridge download URLs to current assets', async () => {
51
115
  writeReleaseMetadata('preview', [
52
116
  'amalgm-preview-0.1.1-preview.1-arm64.zip',
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const {
7
+ ENGINE_REPO_FULL_NAME,
8
+ UI_REPO_FULL_NAME,
9
+ commandForRequest,
10
+ requestFromPayload,
11
+ shouldHandleRequest,
12
+ statusPayload,
13
+ } = require('../events/pr-check-runner');
14
+
15
+ const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678';
16
+
17
+ function payload(overrides = {}) {
18
+ const repository = overrides.repository || ENGINE_REPO_FULL_NAME;
19
+ return {
20
+ action: overrides.action || 'synchronize',
21
+ number: overrides.number || 42,
22
+ repository: { full_name: repository },
23
+ pull_request: {
24
+ number: overrides.number || 42,
25
+ draft: Boolean(overrides.draft),
26
+ base: { ref: overrides.baseBranch || 'main' },
27
+ head: {
28
+ ref: overrides.headRef || 'feature/pr-check',
29
+ sha: overrides.headSha || HEAD_SHA,
30
+ repo: { full_name: overrides.headRepository || repository },
31
+ },
32
+ },
33
+ };
34
+ }
35
+
36
+ test('requestFromPayload normalizes a GitHub pull request payload', () => {
37
+ const request = requestFromPayload(payload({ repository: UI_REPO_FULL_NAME, baseBranch: 'preview' }));
38
+
39
+ assert.equal(request.repository, UI_REPO_FULL_NAME);
40
+ assert.equal(request.action, 'synchronize');
41
+ assert.equal(request.number, 42);
42
+ assert.equal(request.baseBranch, 'preview');
43
+ assert.equal(request.headSha, HEAD_SHA);
44
+ assert.equal(request.headRepository, UI_REPO_FULL_NAME);
45
+ });
46
+
47
+ test('shouldHandleRequest accepts same-repo PRs into main or preview', () => {
48
+ const request = requestFromPayload(payload({ repository: ENGINE_REPO_FULL_NAME, baseBranch: 'main' }));
49
+ assert.deepEqual(shouldHandleRequest(request), { ok: true });
50
+ });
51
+
52
+ test('shouldHandleRequest rejects unsupported PR shapes', () => {
53
+ assert.match(
54
+ shouldHandleRequest(requestFromPayload(payload({ baseBranch: 'feature' }))).reason,
55
+ /base branch/,
56
+ );
57
+ assert.match(
58
+ shouldHandleRequest(requestFromPayload(payload({ draft: true }))).reason,
59
+ /draft/,
60
+ );
61
+ assert.match(
62
+ shouldHandleRequest(requestFromPayload(payload({ headRepository: 'someone/fork' }))).reason,
63
+ /forked/,
64
+ );
65
+ assert.match(
66
+ shouldHandleRequest(requestFromPayload(payload({ action: 'closed' }))).reason,
67
+ /action/,
68
+ );
69
+ });
70
+
71
+ test('commandForRequest returns the repo-specific default command', () => {
72
+ assert.equal(
73
+ commandForRequest(requestFromPayload(payload({ repository: ENGINE_REPO_FULL_NAME })), {}),
74
+ 'npm ci && npm run ci:pr',
75
+ );
76
+ assert.equal(
77
+ commandForRequest(requestFromPayload(payload({ repository: UI_REPO_FULL_NAME })), {}),
78
+ 'npm ci && npm run ci:pr',
79
+ );
80
+ });
81
+
82
+ test('statusPayload uses the stable Amalgm PR check context', () => {
83
+ const request = requestFromPayload(payload({ repository: UI_REPO_FULL_NAME }));
84
+ const status = statusPayload(request, 'pending', 'Running the Amalgm PR check.', {});
85
+
86
+ assert.equal(status.state, 'pending');
87
+ assert.equal(status.context, 'amalgm/pr-check');
88
+ assert.equal(status.targetUrl, 'https://github.com/amalgm-inc/amalgm-ui/pull/42');
89
+ });