@open-mercato/cli 0.7.0 → 0.7.1-develop.7103.1.41ff100d93
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/.turbo/turbo-build.log +7 -1
- package/dist/agentic/guides/module-facts.json +5509 -599
- package/dist/agentic/guides/module-facts.v2.json +5595 -599
- package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
- package/dist/agentic/guides/modules/api_docs/index.md +1 -1
- package/dist/agentic/guides/modules/api_keys/di-registrations-rich.md +11 -0
- package/dist/agentic/guides/modules/api_keys/di-service-tokens.md +11 -0
- package/dist/agentic/guides/modules/api_keys/exact-override-targets.md +1 -0
- package/dist/agentic/guides/modules/api_keys/index.md +4 -2
- package/dist/agentic/guides/modules/attachments/di-registrations-rich.md +6 -5
- package/dist/agentic/guides/modules/attachments/di-service-tokens.md +5 -4
- package/dist/agentic/guides/modules/attachments/exact-override-targets.md +6 -5
- package/dist/agentic/guides/modules/attachments/index.md +2 -2
- package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
- package/dist/agentic/guides/modules/auth/di-registrations-rich.md +3 -2
- package/dist/agentic/guides/modules/auth/di-service-tokens.md +3 -2
- package/dist/agentic/guides/modules/auth/exact-override-targets.md +3 -2
- package/dist/agentic/guides/modules/auth/index.md +1 -1
- package/dist/agentic/guides/modules/business_rules/index.md +1 -1
- package/dist/agentic/guides/modules/catalog/incoming-installed-contributions.md +3 -0
- package/dist/agentic/guides/modules/catalog/index.md +2 -2
- package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
- package/dist/agentic/guides/modules/channel_discord/acl-features.md +13 -0
- package/dist/agentic/guides/modules/channel_discord/ai-agents.md +11 -0
- package/dist/agentic/guides/modules/channel_discord/backend-pages.md +11 -0
- package/dist/agentic/guides/modules/channel_discord/cli-commands.md +13 -0
- package/dist/agentic/guides/modules/channel_discord/contribution-resolutions.md +14 -0
- package/dist/agentic/guides/modules/channel_discord/di-registrations-rich.md +12 -0
- package/dist/agentic/guides/modules/channel_discord/exact-override-targets.md +26 -0
- package/dist/agentic/guides/modules/channel_discord/index.md +22 -0
- package/dist/agentic/guides/modules/channel_discord/owned-contract-module-metadata.md +11 -0
- package/dist/agentic/guides/modules/channel_discord/setup.md +11 -0
- package/dist/agentic/guides/modules/channel_discord/umes-contributions.md +14 -0
- package/dist/agentic/guides/modules/channel_discord/workers.md +12 -0
- package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
- package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
- package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
- package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
- package/dist/agentic/guides/modules/checkout/index.md +1 -1
- package/dist/agentic/guides/modules/communication_channels/di-registrations-rich.md +9 -8
- package/dist/agentic/guides/modules/communication_channels/exact-override-targets.md +9 -8
- package/dist/agentic/guides/modules/communication_channels/incoming-installed-contributions.md +2 -0
- package/dist/agentic/guides/modules/communication_channels/index.md +3 -3
- package/dist/agentic/guides/modules/configs/index.md +1 -1
- package/dist/agentic/guides/modules/content/index.md +1 -1
- package/dist/agentic/guides/modules/currencies/index.md +1 -1
- package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
- package/dist/agentic/guides/modules/customers/active-extension-bindings.md +6 -3
- package/dist/agentic/guides/modules/customers/contribution-resolutions.md +1 -0
- package/dist/agentic/guides/modules/customers/events.md +2 -1
- package/dist/agentic/guides/modules/customers/incoming-installed-contributions.md +7 -0
- package/dist/agentic/guides/modules/customers/index.md +4 -4
- package/dist/agentic/guides/modules/customers/umes-contributions.md +1 -0
- package/dist/agentic/guides/modules/customers/umes-hosts.md +1 -0
- package/dist/agentic/guides/modules/dashboards/index.md +1 -1
- package/dist/agentic/guides/modules/data_sync/exact-override-targets.md +2 -2
- package/dist/agentic/guides/modules/data_sync/index.md +1 -1
- package/dist/agentic/guides/modules/data_sync/workers.md +2 -2
- package/dist/agentic/guides/modules/design_system/index.md +1 -1
- package/dist/agentic/guides/modules/devices/index.md +1 -1
- package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
- package/dist/agentic/guides/modules/directory/di-registrations-rich.md +12 -0
- package/dist/agentic/guides/modules/directory/di-service-tokens.md +12 -0
- package/dist/agentic/guides/modules/directory/exact-override-targets.md +2 -0
- package/dist/agentic/guides/modules/directory/index.md +5 -3
- package/dist/agentic/guides/modules/documents/acl-features.md +17 -0
- package/dist/agentic/guides/modules/documents/backend-pages.md +13 -0
- package/dist/agentic/guides/modules/documents/contribution-resolutions.md +27 -0
- package/dist/agentic/guides/modules/documents/di-registrations-rich.md +21 -0
- package/dist/agentic/guides/modules/documents/domain-commands.md +37 -0
- package/dist/agentic/guides/modules/documents/encryption.md +11 -0
- package/dist/agentic/guides/modules/documents/entities.md +21 -0
- package/dist/agentic/guides/modules/documents/events.md +25 -0
- package/dist/agentic/guides/modules/documents/exact-override-targets.md +39 -0
- package/dist/agentic/guides/modules/documents/index.md +25 -0
- package/dist/agentic/guides/modules/documents/notifications.md +13 -0
- package/dist/agentic/guides/modules/documents/owned-contract-module-metadata.md +11 -0
- package/dist/agentic/guides/modules/documents/setup.md +11 -0
- package/dist/agentic/guides/modules/documents/umes-contributions.md +27 -0
- package/dist/agentic/guides/modules/documents/umes-hosts.md +36 -0
- package/dist/agentic/guides/modules/entities/index.md +1 -1
- package/dist/agentic/guides/modules/eudr/index.md +1 -1
- package/dist/agentic/guides/modules/events/index.md +1 -1
- package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
- package/dist/agentic/guides/modules/gateway_stripe/exact-override-targets.md +1 -1
- package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
- package/dist/agentic/guides/modules/gateway_stripe/workers.md +1 -1
- package/dist/agentic/guides/modules/generators/index.md +1 -1
- package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
- package/dist/agentic/guides/modules/integrations/index.md +1 -1
- package/dist/agentic/guides/modules/messages/index.md +1 -1
- package/dist/agentic/guides/modules/notifications/index.md +1 -1
- package/dist/agentic/guides/modules/onboarding/index.md +1 -1
- package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
- package/dist/agentic/guides/modules/perspectives/index.md +1 -1
- package/dist/agentic/guides/modules/planner/index.md +1 -1
- package/dist/agentic/guides/modules/portal/index.md +1 -1
- package/dist/agentic/guides/modules/progress/index.md +1 -1
- package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
- package/dist/agentic/guides/modules/query_index/index.md +1 -1
- package/dist/agentic/guides/modules/record_locks/index.md +1 -1
- package/dist/agentic/guides/modules/resources/index.md +1 -1
- package/dist/agentic/guides/modules/sales/active-extension-bindings.md +1 -0
- package/dist/agentic/guides/modules/sales/contribution-resolutions.md +1 -0
- package/dist/agentic/guides/modules/sales/exact-override-targets.md +5 -4
- package/dist/agentic/guides/modules/sales/host-extension-points.md +1 -1
- package/dist/agentic/guides/modules/sales/incoming-installed-contributions.md +2 -0
- package/dist/agentic/guides/modules/sales/index.md +3 -3
- package/dist/agentic/guides/modules/sales/setup.md +1 -1
- package/dist/agentic/guides/modules/sales/umes-contributions.md +1 -0
- package/dist/agentic/guides/modules/sales/umes-diagnostics.md +1 -0
- package/dist/agentic/guides/modules/sales/umes-hosts.md +9 -0
- package/dist/agentic/guides/modules/scheduler/cli-commands.md +1 -0
- package/dist/agentic/guides/modules/scheduler/exact-override-targets.md +3 -1
- package/dist/agentic/guides/modules/scheduler/index.md +2 -2
- package/dist/agentic/guides/modules/scheduler/workers.md +2 -1
- package/dist/agentic/guides/modules/search/index.md +1 -1
- package/dist/agentic/guides/modules/security/index.md +1 -1
- package/dist/agentic/guides/modules/security/owned-contract-module-metadata.md +1 -1
- package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
- package/dist/agentic/guides/modules/sso/index.md +1 -1
- package/dist/agentic/guides/modules/staff/index.md +1 -1
- package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
- package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
- package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
- package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
- package/dist/agentic/guides/modules/translations/index.md +1 -1
- package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
- package/dist/agentic/guides/modules/webhooks/index.md +1 -1
- package/dist/agentic/guides/modules/wms/index.md +1 -1
- package/dist/agentic/guides/modules/workflows/index.md +1 -1
- package/dist/agentic/guides/reference-module-facts.json +2 -2
- package/dist/agentic/guides/reference-modules/example/index.md +1 -1
- package/dist/agentic/guides/upstream/manifest.json +1 -1
- package/dist/agentic/shared/AGENTS.md.template +3 -3
- package/dist/agentic/shared/ai/harness/README.md +2 -2
- package/dist/agentic/shared/ai/harness/RELEASE.md +8 -6
- package/dist/agentic/shared/ai/harness/cases.json +83 -0
- package/dist/agentic/shared/ai/harness/cases.schema.json +4 -4
- package/dist/agentic/shared/ai/harness/validators.json +1 -1
- package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +37 -11
- package/dist/lib/generators/ast/index.js +4 -1
- package/dist/lib/generators/ast/index.js.map +2 -2
- package/dist/lib/generators/ast/source-file.js +8 -1
- package/dist/lib/generators/ast/source-file.js.map +2 -2
- package/dist/lib/generators/ast/writers.js +9 -1
- package/dist/lib/generators/ast/writers.js.map +2 -2
- package/dist/lib/generators/module-registry.js +424 -189
- package/dist/lib/generators/module-registry.js.map +2 -2
- package/dist/lib/generators/route-manifest-shards.js +4 -1
- package/dist/lib/generators/route-manifest-shards.js.map +2 -2
- package/dist/lib/spawn.js +3 -2
- package/dist/lib/spawn.js.map +2 -2
- package/dist/lib/testing/integration.js +162 -24
- package/dist/lib/testing/integration.js.map +2 -2
- package/dist/mercato.js +14 -0
- package/dist/mercato.js.map +2 -2
- package/package.json +8 -7
- package/src/__tests__/resolve-queue-abandon-hook.test.ts +39 -0
- package/src/lib/__tests__/spawn.test.ts +23 -0
- package/src/lib/generators/__tests__/__snapshots__/bootstrap-registrations.test.ts.snap +14 -0
- package/src/lib/generators/__tests__/__snapshots__/command-loaders.test.ts.snap +23 -0
- package/src/lib/generators/__tests__/ast-writers-escaping.test.ts +64 -0
- package/src/lib/generators/__tests__/bootstrap-registrations.test.ts +176 -0
- package/src/lib/generators/__tests__/command-loaders.test.ts +239 -0
- package/src/lib/generators/__tests__/generated-registry-imports.test.ts +128 -0
- package/src/lib/generators/__tests__/metadata-extraction.test.ts +44 -0
- package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +32 -8
- package/src/lib/generators/__tests__/module-facts.customers.fixture.test.ts +4 -1
- package/src/lib/generators/__tests__/module-facts.extension-hosts.test.ts +1 -1
- package/src/lib/generators/__tests__/module-subset.test.ts +96 -72
- package/src/lib/generators/__tests__/output-snapshots.test.ts +1 -1
- package/src/lib/generators/__tests__/registry-variant-parity.test.ts +258 -0
- package/src/lib/generators/__tests__/route-manifest-shards.test.ts +15 -0
- package/src/lib/generators/__tests__/structural-contracts.test.ts +7 -3
- package/src/lib/generators/ast/index.ts +2 -1
- package/src/lib/generators/ast/source-file.ts +13 -0
- package/src/lib/generators/ast/writers.ts +26 -1
- package/src/lib/generators/module-registry.ts +534 -227
- package/src/lib/generators/route-manifest-shards.ts +8 -1
- package/src/lib/spawn.ts +5 -4
- package/src/lib/testing/__tests__/integration.test.ts +435 -0
- package/src/lib/testing/integration.ts +254 -32
- package/src/mercato.ts +27 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChildProcess, StdioOptions } from 'node:child_process'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
2
3
|
import { createServer } from 'node:net'
|
|
3
4
|
import { existsSync, readFileSync } from 'node:fs'
|
|
4
5
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
@@ -762,23 +763,56 @@ function runNpxCommand(args: string[], environment: NodeJS.ProcessEnv): Promise<
|
|
|
762
763
|
})
|
|
763
764
|
}
|
|
764
765
|
|
|
766
|
+
export type CapturedOutputProcess = ChildProcess & {
|
|
767
|
+
readCapturedOutput?: () => string
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export const CAPTURED_OUTPUT_MAX_LENGTH = 64 * 1024
|
|
771
|
+
|
|
772
|
+
export function createBoundedOutputBuffer(): { append: (chunk: Buffer | string) => void; read: () => string } {
|
|
773
|
+
let buffered = ''
|
|
774
|
+
return {
|
|
775
|
+
append: (chunk: Buffer | string) => {
|
|
776
|
+
buffered += chunk.toString()
|
|
777
|
+
if (buffered.length > CAPTURED_OUTPUT_MAX_LENGTH) {
|
|
778
|
+
buffered = `…(truncated)…${buffered.slice(-CAPTURED_OUTPUT_MAX_LENGTH)}`
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
read: () => buffered,
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Both streams are returned when both carry output: a single stderr deprecation notice must not
|
|
786
|
+
// hide the stdout tail that usually holds the real startup failure. The 20-line tail in
|
|
787
|
+
// `exitError()` does the trimming.
|
|
788
|
+
export function formatCapturedOutput(stderrText: string, stdoutText: string): string {
|
|
789
|
+
if (stderrText && stdoutText) {
|
|
790
|
+
return `--- stderr ---\n${stderrText}\n--- stdout ---\n${stdoutText}`
|
|
791
|
+
}
|
|
792
|
+
return stderrText || stdoutText
|
|
793
|
+
}
|
|
794
|
+
|
|
765
795
|
function startYarnRawCommand(
|
|
766
796
|
commandArgs: string[],
|
|
767
797
|
environment: NodeJS.ProcessEnv,
|
|
768
|
-
opts: { silent?: boolean } = {},
|
|
798
|
+
opts: { silent?: boolean; detached?: boolean } = {},
|
|
769
799
|
cwd: string = projectRootDirectory,
|
|
770
|
-
):
|
|
800
|
+
): CapturedOutputProcess {
|
|
771
801
|
const outputMode: StdioOptions = opts.silent ? ['ignore', 'pipe', 'pipe'] : 'inherit'
|
|
772
|
-
const resolvedSpawn = resolveSpawnCommand(resolveYarnBinary(), commandArgs)
|
|
773
|
-
const processHandle:
|
|
802
|
+
const resolvedSpawn = resolveSpawnCommand(resolveYarnBinary(), commandArgs, { detached: opts.detached })
|
|
803
|
+
const processHandle: CapturedOutputProcess = spawn(resolvedSpawn.command, resolvedSpawn.args, {
|
|
774
804
|
cwd,
|
|
775
805
|
env: environment,
|
|
776
806
|
stdio: outputMode,
|
|
777
807
|
...resolvedSpawn.spawnOptions,
|
|
778
808
|
})
|
|
779
809
|
if (opts.silent) {
|
|
780
|
-
|
|
781
|
-
|
|
810
|
+
const stdoutBuffer = createBoundedOutputBuffer()
|
|
811
|
+
const stderrBuffer = createBoundedOutputBuffer()
|
|
812
|
+
processHandle.stdout?.on('data', (chunk: Buffer | string) => stdoutBuffer.append(chunk))
|
|
813
|
+
processHandle.stderr?.on('data', (chunk: Buffer | string) => stderrBuffer.append(chunk))
|
|
814
|
+
processHandle.readCapturedOutput = () =>
|
|
815
|
+
formatCapturedOutput(stderrBuffer.read().trim(), stdoutBuffer.read().trim())
|
|
782
816
|
}
|
|
783
817
|
return processHandle
|
|
784
818
|
}
|
|
@@ -786,9 +820,9 @@ function startYarnRawCommand(
|
|
|
786
820
|
function startYarnCommand(
|
|
787
821
|
args: string[],
|
|
788
822
|
environment: NodeJS.ProcessEnv,
|
|
789
|
-
opts: { silent?: boolean } = {},
|
|
823
|
+
opts: { silent?: boolean; detached?: boolean } = {},
|
|
790
824
|
cwd: string = projectRootDirectory,
|
|
791
|
-
):
|
|
825
|
+
): CapturedOutputProcess {
|
|
792
826
|
return startYarnRawCommand(['run', ...args], environment, opts, cwd)
|
|
793
827
|
}
|
|
794
828
|
|
|
@@ -1768,6 +1802,172 @@ function isProcessRunning(processId: number): boolean {
|
|
|
1768
1802
|
}
|
|
1769
1803
|
}
|
|
1770
1804
|
|
|
1805
|
+
export type ProcessTreeKillDependencies = {
|
|
1806
|
+
platform?: NodeJS.Platform
|
|
1807
|
+
killPosixProcessGroup?: (pid: number, signal: NodeJS.Signals) => void
|
|
1808
|
+
killWindowsProcessTree?: (pid: number, options: { forced: boolean }) => void
|
|
1809
|
+
gracePeriodMs?: number
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function defaultKillPosixProcessGroup(pid: number, signal: NodeJS.Signals): void {
|
|
1813
|
+
process.kill(-pid, signal)
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// `/f` is added only on escalation so the Windows path mirrors the POSIX SIGTERM → grace → SIGKILL
|
|
1817
|
+
// sequence: the first attempt lets the tree shut down cleanly, the second forces it.
|
|
1818
|
+
function defaultKillWindowsProcessTree(pid: number, options: { forced: boolean }): void {
|
|
1819
|
+
spawnSync('taskkill', ['/pid', String(pid), '/t', ...(options.forced ? ['/f'] : [])])
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
export function killProcessTree(
|
|
1823
|
+
pid: number,
|
|
1824
|
+
signal: NodeJS.Signals,
|
|
1825
|
+
dependencies: ProcessTreeKillDependencies = {},
|
|
1826
|
+
): void {
|
|
1827
|
+
const platform = dependencies.platform ?? process.platform
|
|
1828
|
+
if (platform === 'win32') {
|
|
1829
|
+
const killWindowsProcessTree = dependencies.killWindowsProcessTree ?? defaultKillWindowsProcessTree
|
|
1830
|
+
killWindowsProcessTree(pid, { forced: signal === 'SIGKILL' })
|
|
1831
|
+
return
|
|
1832
|
+
}
|
|
1833
|
+
const killPosixProcessGroup = dependencies.killPosixProcessGroup ?? defaultKillPosixProcessGroup
|
|
1834
|
+
killPosixProcessGroup(pid, signal)
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
const PROCESS_TREE_KILL_GRACE_PERIOD_MS = 3_000
|
|
1838
|
+
|
|
1839
|
+
function killProcessTreeIfRunning(
|
|
1840
|
+
pid: number,
|
|
1841
|
+
signal: NodeJS.Signals,
|
|
1842
|
+
dependencies: ProcessTreeKillDependencies,
|
|
1843
|
+
): void {
|
|
1844
|
+
try {
|
|
1845
|
+
killProcessTree(pid, signal, dependencies)
|
|
1846
|
+
} catch (error) {
|
|
1847
|
+
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
|
|
1848
|
+
throw error
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// `getProcessExitPromise` only observes *future* events, so a process that already exited would
|
|
1854
|
+
// never settle it and the caller would stall for the whole grace period. This waiter detaches both
|
|
1855
|
+
// listeners and clears the timer whichever way it settles, so nothing is left pending and a late
|
|
1856
|
+
// `'error'` cannot surface as an unhandled rejection.
|
|
1857
|
+
function waitForProcessExitWithin(childProcess: ChildProcess, timeoutMs: number): Promise<boolean> {
|
|
1858
|
+
return new Promise((resolve) => {
|
|
1859
|
+
const settle = (exited: boolean) => {
|
|
1860
|
+
clearTimeout(gracePeriodTimer)
|
|
1861
|
+
childProcess.off('exit', onExit)
|
|
1862
|
+
childProcess.off('error', onError)
|
|
1863
|
+
resolve(exited)
|
|
1864
|
+
}
|
|
1865
|
+
const onExit = () => settle(true)
|
|
1866
|
+
const onError = () => settle(false)
|
|
1867
|
+
const gracePeriodTimer = setTimeout(() => settle(false), timeoutMs)
|
|
1868
|
+
childProcess.on('exit', onExit)
|
|
1869
|
+
childProcess.on('error', onError)
|
|
1870
|
+
})
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// A `ChildProcess` reports `null` on both fields while it is alive, so a non-null value means the
|
|
1874
|
+
// exit has already happened and its `'exit'` event will never fire again.
|
|
1875
|
+
function hasProcessAlreadyExited(childProcess: ChildProcess): boolean {
|
|
1876
|
+
return (childProcess.exitCode ?? null) !== null || (childProcess.signalCode ?? null) !== null
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
export async function terminateProcessTree(
|
|
1880
|
+
childProcess: CapturedOutputProcess,
|
|
1881
|
+
dependencies: ProcessTreeKillDependencies = {},
|
|
1882
|
+
): Promise<void> {
|
|
1883
|
+
const pid = childProcess.pid
|
|
1884
|
+
if (!pid) return
|
|
1885
|
+
// A reaped leader answers only "waiting for its `'exit'` event can never settle" — without this the
|
|
1886
|
+
// crash path would stall the whole teardown for the grace period waiting for an event that already
|
|
1887
|
+
// fired. It does *not* mean the process group is empty: on the readiness-failure path the `yarn`
|
|
1888
|
+
// wrapper exits non-zero while the `mercato start`/Next/worker descendants it spawned stay in its
|
|
1889
|
+
// group, so the group still has to be signalled or #5333's orphan survives verbatim. POSIX keeps
|
|
1890
|
+
// the group id reserved while any member holds it, so signalling it after the leader is reaped is
|
|
1891
|
+
// both safe and effective. Skip the wait, never the signal — and go straight to SIGKILL, since
|
|
1892
|
+
// there is no leader left to coordinate a graceful shutdown or to report the exit we would await.
|
|
1893
|
+
if (hasProcessAlreadyExited(childProcess)) {
|
|
1894
|
+
killProcessTreeIfRunning(pid, 'SIGKILL', dependencies)
|
|
1895
|
+
return
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
killProcessTreeIfRunning(pid, 'SIGTERM', dependencies)
|
|
1899
|
+
|
|
1900
|
+
const gracePeriodMs = dependencies.gracePeriodMs ?? PROCESS_TREE_KILL_GRACE_PERIOD_MS
|
|
1901
|
+
const exitedBeforeGracePeriod = await waitForProcessExitWithin(childProcess, gracePeriodMs)
|
|
1902
|
+
|
|
1903
|
+
if (!exitedBeforeGracePeriod && isProcessRunning(pid)) {
|
|
1904
|
+
killProcessTreeIfRunning(pid, 'SIGKILL', dependencies)
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
const EPHEMERAL_SHUTDOWN_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']
|
|
1909
|
+
|
|
1910
|
+
export type ShutdownProcessRef = Pick<NodeJS.Process, 'once' | 'off' | 'removeAllListeners' | 'kill' | 'pid'>
|
|
1911
|
+
|
|
1912
|
+
export type EphemeralShutdownHandlers = { dispose: () => void }
|
|
1913
|
+
|
|
1914
|
+
// The application is spawned `detached: true`, which puts its whole tree in a new session. A
|
|
1915
|
+
// terminal Ctrl+C only reaches the foreground process group of the controlling terminal, so the
|
|
1916
|
+
// tree never sees the signal and would outlive the run holding `server-start.lock` — exactly the
|
|
1917
|
+
// orphan this harness exists to prevent. Node also skips `'exit'` listeners when it dies from a
|
|
1918
|
+
// signal, so the crash sweep cannot cover this path either. The runner therefore forwards the
|
|
1919
|
+
// interrupt itself: stop the environment, then re-raise the signal so the process still exits with
|
|
1920
|
+
// the conventional 130/143 instead of a synthetic success.
|
|
1921
|
+
export function registerEphemeralShutdownHandlers(options: {
|
|
1922
|
+
stop: () => Promise<void>
|
|
1923
|
+
killApplicationTree: () => void
|
|
1924
|
+
onSignal?: (signal: NodeJS.Signals) => void
|
|
1925
|
+
processRef?: ShutdownProcessRef
|
|
1926
|
+
}): EphemeralShutdownHandlers {
|
|
1927
|
+
const processRef = options.processRef ?? process
|
|
1928
|
+
const onProcessExit = () => options.killApplicationTree()
|
|
1929
|
+
const signalHandlers = new Map<NodeJS.Signals, () => void>()
|
|
1930
|
+
|
|
1931
|
+
// Registered per `startEphemeralEnvironment()` call, and the environment is restarted on retry,
|
|
1932
|
+
// so the handlers must come back off in `stop()` or a retried run stacks dead closures until it
|
|
1933
|
+
// trips MaxListenersExceededWarning.
|
|
1934
|
+
const dispose = () => {
|
|
1935
|
+
processRef.off('exit', onProcessExit)
|
|
1936
|
+
for (const [signal, handler] of signalHandlers) {
|
|
1937
|
+
processRef.off(signal, handler)
|
|
1938
|
+
}
|
|
1939
|
+
signalHandlers.clear()
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
for (const signal of EPHEMERAL_SHUTDOWN_SIGNALS) {
|
|
1943
|
+
const handler = () => {
|
|
1944
|
+
void (async () => {
|
|
1945
|
+
options.onSignal?.(signal)
|
|
1946
|
+
try {
|
|
1947
|
+
await options.stop()
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
// A teardown failure must not become an unhandled rejection, and must not swallow the
|
|
1950
|
+
// signal: report it and still re-raise so the runner exits the way the shell expects.
|
|
1951
|
+
console.error(`Failed to stop the ephemeral environment on ${signal}:`, error)
|
|
1952
|
+
} finally {
|
|
1953
|
+
dispose()
|
|
1954
|
+
// Load-bearing, not cleanup: `dispose()` already detached this module's `once` handler, so
|
|
1955
|
+
// the only listeners left belong to somebody else (testcontainers, Playwright, a host CLI).
|
|
1956
|
+
// Any survivor would swallow the re-raised signal and turn the interrupt back into the
|
|
1957
|
+
// synthetic success this whole path exists to avoid, so they come off before the re-raise.
|
|
1958
|
+
processRef.removeAllListeners(signal)
|
|
1959
|
+
processRef.kill(processRef.pid as number, signal)
|
|
1960
|
+
}
|
|
1961
|
+
})()
|
|
1962
|
+
}
|
|
1963
|
+
signalHandlers.set(signal, handler)
|
|
1964
|
+
processRef.once(signal, handler)
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
processRef.once('exit', onProcessExit)
|
|
1968
|
+
return { dispose }
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1771
1971
|
async function getPathAgeMilliseconds(targetPath: string): Promise<number | null> {
|
|
1772
1972
|
try {
|
|
1773
1973
|
const stats = await stat(targetPath)
|
|
@@ -2094,7 +2294,7 @@ export async function tryReuseExistingEnvironment(options: EphemeralRuntimeOptio
|
|
|
2094
2294
|
|
|
2095
2295
|
export async function waitForApplicationReadiness(
|
|
2096
2296
|
baseUrl: string,
|
|
2097
|
-
appProcess:
|
|
2297
|
+
appProcess: CapturedOutputProcess,
|
|
2098
2298
|
options: { timeoutMs: number; intervalMs?: number; stabilizationMs?: number },
|
|
2099
2299
|
): Promise<void> {
|
|
2100
2300
|
const startTimestamp = Date.now()
|
|
@@ -2115,8 +2315,15 @@ export async function waitForApplicationReadiness(
|
|
|
2115
2315
|
return { exited: true as const }
|
|
2116
2316
|
},
|
|
2117
2317
|
)
|
|
2118
|
-
const exitError = () =>
|
|
2119
|
-
|
|
2318
|
+
const exitError = () => {
|
|
2319
|
+
const capturedOutput = appProcess.readCapturedOutput?.().trim()
|
|
2320
|
+
const capturedOutputTail = capturedOutput
|
|
2321
|
+
? `\nCaptured output:\n${capturedOutput.split('\n').slice(-20).join('\n')}`
|
|
2322
|
+
: ''
|
|
2323
|
+
return new Error(
|
|
2324
|
+
`Application process exited before readiness check (exit ${exitCode ?? 'unknown'})${capturedOutputTail}`,
|
|
2325
|
+
)
|
|
2326
|
+
}
|
|
2120
2327
|
|
|
2121
2328
|
while (Date.now() - startTimestamp < options.timeoutMs) {
|
|
2122
2329
|
// Run one probe cycle to completion before starting the next. Overlapping cycles (the previous
|
|
@@ -3420,21 +3627,45 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
|
|
|
3420
3627
|
})
|
|
3421
3628
|
|
|
3422
3629
|
const runtimeLock = await acquireEphemeralRuntimeLock(options.logPrefix)
|
|
3423
|
-
let applicationProcess:
|
|
3630
|
+
let applicationProcess: CapturedOutputProcess | null = null
|
|
3424
3631
|
let isStopped = false
|
|
3632
|
+
let shutdownHandlers: EphemeralShutdownHandlers | null = null
|
|
3425
3633
|
const stop = async (): Promise<void> => {
|
|
3426
3634
|
if (isStopped) return
|
|
3427
3635
|
isStopped = true
|
|
3428
3636
|
try {
|
|
3429
3637
|
if (applicationProcess && !applicationProcess.killed) {
|
|
3430
|
-
|
|
3638
|
+
try {
|
|
3639
|
+
await terminateProcessTree(applicationProcess)
|
|
3640
|
+
} catch (error) {
|
|
3641
|
+
// `killProcessTreeIfRunning` rethrows anything that is not `ESRCH`, and this call sits
|
|
3642
|
+
// ahead of the container and state cleanup. A kill that fails must not strand the
|
|
3643
|
+
// Postgres container and the state file too — report it and finish tearing down.
|
|
3644
|
+
console.error(`[${options.logPrefix}] Failed to terminate the application process tree:`, error)
|
|
3645
|
+
}
|
|
3431
3646
|
}
|
|
3432
3647
|
await databaseContainer.stop()
|
|
3433
3648
|
await clearEphemeralEnvironmentState()
|
|
3434
3649
|
} finally {
|
|
3435
3650
|
await runtimeLock.release()
|
|
3436
|
-
|
|
3437
|
-
|
|
3651
|
+
shutdownHandlers?.dispose()
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
shutdownHandlers = registerEphemeralShutdownHandlers({
|
|
3655
|
+
stop,
|
|
3656
|
+
onSignal: (signal) =>
|
|
3657
|
+
console.log(`[${options.logPrefix}] Received ${signal}, stopping ephemeral environment...`),
|
|
3658
|
+
// Deliberately not guarded on `isStopped`: `startEphemeralEnvironment`'s own catch already ran
|
|
3659
|
+
// `stop()` before rethrowing, so the guard made the sweep dead on exactly the paths that need
|
|
3660
|
+
// it. After a successful `stop()` the extra group kill is a harmless `ESRCH` this swallows.
|
|
3661
|
+
killApplicationTree: () => {
|
|
3662
|
+
const pid = applicationProcess?.pid
|
|
3663
|
+
if (!pid) return
|
|
3664
|
+
try {
|
|
3665
|
+
killProcessTree(pid, 'SIGKILL')
|
|
3666
|
+
} catch {}
|
|
3667
|
+
},
|
|
3668
|
+
})
|
|
3438
3669
|
|
|
3439
3670
|
try {
|
|
3440
3671
|
const appReadyTimeoutMs = resolveAppReadyTimeoutMs(options.logPrefix)
|
|
@@ -3521,6 +3752,7 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
|
|
|
3521
3752
|
console.log(`[${options.logPrefix}] Starting application on ${applicationBaseUrl}...`)
|
|
3522
3753
|
const startedAppProcess = startYarnCommand(['start'], commandEnvironment, {
|
|
3523
3754
|
silent: !options.verbose,
|
|
3755
|
+
detached: true,
|
|
3524
3756
|
}, appDirectory)
|
|
3525
3757
|
applicationProcess = startedAppProcess
|
|
3526
3758
|
|
|
@@ -3559,15 +3791,11 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
|
|
|
3559
3791
|
}
|
|
3560
3792
|
}
|
|
3561
3793
|
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
}
|
|
3568
|
-
|
|
3569
|
-
process.once('SIGINT', () => void onSignal('SIGINT'))
|
|
3570
|
-
process.once('SIGTERM', () => void onSignal('SIGTERM'))
|
|
3794
|
+
// Interrupt handling belongs to `startEphemeralEnvironment`, which owns the detached tree and now
|
|
3795
|
+
// registers SIGINT/SIGTERM handlers for every run — not just the `--keep` ones. Registering a
|
|
3796
|
+
// second pair here would race them: both fire on the same signal, and this one's `process.exit()`
|
|
3797
|
+
// would cut the environment's teardown short mid-await.
|
|
3798
|
+
async function keepEnvironmentRunningForever(): Promise<void> {
|
|
3571
3799
|
await new Promise<void>(() => {})
|
|
3572
3800
|
}
|
|
3573
3801
|
|
|
@@ -3617,10 +3845,7 @@ export async function runIntegrationTestsInEphemeralEnvironment(rawArgs: string[
|
|
|
3617
3845
|
|
|
3618
3846
|
if (options.keep) {
|
|
3619
3847
|
console.log('[integration] --keep enabled: leaving app and database running. Press Ctrl+C to stop.')
|
|
3620
|
-
await keepEnvironmentRunningForever(
|
|
3621
|
-
logPrefix: 'integration',
|
|
3622
|
-
stop: environment.stop,
|
|
3623
|
-
})
|
|
3848
|
+
await keepEnvironmentRunningForever()
|
|
3624
3849
|
}
|
|
3625
3850
|
} finally {
|
|
3626
3851
|
if (!options.keep) {
|
|
@@ -3648,10 +3873,7 @@ export async function runEphemeralAppForQa(rawArgs: string[]): Promise<void> {
|
|
|
3648
3873
|
console.log('[ephemeral] Reused existing environment. Press Ctrl+C to exit without stopping the shared runtime.')
|
|
3649
3874
|
}
|
|
3650
3875
|
|
|
3651
|
-
await keepEnvironmentRunningForever(
|
|
3652
|
-
logPrefix: 'ephemeral',
|
|
3653
|
-
stop: environment.stop,
|
|
3654
|
-
})
|
|
3876
|
+
await keepEnvironmentRunningForever()
|
|
3655
3877
|
}
|
|
3656
3878
|
|
|
3657
3879
|
export async function runInteractiveIntegrationInEphemeralEnvironment(rawArgs: string[]): Promise<void> {
|
package/src/mercato.ts
CHANGED
|
@@ -78,6 +78,29 @@ function getRegisteredCliWorkers(modules: Module[] = getCliModules()): ModuleWor
|
|
|
78
78
|
return allWorkers
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Picks the abandoned-job callback for a queue from its workers.
|
|
83
|
+
*
|
|
84
|
+
* The numeric queue options merge across a queue's workers with `Math.max`; two callbacks cannot
|
|
85
|
+
* merge, so the first declared one wins. Silence there would make a genuine wiring mistake — two
|
|
86
|
+
* handlers on one queue each expecting to report its abandoned jobs — look like it works while one of
|
|
87
|
+
* them never runs.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveQueueAbandonHook(
|
|
90
|
+
queueName: string,
|
|
91
|
+
queueWorkers: Array<Pick<ModuleWorker, 'id' | 'onJobAbandoned'>>,
|
|
92
|
+
warn: (message: string) => void = console.warn,
|
|
93
|
+
): ModuleWorker['onJobAbandoned'] {
|
|
94
|
+
const declaring = queueWorkers.filter((worker) => worker.onJobAbandoned)
|
|
95
|
+
if (declaring.length > 1) {
|
|
96
|
+
warn(
|
|
97
|
+
`[worker] Queue "${queueName}" has ${declaring.length} workers declaring onJobAbandoned `
|
|
98
|
+
+ `(${declaring.map((worker) => worker.id).join(', ')}); using the first and ignoring the rest.`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return declaring[0]?.onJobAbandoned
|
|
102
|
+
}
|
|
103
|
+
|
|
81
104
|
function shouldEmbedLocalSchedulerInSharedWorker(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
82
105
|
return parseBooleanToken(env.OM_DEV_EMBED_SCHEDULER_IN_SHARED_WORKER) === true
|
|
83
106
|
}
|
|
@@ -1678,6 +1701,7 @@ export async function run(argv = process.argv) {
|
|
|
1678
1701
|
Math.max(...queueWorkers.map((w) => w.concurrency), 1)
|
|
1679
1702
|
const maxStalledCount = Math.max(...queueWorkers.map((w) => w.maxStalledCount ?? 1), 1)
|
|
1680
1703
|
const lockDuration = Math.max(...queueWorkers.map((w) => w.lockDuration ?? 0), 0) || undefined
|
|
1704
|
+
const onJobAbandoned = resolveQueueAbandonHook(queue, queueWorkers)
|
|
1681
1705
|
|
|
1682
1706
|
console.log(`[worker] Starting "${queue}" with ${queueWorkers.length} handler(s), concurrency: ${concurrency}`)
|
|
1683
1707
|
|
|
@@ -1688,6 +1712,7 @@ export async function run(argv = process.argv) {
|
|
|
1688
1712
|
concurrency,
|
|
1689
1713
|
lockDuration,
|
|
1690
1714
|
maxStalledCount,
|
|
1715
|
+
onJobAbandoned,
|
|
1691
1716
|
background: true,
|
|
1692
1717
|
handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer),
|
|
1693
1718
|
})
|
|
@@ -1733,6 +1758,7 @@ export async function run(argv = process.argv) {
|
|
|
1733
1758
|
const concurrency = budgetPlan.entries[0]?.effective ?? requested
|
|
1734
1759
|
const maxStalledCount = Math.max(...queueWorkers.map((w) => w.maxStalledCount ?? 1), 1)
|
|
1735
1760
|
const lockDuration = Math.max(...queueWorkers.map((w) => w.lockDuration ?? 0), 0) || undefined
|
|
1761
|
+
const onJobAbandoned = resolveQueueAbandonHook(queueName!, queueWorkers)
|
|
1736
1762
|
|
|
1737
1763
|
console.log(`[worker] Found ${queueWorkers.length} worker(s) for queue "${queueName}"`)
|
|
1738
1764
|
|
|
@@ -1743,6 +1769,7 @@ export async function run(argv = process.argv) {
|
|
|
1743
1769
|
concurrency,
|
|
1744
1770
|
lockDuration,
|
|
1745
1771
|
maxStalledCount,
|
|
1772
|
+
onJobAbandoned,
|
|
1746
1773
|
handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer),
|
|
1747
1774
|
})
|
|
1748
1775
|
} else {
|