@lumpcode/cli-utils 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -101,6 +101,8 @@ function formatJsonFileContent(input) {
101
101
  return trailingNewline ? `${json}\n` : json;
102
102
  }
103
103
 
104
+ const WORKSPACE_LOCK_WAIT_TIMEOUT_MS = 15 * 60 * 1000;
105
+ const WORKSPACE_LOCK_WAIT_LOG_INTERVAL_MS = 30000;
104
106
  const WAIT_POLL_MS = 500;
105
107
  function workspaceLocksDirPath(input) {
106
108
  return path__namespace.join(input.globalConfigFolderPath, input.spec.locksSubdirName);
@@ -113,26 +115,53 @@ function workspaceLockFilePath(input) {
113
115
  spec: input.spec,
114
116
  }), `${hash}.lock.json`);
115
117
  }
116
- function formatBusyMessage(input) {
117
- const { spec, workspacePath, holder } = input;
118
+ function formatHolderClause(holder, style) {
118
119
  if (holder?.lumpName && holder.pid) {
119
- return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
120
- `(pid ${holder.pid}, lump "${holder.lumpName}"). Wait for it to finish or stop the daemon before running again.`);
120
+ return style === 'busy'
121
+ ? ` (pid ${holder.pid}, lump "${holder.lumpName}")`
122
+ : ` (held by lump "${holder.lumpName}" pid ${holder.pid})`;
121
123
  }
122
124
  if (holder?.pid) {
123
- return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
124
- `(pid ${holder.pid}). Wait for it to finish or stop the daemon before running again.`);
125
+ return style === 'busy' ? ` (pid ${holder.pid})` : ` (held by pid ${holder.pid})`;
126
+ }
127
+ return '';
128
+ }
129
+ function formatBusyMessage(input) {
130
+ const { spec, workspacePath, holder } = input;
131
+ const clause = formatHolderClause(holder, 'busy');
132
+ if (clause) {
133
+ return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run` +
134
+ `${clause}. Wait for it to finish or stop the daemon before running again.`);
125
135
  }
126
136
  return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run. ` +
127
137
  `Wait for it to finish or stop the daemon before running again.`);
128
138
  }
129
139
  function formatWorkspaceFileWaitMessage(input) {
130
140
  const { spec, workspacePath, holder } = input;
131
- if (holder?.lumpName && holder.pid) {
132
- return (`${spec.waitLogNoun} busy at "${workspacePath}" ` +
133
- `(held by lump "${holder.lumpName}" pid ${holder.pid}); waiting…`);
134
- }
135
- return `${spec.waitLogNoun} busy at "${workspacePath}"; waiting…`;
141
+ return (`${spec.waitLogNoun} busy at "${workspacePath}"` +
142
+ `${formatHolderClause(holder, 'held')}; waiting…`);
143
+ }
144
+ function formatWorkspaceFileStillWaitingMessage(input) {
145
+ const { spec, workspacePath, holder, elapsedMs } = input;
146
+ const elapsedSec = Math.max(1, Math.round(elapsedMs / 1000));
147
+ return (`${spec.waitLogNoun} busy at "${workspacePath}"` +
148
+ `${formatHolderClause(holder, 'held')}; still waiting (${elapsedSec}s)…`);
149
+ }
150
+ function formatWaitTimeoutMessage(input) {
151
+ const { spec, workspacePath, holder, waitTimeoutMs } = input;
152
+ return (`Waited ${waitTimeoutMs}ms for ${spec.waitLogNoun} "${workspacePath}"` +
153
+ `${formatHolderClause(holder, 'held')}; giving up.`);
154
+ }
155
+ function toBusyError(input) {
156
+ const { spec, workspacePath, holder, message, reason } = input;
157
+ return {
158
+ code: spec.busyCode,
159
+ message,
160
+ [spec.workspacePathField]: workspacePath,
161
+ ...(holder?.pid !== undefined ? { holderPid: holder.pid } : {}),
162
+ ...(holder?.lumpName !== undefined ? { holderLumpName: holder.lumpName } : {}),
163
+ ...(reason !== undefined ? { reason } : {}),
164
+ };
136
165
  }
137
166
  async function readLockHolder(lockFilePath) {
138
167
  const result = await readJsonFile({ filePath: lockFilePath, ifMissing: 'undefined' });
@@ -175,6 +204,8 @@ async function tryAcquireWorkspaceFileLockOnce(input) {
175
204
  }
176
205
  async function acquireWorkspaceFileLock(input) {
177
206
  const { spec, globalConfigFolderPath, workspacePath, lumpName, mode, projectName, logger } = input;
207
+ const waitTimeoutMs = input.waitTimeoutMs ?? WORKSPACE_LOCK_WAIT_TIMEOUT_MS;
208
+ const waitLogIntervalMs = input.waitLogIntervalMs ?? WORKSPACE_LOCK_WAIT_LOG_INTERVAL_MS;
178
209
  const normalizedWorkspacePath = path__namespace.resolve(workspacePath);
179
210
  const locksDir = workspaceLocksDirPath({ globalConfigFolderPath, spec });
180
211
  await fs__namespace.mkdir(locksDir, { recursive: true });
@@ -191,6 +222,8 @@ async function acquireWorkspaceFileLock(input) {
191
222
  ...(projectName !== undefined ? { projectName } : {}),
192
223
  };
193
224
  let loggedWait = false;
225
+ let lastWaitLogAt = 0;
226
+ const waitStartedAt = Date.now();
194
227
  for (;;) {
195
228
  const attempt = await tryAcquireWorkspaceFileLockOnce({ lockFilePath, payload, spec, logger });
196
229
  if (attempt.status === 'acquired') {
@@ -226,29 +259,51 @@ async function acquireWorkspaceFileLock(input) {
226
259
  continue;
227
260
  }
228
261
  if (mode === 'fail') {
229
- return core.failure({
230
- code: spec.busyCode,
262
+ return core.failure(toBusyError({
263
+ spec,
264
+ workspacePath: normalizedWorkspacePath,
265
+ holder: attempt.holder,
231
266
  message: formatBusyMessage({
232
267
  spec,
233
268
  workspacePath: normalizedWorkspacePath,
234
269
  holder: attempt.holder,
235
270
  }),
236
- [spec.workspacePathField]: normalizedWorkspacePath,
237
- ...(attempt.holder?.pid !== undefined ? { holderPid: attempt.holder.pid } : {}),
238
- ...(attempt.holder?.lumpName !== undefined
239
- ? { holderLumpName: attempt.holder.lumpName }
240
- : {}),
241
- });
271
+ }));
242
272
  }
243
- if (!loggedWait) {
244
- logger?.info(formatWorkspaceFileWaitMessage({
273
+ const now = Date.now();
274
+ const elapsedMs = now - waitStartedAt;
275
+ if (elapsedMs >= waitTimeoutMs) {
276
+ return core.failure(toBusyError({
245
277
  spec,
246
278
  workspacePath: normalizedWorkspacePath,
247
279
  holder: attempt.holder,
280
+ reason: 'waitTimedOut',
281
+ message: formatWaitTimeoutMessage({
282
+ spec,
283
+ workspacePath: normalizedWorkspacePath,
284
+ holder: attempt.holder,
285
+ waitTimeoutMs,
286
+ }),
248
287
  }));
288
+ }
289
+ if (!loggedWait || now - lastWaitLogAt >= waitLogIntervalMs) {
290
+ logger?.info(loggedWait
291
+ ? formatWorkspaceFileStillWaitingMessage({
292
+ spec,
293
+ workspacePath: normalizedWorkspacePath,
294
+ holder: attempt.holder,
295
+ elapsedMs,
296
+ })
297
+ : formatWorkspaceFileWaitMessage({
298
+ spec,
299
+ workspacePath: normalizedWorkspacePath,
300
+ holder: attempt.holder,
301
+ }));
249
302
  loggedWait = true;
303
+ lastWaitLogAt = now;
250
304
  }
251
- await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
305
+ const remainingMs = waitTimeoutMs - elapsedMs;
306
+ await new Promise((resolve) => setTimeout(resolve, Math.min(WAIT_POLL_MS, Math.max(0, remainingMs))));
252
307
  }
253
308
  }
254
309
 
@@ -292,6 +347,8 @@ async function acquireGitCommonDirLock(input) {
292
347
  mode: input.lockMode,
293
348
  projectName: input.projectName,
294
349
  logger: input.logger,
350
+ waitTimeoutMs: input.waitTimeoutMs,
351
+ waitLogIntervalMs: input.waitLogIntervalMs,
295
352
  });
296
353
  }
297
354
  /** Acquire, run `fn`, always release. */
package/dist/index.d.ts CHANGED
@@ -64,7 +64,7 @@ type LumpJsConfigStep<V extends LumpVariables = LumpVariables, SV extends StepVa
64
64
  type LumpJsConfigSoloStep<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = LumpJsConfigStep<V, SV> | LumpJsConfigStep<V, SV>['promptTemplate'] | LumpJsConfigStep<V, SV>['promptFn'];
65
65
  type LumpJsConfig<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = MergeObjs<Omit<{
66
66
  [K in keyof RunLumpInput<V, SV>]?: NonNullable<RunLumpInput<V, SV>[K]> extends Function ? (RunLumpInput<V, SV>[K] | FilePath) : RunLumpInput<V, SV>[K];
67
- }, 'gitCommitMessageFn' | 'projectRoot' | 'branchFn' | 'baseBranch' | 'setupWorkspaceFn' | 'teardownWorkspaceFn' | 'gitAddCommandFn' | 'gitCommitCommandFn' | 'gitPushCommandFn' | 'getContextListFn' | 'refreshRemoteTrackingRefsFn'>, {
67
+ }, 'gitCommitMessageFn' | 'projectRoot' | 'branchFn' | 'baseBranch' | 'setupWorkspaceFn' | 'teardownWorkspaceFn' | 'gitAddCommitFn' | 'gitPushFn' | 'getContextListFn' | 'refreshRemoteTrackingRefsFn'>, {
68
68
  /**
69
69
  * Execution integration branch. Exact string, `BaseBranchFn`, or FilePath to a module.
70
70
  * Omit → concrete effective discovery branch. Pattern strings are rejected at resolve.
package/dist/index.js CHANGED
@@ -78,6 +78,8 @@ function formatJsonFileContent(input) {
78
78
  return trailingNewline ? `${json}\n` : json;
79
79
  }
80
80
 
81
+ const WORKSPACE_LOCK_WAIT_TIMEOUT_MS = 15 * 60 * 1000;
82
+ const WORKSPACE_LOCK_WAIT_LOG_INTERVAL_MS = 30000;
81
83
  const WAIT_POLL_MS = 500;
82
84
  function workspaceLocksDirPath(input) {
83
85
  return path.join(input.globalConfigFolderPath, input.spec.locksSubdirName);
@@ -90,26 +92,53 @@ function workspaceLockFilePath(input) {
90
92
  spec: input.spec,
91
93
  }), `${hash}.lock.json`);
92
94
  }
93
- function formatBusyMessage(input) {
94
- const { spec, workspacePath, holder } = input;
95
+ function formatHolderClause(holder, style) {
95
96
  if (holder?.lumpName && holder.pid) {
96
- return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
97
- `(pid ${holder.pid}, lump "${holder.lumpName}"). Wait for it to finish or stop the daemon before running again.`);
97
+ return style === 'busy'
98
+ ? ` (pid ${holder.pid}, lump "${holder.lumpName}")`
99
+ : ` (held by lump "${holder.lumpName}" pid ${holder.pid})`;
98
100
  }
99
101
  if (holder?.pid) {
100
- return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run ` +
101
- `(pid ${holder.pid}). Wait for it to finish or stop the daemon before running again.`);
102
+ return style === 'busy' ? ` (pid ${holder.pid})` : ` (held by pid ${holder.pid})`;
103
+ }
104
+ return '';
105
+ }
106
+ function formatBusyMessage(input) {
107
+ const { spec, workspacePath, holder } = input;
108
+ const clause = formatHolderClause(holder, 'busy');
109
+ if (clause) {
110
+ return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run` +
111
+ `${clause}. Wait for it to finish or stop the daemon before running again.`);
102
112
  }
103
113
  return (`${spec.workspaceLabel} "${workspacePath}" is in use by another lumpcode run. ` +
104
114
  `Wait for it to finish or stop the daemon before running again.`);
105
115
  }
106
116
  function formatWorkspaceFileWaitMessage(input) {
107
117
  const { spec, workspacePath, holder } = input;
108
- if (holder?.lumpName && holder.pid) {
109
- return (`${spec.waitLogNoun} busy at "${workspacePath}" ` +
110
- `(held by lump "${holder.lumpName}" pid ${holder.pid}); waiting…`);
111
- }
112
- return `${spec.waitLogNoun} busy at "${workspacePath}"; waiting…`;
118
+ return (`${spec.waitLogNoun} busy at "${workspacePath}"` +
119
+ `${formatHolderClause(holder, 'held')}; waiting…`);
120
+ }
121
+ function formatWorkspaceFileStillWaitingMessage(input) {
122
+ const { spec, workspacePath, holder, elapsedMs } = input;
123
+ const elapsedSec = Math.max(1, Math.round(elapsedMs / 1000));
124
+ return (`${spec.waitLogNoun} busy at "${workspacePath}"` +
125
+ `${formatHolderClause(holder, 'held')}; still waiting (${elapsedSec}s)…`);
126
+ }
127
+ function formatWaitTimeoutMessage(input) {
128
+ const { spec, workspacePath, holder, waitTimeoutMs } = input;
129
+ return (`Waited ${waitTimeoutMs}ms for ${spec.waitLogNoun} "${workspacePath}"` +
130
+ `${formatHolderClause(holder, 'held')}; giving up.`);
131
+ }
132
+ function toBusyError(input) {
133
+ const { spec, workspacePath, holder, message, reason } = input;
134
+ return {
135
+ code: spec.busyCode,
136
+ message,
137
+ [spec.workspacePathField]: workspacePath,
138
+ ...(holder?.pid !== undefined ? { holderPid: holder.pid } : {}),
139
+ ...(holder?.lumpName !== undefined ? { holderLumpName: holder.lumpName } : {}),
140
+ ...(reason !== undefined ? { reason } : {}),
141
+ };
113
142
  }
114
143
  async function readLockHolder(lockFilePath) {
115
144
  const result = await readJsonFile({ filePath: lockFilePath, ifMissing: 'undefined' });
@@ -152,6 +181,8 @@ async function tryAcquireWorkspaceFileLockOnce(input) {
152
181
  }
153
182
  async function acquireWorkspaceFileLock(input) {
154
183
  const { spec, globalConfigFolderPath, workspacePath, lumpName, mode, projectName, logger } = input;
184
+ const waitTimeoutMs = input.waitTimeoutMs ?? WORKSPACE_LOCK_WAIT_TIMEOUT_MS;
185
+ const waitLogIntervalMs = input.waitLogIntervalMs ?? WORKSPACE_LOCK_WAIT_LOG_INTERVAL_MS;
155
186
  const normalizedWorkspacePath = path.resolve(workspacePath);
156
187
  const locksDir = workspaceLocksDirPath({ globalConfigFolderPath, spec });
157
188
  await fs.mkdir(locksDir, { recursive: true });
@@ -168,6 +199,8 @@ async function acquireWorkspaceFileLock(input) {
168
199
  ...(projectName !== undefined ? { projectName } : {}),
169
200
  };
170
201
  let loggedWait = false;
202
+ let lastWaitLogAt = 0;
203
+ const waitStartedAt = Date.now();
171
204
  for (;;) {
172
205
  const attempt = await tryAcquireWorkspaceFileLockOnce({ lockFilePath, payload, spec, logger });
173
206
  if (attempt.status === 'acquired') {
@@ -203,29 +236,51 @@ async function acquireWorkspaceFileLock(input) {
203
236
  continue;
204
237
  }
205
238
  if (mode === 'fail') {
206
- return failure({
207
- code: spec.busyCode,
239
+ return failure(toBusyError({
240
+ spec,
241
+ workspacePath: normalizedWorkspacePath,
242
+ holder: attempt.holder,
208
243
  message: formatBusyMessage({
209
244
  spec,
210
245
  workspacePath: normalizedWorkspacePath,
211
246
  holder: attempt.holder,
212
247
  }),
213
- [spec.workspacePathField]: normalizedWorkspacePath,
214
- ...(attempt.holder?.pid !== undefined ? { holderPid: attempt.holder.pid } : {}),
215
- ...(attempt.holder?.lumpName !== undefined
216
- ? { holderLumpName: attempt.holder.lumpName }
217
- : {}),
218
- });
248
+ }));
219
249
  }
220
- if (!loggedWait) {
221
- logger?.info(formatWorkspaceFileWaitMessage({
250
+ const now = Date.now();
251
+ const elapsedMs = now - waitStartedAt;
252
+ if (elapsedMs >= waitTimeoutMs) {
253
+ return failure(toBusyError({
222
254
  spec,
223
255
  workspacePath: normalizedWorkspacePath,
224
256
  holder: attempt.holder,
257
+ reason: 'waitTimedOut',
258
+ message: formatWaitTimeoutMessage({
259
+ spec,
260
+ workspacePath: normalizedWorkspacePath,
261
+ holder: attempt.holder,
262
+ waitTimeoutMs,
263
+ }),
225
264
  }));
265
+ }
266
+ if (!loggedWait || now - lastWaitLogAt >= waitLogIntervalMs) {
267
+ logger?.info(loggedWait
268
+ ? formatWorkspaceFileStillWaitingMessage({
269
+ spec,
270
+ workspacePath: normalizedWorkspacePath,
271
+ holder: attempt.holder,
272
+ elapsedMs,
273
+ })
274
+ : formatWorkspaceFileWaitMessage({
275
+ spec,
276
+ workspacePath: normalizedWorkspacePath,
277
+ holder: attempt.holder,
278
+ }));
226
279
  loggedWait = true;
280
+ lastWaitLogAt = now;
227
281
  }
228
- await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
282
+ const remainingMs = waitTimeoutMs - elapsedMs;
283
+ await new Promise((resolve) => setTimeout(resolve, Math.min(WAIT_POLL_MS, Math.max(0, remainingMs))));
229
284
  }
230
285
  }
231
286
 
@@ -269,6 +324,8 @@ async function acquireGitCommonDirLock(input) {
269
324
  mode: input.lockMode,
270
325
  projectName: input.projectName,
271
326
  logger: input.logger,
327
+ waitTimeoutMs: input.waitTimeoutMs,
328
+ waitLogIntervalMs: input.waitLogIntervalMs,
272
329
  });
273
330
  }
274
331
  /** Acquire, run `fn`, always release. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumpcode/cli-utils",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Types, defineX helpers, and runtime utilities for Lumpcode lump configs and recipes",
5
5
  "keywords": [
6
6
  "lumpcode",
@@ -43,8 +43,8 @@
43
43
  "test": "vitest run"
44
44
  },
45
45
  "dependencies": {
46
- "@lumpcode/cli-types": "^0.1.1",
47
- "@lumpcode/core": "^0.1.1",
46
+ "@lumpcode/cli-types": "^0.2.0",
47
+ "@lumpcode/core": "^0.2.0",
48
48
  "js-yaml": "^5.0.0"
49
49
  },
50
50
  "devDependencies": {