hereya-cli 0.103.0 → 0.105.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Per-(target, workspace) job serialization for the remote executor.
3
+ *
4
+ * Production incident: a user resubmitted a deploy that looked stuck, and the
5
+ * executor ran BOTH identical deploys of the same project+workspace
6
+ * concurrently. They raced each other over the same cloned repo dirs, local
7
+ * hereya state and tofu/CDK state, and both wedged. Jobs that mutate the same
8
+ * logical target in the same workspace must run serially on one executor;
9
+ * unrelated jobs (e.g. the normal boot burst of provisions for different
10
+ * packages) must keep running concurrently.
11
+ */
12
+ /**
13
+ * Escape '%' (the escape character) and ':' (the key segment separator) so
14
+ * two different (target, workspace) pairs can never collide on the same key
15
+ * (e.g. project `a:b` in workspace `c` vs project `a` in workspace `b:c`).
16
+ */
17
+ function escapeSegment(value) {
18
+ return value.replaceAll('%', '%25').replaceAll(':', '%3A');
19
+ }
20
+ function buildKey(kind, target, workspace) {
21
+ return `${kind}:${escapeSegment(target)}:${escapeSegment(workspace)}`;
22
+ }
23
+ function nonEmptyString(value) {
24
+ if (typeof value !== 'string')
25
+ return undefined;
26
+ const trimmed = value.trim();
27
+ return trimmed === '' ? undefined : trimmed;
28
+ }
29
+ /**
30
+ * Derives a stable key identifying "same logical target in the same
31
+ * workspace", or null when the job must NOT be serialized (read-only jobs,
32
+ * unknown job types, malformed payloads — those run immediately, no lock).
33
+ * Never throws.
34
+ *
35
+ * Key derivation per job type (the kind prefix prevents cross-kind
36
+ * collisions, e.g. a project named like an app):
37
+ * - deploy / undeploy: `project:<payload.project>:<payload.workspace>`
38
+ * - init: `project:<payload.projectName>:<payload.workspace>`
39
+ * - app-deploy / app-destroy: `app:<payload.appName>:<payload.workspace>`
40
+ * - provision / destroy: `pkg:<payload.package>:<payload.workspace>`
41
+ * - resolve-env: null (read-only, never serialized)
42
+ */
43
+ export function deriveJobSerializationKey(job) {
44
+ try {
45
+ const { payload } = job;
46
+ if (!payload || typeof payload !== 'object')
47
+ return null;
48
+ const workspace = nonEmptyString(payload.workspace);
49
+ if (!workspace)
50
+ return null;
51
+ switch (job.type) {
52
+ case 'app-deploy':
53
+ case 'app-destroy': {
54
+ const appName = nonEmptyString(payload.appName);
55
+ return appName ? buildKey('app', appName, workspace) : null;
56
+ }
57
+ case 'deploy':
58
+ case 'undeploy': {
59
+ // The deploy payload carries the project NAME (the repo URL lives in
60
+ // the project metadata fetched later), so the name is the identity.
61
+ const project = nonEmptyString(payload.project);
62
+ return project ? buildKey('project', project, workspace) : null;
63
+ }
64
+ case 'destroy':
65
+ case 'provision': {
66
+ const pkg = nonEmptyString(payload.package);
67
+ return pkg ? buildKey('pkg', pkg, workspace) : null;
68
+ }
69
+ case 'init': {
70
+ // Shares the `project:` key space with deploy/undeploy on purpose:
71
+ // an init and a deploy of the same project must not interleave.
72
+ const projectName = nonEmptyString(payload.projectName);
73
+ return projectName ? buildKey('project', projectName, workspace) : null;
74
+ }
75
+ default: {
76
+ // resolve-env (read-only) and any unknown/future job type.
77
+ return null;
78
+ }
79
+ }
80
+ }
81
+ catch {
82
+ // Serialization is best-effort protection; a weird payload must never
83
+ // prevent the job from running at all.
84
+ return null;
85
+ }
86
+ }
87
+ /**
88
+ * Minimal keyed mutex: chains async functions per key so runs with the same
89
+ * key execute strictly serially (FIFO), while runs with different keys — or a
90
+ * null key — start immediately and proceed concurrently.
91
+ *
92
+ * - A rejecting fn never blocks its successors: each fn is chained via
93
+ * `.then(fn, fn)` onto a rejection-swallowed tail.
94
+ * - The map entry for a key is removed once its chain drains, so the map
95
+ * cannot grow unboundedly over executor uptime.
96
+ */
97
+ export class KeyedMutex {
98
+ chains = new Map();
99
+ /** Number of keys with in-flight or queued runs (introspection/test aid). */
100
+ get size() {
101
+ return this.chains.size;
102
+ }
103
+ /**
104
+ * True when at least one run for this key is in flight or queued — i.e. a
105
+ * new run() with this key would have to wait. A null key is never busy.
106
+ */
107
+ isBusy(key) {
108
+ return key !== null && this.chains.has(key);
109
+ }
110
+ async run(key, fn) {
111
+ if (key === null) {
112
+ return fn();
113
+ }
114
+ const existing = this.chains.get(key);
115
+ const entry = existing ?? { pending: 0, tail: Promise.resolve() };
116
+ if (!existing) {
117
+ this.chains.set(key, entry);
118
+ }
119
+ entry.pending += 1;
120
+ const chained = entry.tail.then(fn, fn);
121
+ // The stored tail must never reject (a failed predecessor must not block
122
+ // or poison successors); once this run settles, drop the map entry if no
123
+ // other run is in flight or queued for the key.
124
+ entry.tail = chained
125
+ .then(() => { }, () => { })
126
+ .finally(() => {
127
+ entry.pending -= 1;
128
+ if (entry.pending === 0 && this.chains.get(key) === entry) {
129
+ this.chains.delete(key);
130
+ }
131
+ });
132
+ return chained;
133
+ }
134
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Per-job execution timeout in minutes. Overridable via
3
+ * HEREYA_EXECUTOR_JOB_TIMEOUT_MIN (positive number, in minutes).
4
+ * A wedged child process (e.g. a tool blocking on a prompt) must not hold
5
+ * its executor slot forever while the heartbeat keeps the job "alive".
6
+ */
7
+ export declare function executorJobTimeoutMin(): number;
@@ -0,0 +1,17 @@
1
+ const DEFAULT_EXECUTOR_JOB_TIMEOUT_MIN = 60;
2
+ /**
3
+ * Per-job execution timeout in minutes. Overridable via
4
+ * HEREYA_EXECUTOR_JOB_TIMEOUT_MIN (positive number, in minutes).
5
+ * A wedged child process (e.g. a tool blocking on a prompt) must not hold
6
+ * its executor slot forever while the heartbeat keeps the job "alive".
7
+ */
8
+ export function executorJobTimeoutMin() {
9
+ const fromEnv = process.env.HEREYA_EXECUTOR_JOB_TIMEOUT_MIN;
10
+ if (fromEnv !== undefined) {
11
+ const parsed = Number(fromEnv);
12
+ if (Number.isFinite(parsed) && parsed > 0) {
13
+ return parsed;
14
+ }
15
+ }
16
+ return DEFAULT_EXECUTOR_JOB_TIMEOUT_MIN;
17
+ }
@@ -1,3 +1,4 @@
1
+ import { REQUEST_TIMED_OUT } from '../backend/cloud/cloud-backend/executor-jobs.js';
1
2
  const DEFAULT_TIMEOUT_MIN = 90;
2
3
  const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
3
4
  /**
@@ -11,6 +12,12 @@ const REWAKE_INTERVAL_MS = 60_000;
11
12
  * a chance to land before we ask for another one.
12
13
  */
13
14
  const REWAKE_INITIAL_DELAY_MS = 60_000;
15
+ /**
16
+ * A status fetch aborted by its timeout (see executor-jobs.ts) is transient —
17
+ * retry a bounded number of consecutive times with a short backoff.
18
+ */
19
+ const MAX_CONSECUTIVE_POLL_TIMEOUTS = 3;
20
+ const POLL_TIMEOUT_RETRY_DELAY_MS = 2000;
14
21
  /**
15
22
  * Default poll timeout, in ms. Overridable via HEREYA_JOB_TIMEOUT_MIN env var
16
23
  * (positive integer, in minutes). Falls back to DEFAULT_TIMEOUT_MIN.
@@ -49,6 +56,33 @@ async function maybeRewake(args) {
49
56
  }
50
57
  return now;
51
58
  }
59
+ /**
60
+ * Forwards new job logs to the logger, or emits a periodic "still waiting"
61
+ * heartbeat when the job has been quiet. Returns the updated cursors.
62
+ */
63
+ function forwardJobProgress(args) {
64
+ const { job, jobId, logger, startTime, timeout } = args;
65
+ let { lastLogLength, lastUserHeartbeat } = args;
66
+ const sawNewLog = Boolean(job.logs && job.logs.length > lastLogLength);
67
+ if (sawNewLog) {
68
+ const newLogs = job.logs.slice(lastLogLength);
69
+ if (newLogs.trim()) {
70
+ logger?.info?.(newLogs);
71
+ }
72
+ lastLogLength = job.logs.length;
73
+ lastUserHeartbeat = Date.now();
74
+ }
75
+ else {
76
+ const now = Date.now();
77
+ if (now - lastUserHeartbeat >= HEARTBEAT_INTERVAL_MS) {
78
+ const elapsedMin = Math.round((now - startTime) / 60_000);
79
+ const totalMin = Math.round(timeout / 60_000);
80
+ logger?.info?.(`still waiting on job ${jobId} (${elapsedMin}m elapsed, ${totalMin}m timeout). Set HEREYA_JOB_TIMEOUT_MIN to extend.`);
81
+ lastUserHeartbeat = now;
82
+ }
83
+ }
84
+ return { lastLogLength, lastUserHeartbeat };
85
+ }
52
86
  export async function pollExecutorJob(input) {
53
87
  const { cloudBackend, jobId, logger, workspace } = input;
54
88
  const timeout = input.timeoutMs ?? defaultTimeoutMs();
@@ -58,6 +92,7 @@ export async function pollExecutorJob(input) {
58
92
  let lastStatus = 'pending';
59
93
  let lastUserHeartbeat = startTime;
60
94
  let lastRewakeAt = startTime;
95
+ let consecutiveTimeouts = 0;
61
96
  while (Date.now() - startTime < timeout) {
62
97
  // eslint-disable-next-line no-await-in-loop
63
98
  const statusResult = await cloudBackend.getExecutorJobStatus({
@@ -67,28 +102,29 @@ export async function pollExecutorJob(input) {
67
102
  workspace,
68
103
  });
69
104
  if (!statusResult.success) {
105
+ if (statusResult.reason === REQUEST_TIMED_OUT && consecutiveTimeouts < MAX_CONSECUTIVE_POLL_TIMEOUTS) {
106
+ consecutiveTimeouts += 1;
107
+ logger?.debug?.(`Job status poll timed out (attempt ${consecutiveTimeouts}/${MAX_CONSECUTIVE_POLL_TIMEOUTS}); retrying...`);
108
+ // eslint-disable-next-line no-await-in-loop
109
+ await new Promise((resolve) => {
110
+ setTimeout(resolve, POLL_TIMEOUT_RETRY_DELAY_MS);
111
+ });
112
+ continue;
113
+ }
70
114
  return { reason: `Failed to get job status: ${statusResult.reason}`, success: false };
71
115
  }
116
+ consecutiveTimeouts = 0;
72
117
  const { job } = statusResult;
73
118
  lastStatus = job.status;
74
- const sawNewLog = Boolean(job.logs && job.logs.length > lastLogLength);
75
- if (sawNewLog) {
76
- const newLogs = job.logs.slice(lastLogLength);
77
- if (newLogs.trim()) {
78
- logger?.info?.(newLogs);
79
- }
80
- lastLogLength = job.logs.length;
81
- lastUserHeartbeat = Date.now();
82
- }
83
- else {
84
- const now = Date.now();
85
- if (now - lastUserHeartbeat >= HEARTBEAT_INTERVAL_MS) {
86
- const elapsedMin = Math.round((now - startTime) / 60_000);
87
- const totalMin = Math.round(timeout / 60_000);
88
- logger?.info?.(`still waiting on job ${jobId} (${elapsedMin}m elapsed, ${totalMin}m timeout). Set HEREYA_JOB_TIMEOUT_MIN to extend.`);
89
- lastUserHeartbeat = now;
90
- }
91
- }
119
+ ({ lastLogLength, lastUserHeartbeat } = forwardJobProgress({
120
+ job,
121
+ jobId,
122
+ lastLogLength,
123
+ lastUserHeartbeat,
124
+ logger,
125
+ startTime,
126
+ timeout,
127
+ }));
92
128
  // eslint-disable-next-line no-await-in-loop
93
129
  lastRewakeAt = await maybeRewake({
94
130
  cloudBackend,
package/dist/lib/shell.js CHANGED
@@ -34,8 +34,10 @@ export async function runShell(cmd, args, options = {}) {
34
34
  cwd: options.directory ?? process.cwd(),
35
35
  env: { ...process.env, ...options.env },
36
36
  shell: true,
37
- // If not explicitly set, use 'inherit' when debugging, or 'pipe' otherwise
38
- stdio: options.stdio ?? 'pipe',
37
+ // Default: stdin ignored (closed), stdout/stderr piped. An open stdin
38
+ // pipe nothing writes to lets prompting tools (tofu, git, npm) block
39
+ // forever; with 'ignore' they read EOF and fail fast instead.
40
+ stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
39
41
  };
40
42
  logger.debug(`Executing: ${cmd} ${args.join(' ')}\n`);
41
43
  const child = spawn(cmd, args, spawnOptions);
@@ -2402,6 +2402,61 @@
2402
2402
  "index.js"
2403
2403
  ]
2404
2404
  },
2405
+ "flow:remove": {
2406
+ "aliases": [],
2407
+ "args": {
2408
+ "package": {
2409
+ "description": "Remove a previously added package.",
2410
+ "name": "package",
2411
+ "required": true
2412
+ }
2413
+ },
2414
+ "description": "Remove a package from the project in a git branch-based workspace",
2415
+ "examples": [
2416
+ "<%= config.bin %> <%= command.id %> cloudy/docker_postgres",
2417
+ "<%= config.bin %> <%= command.id %> cloudy/docker_postgres --profile staging"
2418
+ ],
2419
+ "flags": {
2420
+ "chdir": {
2421
+ "description": "\n Directory where the command will be executed.\n If not specified, it defaults to the current working directory.\n Alternatively, you can define the project root by setting the HEREYA_PROJECT_ROOT_DIR environment variable.\n ",
2422
+ "name": "chdir",
2423
+ "required": false,
2424
+ "hasDynamicHelp": false,
2425
+ "multiple": false,
2426
+ "type": "option"
2427
+ },
2428
+ "debug": {
2429
+ "description": "enable debug mode",
2430
+ "name": "debug",
2431
+ "allowNo": false,
2432
+ "type": "boolean"
2433
+ },
2434
+ "profile": {
2435
+ "description": "profile to use for the workspace (will be appended to workspace name)",
2436
+ "name": "profile",
2437
+ "required": false,
2438
+ "hasDynamicHelp": false,
2439
+ "multiple": false,
2440
+ "type": "option"
2441
+ }
2442
+ },
2443
+ "hasDynamicHelp": false,
2444
+ "hiddenAliases": [],
2445
+ "id": "flow:remove",
2446
+ "pluginAlias": "hereya-cli",
2447
+ "pluginName": "hereya-cli",
2448
+ "pluginType": "core",
2449
+ "strict": true,
2450
+ "enableJsonFlag": false,
2451
+ "isESM": true,
2452
+ "relativePath": [
2453
+ "dist",
2454
+ "commands",
2455
+ "flow",
2456
+ "remove",
2457
+ "index.js"
2458
+ ]
2459
+ },
2405
2460
  "flow:run": {
2406
2461
  "aliases": [],
2407
2462
  "args": {
@@ -2992,61 +3047,6 @@
2992
3047
  "index.js"
2993
3048
  ]
2994
3049
  },
2995
- "flow:remove": {
2996
- "aliases": [],
2997
- "args": {
2998
- "package": {
2999
- "description": "Remove a previously added package.",
3000
- "name": "package",
3001
- "required": true
3002
- }
3003
- },
3004
- "description": "Remove a package from the project in a git branch-based workspace",
3005
- "examples": [
3006
- "<%= config.bin %> <%= command.id %> cloudy/docker_postgres",
3007
- "<%= config.bin %> <%= command.id %> cloudy/docker_postgres --profile staging"
3008
- ],
3009
- "flags": {
3010
- "chdir": {
3011
- "description": "\n Directory where the command will be executed.\n If not specified, it defaults to the current working directory.\n Alternatively, you can define the project root by setting the HEREYA_PROJECT_ROOT_DIR environment variable.\n ",
3012
- "name": "chdir",
3013
- "required": false,
3014
- "hasDynamicHelp": false,
3015
- "multiple": false,
3016
- "type": "option"
3017
- },
3018
- "debug": {
3019
- "description": "enable debug mode",
3020
- "name": "debug",
3021
- "allowNo": false,
3022
- "type": "boolean"
3023
- },
3024
- "profile": {
3025
- "description": "profile to use for the workspace (will be appended to workspace name)",
3026
- "name": "profile",
3027
- "required": false,
3028
- "hasDynamicHelp": false,
3029
- "multiple": false,
3030
- "type": "option"
3031
- }
3032
- },
3033
- "hasDynamicHelp": false,
3034
- "hiddenAliases": [],
3035
- "id": "flow:remove",
3036
- "pluginAlias": "hereya-cli",
3037
- "pluginName": "hereya-cli",
3038
- "pluginType": "core",
3039
- "strict": true,
3040
- "enableJsonFlag": false,
3041
- "isESM": true,
3042
- "relativePath": [
3043
- "dist",
3044
- "commands",
3045
- "flow",
3046
- "remove",
3047
- "index.js"
3048
- ]
3049
- },
3050
3050
  "devenv:project:init": {
3051
3051
  "aliases": [],
3052
3052
  "args": {
@@ -3314,6 +3314,51 @@
3314
3314
  "index.js"
3315
3315
  ]
3316
3316
  },
3317
+ "workspace:env:unset": {
3318
+ "aliases": [],
3319
+ "args": {},
3320
+ "description": "unset an env var for a workspace",
3321
+ "examples": [
3322
+ "<%= config.bin %> <%= command.id %> -w my-workspace -n myVar"
3323
+ ],
3324
+ "flags": {
3325
+ "name": {
3326
+ "char": "n",
3327
+ "description": "name of the env var to unset",
3328
+ "name": "name",
3329
+ "required": true,
3330
+ "hasDynamicHelp": false,
3331
+ "multiple": false,
3332
+ "type": "option"
3333
+ },
3334
+ "workspace": {
3335
+ "char": "w",
3336
+ "description": "name of the workspace to unset an env var for",
3337
+ "name": "workspace",
3338
+ "required": true,
3339
+ "hasDynamicHelp": false,
3340
+ "multiple": false,
3341
+ "type": "option"
3342
+ }
3343
+ },
3344
+ "hasDynamicHelp": false,
3345
+ "hiddenAliases": [],
3346
+ "id": "workspace:env:unset",
3347
+ "pluginAlias": "hereya-cli",
3348
+ "pluginName": "hereya-cli",
3349
+ "pluginType": "core",
3350
+ "strict": true,
3351
+ "enableJsonFlag": false,
3352
+ "isESM": true,
3353
+ "relativePath": [
3354
+ "dist",
3355
+ "commands",
3356
+ "workspace",
3357
+ "env",
3358
+ "unset",
3359
+ "index.js"
3360
+ ]
3361
+ },
3317
3362
  "workspace:executor:install": {
3318
3363
  "aliases": [],
3319
3364
  "args": {},
@@ -3470,52 +3515,7 @@
3470
3515
  "uninstall",
3471
3516
  "index.js"
3472
3517
  ]
3473
- },
3474
- "workspace:env:unset": {
3475
- "aliases": [],
3476
- "args": {},
3477
- "description": "unset an env var for a workspace",
3478
- "examples": [
3479
- "<%= config.bin %> <%= command.id %> -w my-workspace -n myVar"
3480
- ],
3481
- "flags": {
3482
- "name": {
3483
- "char": "n",
3484
- "description": "name of the env var to unset",
3485
- "name": "name",
3486
- "required": true,
3487
- "hasDynamicHelp": false,
3488
- "multiple": false,
3489
- "type": "option"
3490
- },
3491
- "workspace": {
3492
- "char": "w",
3493
- "description": "name of the workspace to unset an env var for",
3494
- "name": "workspace",
3495
- "required": true,
3496
- "hasDynamicHelp": false,
3497
- "multiple": false,
3498
- "type": "option"
3499
- }
3500
- },
3501
- "hasDynamicHelp": false,
3502
- "hiddenAliases": [],
3503
- "id": "workspace:env:unset",
3504
- "pluginAlias": "hereya-cli",
3505
- "pluginName": "hereya-cli",
3506
- "pluginType": "core",
3507
- "strict": true,
3508
- "enableJsonFlag": false,
3509
- "isESM": true,
3510
- "relativePath": [
3511
- "dist",
3512
- "commands",
3513
- "workspace",
3514
- "env",
3515
- "unset",
3516
- "index.js"
3517
- ]
3518
3518
  }
3519
3519
  },
3520
- "version": "0.103.0"
3520
+ "version": "0.105.0"
3521
3521
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hereya-cli",
3
3
  "description": "Infrastructure as Package",
4
- "version": "0.103.0",
4
+ "version": "0.105.0",
5
5
  "author": "Hereya Developers",
6
6
  "bin": {
7
7
  "hereya": "./bin/run.cjs"