akm-cli 0.9.0 → 0.9.1-beta.1
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/CHANGELOG.md +707 -0
- package/README.md +28 -63
- package/STABILITY.md +4 -2
- package/dist/commands/agent/contribute-cli.js +1 -1
- package/dist/commands/feedback-cli.js +7 -1
- package/dist/commands/health/llm-usage.js +2 -1
- package/dist/commands/health/surfaces.js +4 -77
- package/dist/commands/health.js +65 -11
- package/dist/commands/improve/distill/quality-gate.js +6 -1
- package/dist/commands/improve/eligibility.js +7 -1
- package/dist/commands/improve/improve.js +126 -10
- package/dist/commands/improve/locks.js +7 -0
- package/dist/commands/improve/memory/memory-improve.js +8 -0
- package/dist/commands/improve/run-context.js +5 -0
- package/dist/commands/improve/session-asset.js +4 -0
- package/dist/commands/lint/base-linter.js +31 -7
- package/dist/commands/lint/index.js +200 -50
- package/dist/commands/lint/types.js +22 -1
- package/dist/commands/proposal/repository.js +17 -1
- package/dist/commands/sources/info.js +12 -2
- package/dist/commands/sources/installed-stashes.js +6 -1
- package/dist/commands/tasks/tasks.js +8 -2
- package/dist/commands/workflow-cli.js +17 -11
- package/dist/core/abort-deadline.js +28 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +67 -5
- package/dist/core/adapter/adapters/akm-adapter.js +13 -10
- package/dist/core/adapter/adapters/akm-lint.js +72 -20
- package/dist/core/adapter/adapters/akm-task-adapter.js +40 -19
- package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
- package/dist/core/asset/frontmatter.js +4 -0
- package/dist/core/common.js +66 -6
- package/dist/core/concurrent.js +32 -0
- package/dist/core/config/schema/workflow.js +11 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/events.js +30 -24
- package/dist/core/file-lock.js +7 -1
- package/dist/core/improve-result.js +5 -0
- package/dist/core/json-schema.js +327 -9
- package/dist/core/loopback.js +89 -0
- package/dist/core/migration-operation.js +17 -2
- package/dist/core/path-access.js +107 -0
- package/dist/core/redaction.js +64 -17
- package/dist/core/spawn-env.js +234 -0
- package/dist/core/state-db-scope.js +134 -0
- package/dist/core/subprocess.js +181 -37
- package/dist/core/write-provenance.js +85 -0
- package/dist/core/write-source.js +7 -0
- package/dist/indexer/db/graph-db.js +17 -6
- package/dist/indexer/ensure-index.js +10 -3
- package/dist/indexer/index-written-assets.js +17 -2
- package/dist/indexer/indexer.js +55 -15
- package/dist/indexer/passes/memory-inference.js +4 -0
- package/dist/indexer/search/db-search.js +8 -15
- package/dist/integrations/agent/engine-resolution.js +24 -11
- package/dist/integrations/agent/model-aliases.js +1 -1
- package/dist/integrations/agent/profiles.js +9 -1
- package/dist/integrations/agent/spawn.js +15 -87
- package/dist/integrations/lockfile.js +45 -2
- package/dist/output/text/lint-format.js +17 -4
- package/dist/scripts/akm-migrate-node.js +1435 -753
- package/dist/scripts/akm-migrate.js +1403 -721
- package/dist/sources/providers/git-stash.js +19 -0
- package/dist/sources/providers/git.js +1 -1
- package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
- package/dist/storage/database.js +6 -1
- package/dist/storage/managed-db.js +19 -0
- package/dist/storage/repositories/index-connection.js +28 -3
- package/dist/storage/repositories/index-entries-repository.js +6 -1
- package/dist/storage/repositories/workflow-runs-repository.js +66 -13
- package/dist/tasks/log-redaction.js +156 -0
- package/dist/tasks/parser.js +82 -5
- package/dist/tasks/runner.js +139 -11
- package/dist/tasks/schema.js +65 -0
- package/dist/workflows/concurrency-policy.js +95 -1
- package/dist/workflows/exec/dispatch-redaction.js +114 -0
- package/dist/workflows/exec/exec-unit.js +542 -0
- package/dist/workflows/exec/frozen-judge.js +114 -42
- package/dist/workflows/exec/native-executor.js +457 -238
- package/dist/workflows/exec/param-secrets.js +4 -3
- package/dist/workflows/exec/run-workflow.js +424 -219
- package/dist/workflows/exec/step-work.js +496 -165
- package/dist/workflows/exec/unit-dispatch.js +31 -1
- package/dist/workflows/exec/unit-writer.js +53 -13
- package/dist/workflows/exec/worktree.js +454 -41
- package/dist/workflows/ir/compile.js +26 -2
- package/dist/workflows/ir/freeze.js +82 -15
- package/dist/workflows/ir/schema.js +105 -20
- package/dist/workflows/parser.js +216 -18
- package/dist/workflows/program/schema.js +24 -0
- package/dist/workflows/renderer.js +32 -4
- package/dist/workflows/resource-limits.js +182 -0
- package/dist/workflows/runtime/runs.js +146 -6
- package/dist/workflows/validate-summary.js +17 -2
- package/docs/README.md +74 -32
- package/docs/migration/release-notes/0.9.0.md +2 -1
- package/docs/migration/v0.7-to-v0.8.md +2 -1
- package/docs/migration/v0.8-to-v0.9.md +3 -1
- package/docs/reference/README.md +11 -4
- package/docs/reference/bundle-types.md +19 -0
- package/docs/reference/cli.md +105 -16
- package/docs/reference/configuration.md +15 -2
- package/docs/reference/data-and-telemetry.md +30 -10
- package/docs/reference/supported-formats.md +50 -0
- package/docs/reference/workflow-schema.md +1014 -0
- package/docs/reference/workflows.md +37 -633
- package/package.json +13 -6
- package/schemas/akm-config.json +8 -0
- package/schemas/akm-task.json +27 -5
- package/schemas/akm-workflow.json +85 -10
package/dist/tasks/parser.js
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
* workflow: workflows/daily-backup
|
|
13
13
|
* params:
|
|
14
14
|
* region: us-east-1
|
|
15
|
+
* timeoutMs: 3600000 # whole-run bound; omit for the unattended
|
|
16
|
+
* # default, `null` to opt out entirely
|
|
17
|
+
* maxSteps: 20 # optional run bounds, same as the
|
|
18
|
+
* maxRetries: 1 # `akm workflow run` flags
|
|
15
19
|
* # ...or:
|
|
16
20
|
* prompt: agents/my-agent # asset ref
|
|
17
21
|
* # ...or:
|
|
@@ -27,6 +31,8 @@
|
|
|
27
31
|
* description: …
|
|
28
32
|
* when_to_use: …
|
|
29
33
|
* tags: [scheduled, backup]
|
|
34
|
+
* redact: [ACME_DEPLOY_TOKEN] # optional: env var NAMES (never values) to
|
|
35
|
+
* # scrub from this task's persisted log
|
|
30
36
|
* ```
|
|
31
37
|
*
|
|
32
38
|
* Validation lives in {@link validateTaskDocument}. The parser enforces the
|
|
@@ -38,7 +44,8 @@ import { parse as parseYaml } from "yaml";
|
|
|
38
44
|
import { isFullRefInput } from "../core/asset/resolve-ref.js";
|
|
39
45
|
import { UsageError } from "../core/errors.js";
|
|
40
46
|
import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
|
|
41
|
-
import {
|
|
47
|
+
import { WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_RETRIES } from "../workflows/resource-limits.js";
|
|
48
|
+
import { TASK_MAX_REDACT_NAMES, TASK_MAX_TIMEOUT_MS, TASK_SCHEMA_VERSION, } from "./schema.js";
|
|
42
49
|
import { validateTaskId } from "./task-id.js";
|
|
43
50
|
export function parseTaskDocument(input) {
|
|
44
51
|
const { yaml, filePath } = input;
|
|
@@ -79,15 +86,25 @@ export function parseTaskDocument(input) {
|
|
|
79
86
|
}
|
|
80
87
|
let target;
|
|
81
88
|
if (hasWorkflow) {
|
|
82
|
-
rejectTargetFields(data, ["params"], id, filePath);
|
|
89
|
+
rejectTargetFields(data, ["params", "timeoutMs", "maxSteps", "maxRetries"], id, filePath);
|
|
83
90
|
const ref = requireString(data.workflow, "workflow", filePath);
|
|
84
91
|
if (!ref) {
|
|
85
92
|
throw new UsageError(`Task "${id}" has empty \`workflow\`. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
86
93
|
}
|
|
94
|
+
// The three run bounds `akm workflow run` takes as flags, declared in the
|
|
95
|
+
// task file instead: an unattended run gets the same abort path the
|
|
96
|
+
// interactive CLI has. `timeoutMs` left unset falls back to the runner's
|
|
97
|
+
// default (see DEFAULT_WORKFLOW_TASK_TIMEOUT_MS); `null` opts out.
|
|
98
|
+
const workflowTimeoutMs = readTimeout(data.timeoutMs, filePath);
|
|
99
|
+
const maxSteps = readBoundedInteger(data.maxSteps, "maxSteps", 1, undefined, filePath);
|
|
100
|
+
const maxRetries = readBoundedInteger(data.maxRetries, "maxRetries", 0, WORKFLOW_MAX_RETRIES, filePath);
|
|
87
101
|
target = {
|
|
88
102
|
kind: "workflow",
|
|
89
103
|
ref,
|
|
90
104
|
params: readParams(data.params, filePath),
|
|
105
|
+
...(workflowTimeoutMs !== undefined ? { timeoutMs: workflowTimeoutMs } : {}),
|
|
106
|
+
...(maxSteps !== undefined ? { maxSteps } : {}),
|
|
107
|
+
...(maxRetries !== undefined ? { maxRetries } : {}),
|
|
91
108
|
};
|
|
92
109
|
}
|
|
93
110
|
else if (hasCommand) {
|
|
@@ -114,6 +131,7 @@ export function parseTaskDocument(input) {
|
|
|
114
131
|
};
|
|
115
132
|
}
|
|
116
133
|
const timeoutMs = hasCommand ? readTimeout(data.timeoutMs, filePath) : undefined;
|
|
134
|
+
const redact = readRedactNames(data.redact, filePath);
|
|
117
135
|
return {
|
|
118
136
|
version: TASK_SCHEMA_VERSION,
|
|
119
137
|
schemaVersion: TASK_SCHEMA_VERSION,
|
|
@@ -127,6 +145,7 @@ export function parseTaskDocument(input) {
|
|
|
127
145
|
...(tags ? { tags } : {}),
|
|
128
146
|
source: { path: filePath },
|
|
129
147
|
timeoutMs,
|
|
148
|
+
...(redact ? { redact } : {}),
|
|
130
149
|
};
|
|
131
150
|
}
|
|
132
151
|
const TASK_KEYS = new Set([
|
|
@@ -144,9 +163,22 @@ const TASK_KEYS = new Set([
|
|
|
144
163
|
"engine",
|
|
145
164
|
"model",
|
|
146
165
|
"timeoutMs",
|
|
166
|
+
"maxSteps",
|
|
167
|
+
"maxRetries",
|
|
147
168
|
"llm",
|
|
169
|
+
"redact",
|
|
170
|
+
]);
|
|
171
|
+
const SHARED_KEYS = new Set([
|
|
172
|
+
"version",
|
|
173
|
+
"name",
|
|
174
|
+
"description",
|
|
175
|
+
"when_to_use",
|
|
176
|
+
"tags",
|
|
177
|
+
"schedule",
|
|
178
|
+
"enabled",
|
|
179
|
+
// `redact:` is target-agnostic: every kind funnels through the same log sink.
|
|
180
|
+
"redact",
|
|
148
181
|
]);
|
|
149
|
-
const SHARED_KEYS = new Set(["version", "name", "description", "when_to_use", "tags", "schedule", "enabled"]);
|
|
150
182
|
function requireVersion(data, id, filePath) {
|
|
151
183
|
if (data.version === TASK_SCHEMA_VERSION)
|
|
152
184
|
return;
|
|
@@ -258,9 +290,54 @@ function readTimeout(value, filePath) {
|
|
|
258
290
|
return undefined;
|
|
259
291
|
if (value === null)
|
|
260
292
|
return null;
|
|
261
|
-
|
|
293
|
+
// The ceiling is `setTimeout`'s, not a policy: a larger delay overflows and
|
|
294
|
+
// fires immediately, turning a generous timeout into an instant abort.
|
|
295
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= TASK_MAX_TIMEOUT_MS)
|
|
262
296
|
return value;
|
|
263
|
-
throw new UsageError(`Key "timeoutMs" must be
|
|
297
|
+
throw new UsageError(`Key "timeoutMs" must be an integer from 1 through ${TASK_MAX_TIMEOUT_MS}, or null. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Read the `redact:` opt-in list — environment variable NAMES whose values are
|
|
301
|
+
* scrubbed from this task's persisted log (#755).
|
|
302
|
+
*
|
|
303
|
+
* A value that looks like a secret rather than a name is rejected outright, not
|
|
304
|
+
* silently accepted: a literal in a task file would be indexed, searchable and
|
|
305
|
+
* printed verbatim by `akm show`, so accepting one would leak the secret
|
|
306
|
+
* through a wider channel than the redaction closes.
|
|
307
|
+
*/
|
|
308
|
+
function readRedactNames(value, filePath) {
|
|
309
|
+
if (value === undefined || value === null)
|
|
310
|
+
return undefined;
|
|
311
|
+
const invalid = (detail) => {
|
|
312
|
+
throw new UsageError(`Key "redact" ${detail}. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
313
|
+
};
|
|
314
|
+
if (!Array.isArray(value))
|
|
315
|
+
return invalid("must be a list of environment variable names");
|
|
316
|
+
if (value.length > TASK_MAX_REDACT_NAMES) {
|
|
317
|
+
return invalid(`accepts at most ${TASK_MAX_REDACT_NAMES} names (got ${value.length})`);
|
|
318
|
+
}
|
|
319
|
+
const names = [];
|
|
320
|
+
for (const entry of value) {
|
|
321
|
+
if (typeof entry !== "string" || !WORKFLOW_ENV_VAR_NAME_PATTERN.test(entry)) {
|
|
322
|
+
return invalid(`takes environment variable NAMES only (matching ${WORKFLOW_ENV_VAR_NAME_PATTERN.source}), not values — ` +
|
|
323
|
+
`got ${JSON.stringify(entry)}. A secret written here would be indexed and printed by \`akm show\``);
|
|
324
|
+
}
|
|
325
|
+
if (!names.includes(entry))
|
|
326
|
+
names.push(entry);
|
|
327
|
+
}
|
|
328
|
+
return names.length > 0 ? names : undefined;
|
|
329
|
+
}
|
|
330
|
+
function readBoundedInteger(value, key, minimum, maximum, filePath) {
|
|
331
|
+
if (value === undefined || value === null)
|
|
332
|
+
return undefined;
|
|
333
|
+
if (typeof value === "number" &&
|
|
334
|
+
Number.isSafeInteger(value) &&
|
|
335
|
+
value >= minimum &&
|
|
336
|
+
(maximum === undefined || value <= maximum)) {
|
|
337
|
+
return value;
|
|
338
|
+
}
|
|
339
|
+
const range = maximum === undefined ? `at least ${minimum}` : `from ${minimum} through ${maximum}`;
|
|
340
|
+
throw new UsageError(`Key "${key}" must be an integer ${range}. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
264
341
|
}
|
|
265
342
|
function readLlmOverrides(value, filePath) {
|
|
266
343
|
if (value === undefined)
|
package/dist/tasks/runner.js
CHANGED
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
* 3. Skip disabled tasks only when the invocation is scheduler-generated;
|
|
16
16
|
* explicit manual runs are allowed for catch-up and testing.
|
|
17
17
|
* 4. Dispatch by target kind:
|
|
18
|
-
* • workflow → `runWorkflowSteps({ target: ref, params })`
|
|
18
|
+
* • workflow → `runWorkflowSteps({ target: ref, params, signal, … })`
|
|
19
|
+
* under a whole-run timeout (issue 11): an unattended run
|
|
20
|
+
* gets the same abort path `akm workflow run --timeout`
|
|
21
|
+
* gives an interactive one.
|
|
19
22
|
* • prompt → `executeRunner(engine, prompt, { stdio: "captured" })`
|
|
20
23
|
* 5. Capture stdout / stderr as structured rows in logs.db (task_logs) and,
|
|
21
24
|
* transitionally, as a flat text tail at `<cacheDir>/tasks/logs/<id>/<ts>.log`
|
|
@@ -28,6 +31,7 @@
|
|
|
28
31
|
import fs from "node:fs";
|
|
29
32
|
import os from "node:os";
|
|
30
33
|
import path from "node:path";
|
|
34
|
+
import { armAbortDeadline } from "../core/abort-deadline.js";
|
|
31
35
|
import { shouldSkipUnactivatedTask } from "../core/activation-policy.js";
|
|
32
36
|
import { assertNever } from "../core/assert.js";
|
|
33
37
|
import { placementSpecFor } from "../core/asset/asset-placement.js";
|
|
@@ -36,7 +40,7 @@ import { loadConfig } from "../core/config/config.js";
|
|
|
36
40
|
import { AkmError, NotFoundError, rethrowIfTestIsolationError } from "../core/errors.js";
|
|
37
41
|
import { buildTaskRunId, insertTaskLogLines, openLogsDatabase, } from "../core/logs-db.js";
|
|
38
42
|
import { getTaskLogDir } from "../core/paths.js";
|
|
39
|
-
import { redactCredentialPatterns } from "../core/redaction.js";
|
|
43
|
+
import { redactCredentialPatterns, redactSensitiveText } from "../core/redaction.js";
|
|
40
44
|
import { withStateDb } from "../core/state-db.js";
|
|
41
45
|
import { runManagedSubprocess } from "../core/subprocess.js";
|
|
42
46
|
import { fallbackAnnouncement, NO_ENGINE_MESSAGE_SUFFIX, NO_ENGINE_REMEDY, withEngineFallback, } from "../integrations/agent/engine-fallback.js";
|
|
@@ -48,6 +52,7 @@ import { resolveAssetPath } from "../sources/resolve.js";
|
|
|
48
52
|
import { decodeTaskHistoryMetadata, finalizeTaskHistoryAttempt, getTaskHistory, queryTaskHistory, reserveTaskHistoryAttempt, upsertTaskHistory, } from "../storage/repositories/task-history-repository.js";
|
|
49
53
|
import { runWorkflowSteps } from "../workflows/exec/run-workflow.js";
|
|
50
54
|
import { findBareAkmExecutableIndex } from "./command-executable.js";
|
|
55
|
+
import { collectTaskLogSensitiveValues } from "./log-redaction.js";
|
|
51
56
|
import { parseTaskDocument } from "./parser.js";
|
|
52
57
|
import { resolveAkmInvocation } from "./resolve-akm-bin.js";
|
|
53
58
|
import { validateTaskId } from "./task-id.js";
|
|
@@ -109,6 +114,7 @@ export async function runTask(id, options) {
|
|
|
109
114
|
logPath,
|
|
110
115
|
fileText: `${disabledLine}\n`,
|
|
111
116
|
dbLines: [{ line: disabledLine }],
|
|
117
|
+
redactNames: task.redact,
|
|
112
118
|
});
|
|
113
119
|
appendHistory(result, attempt.historyReserved);
|
|
114
120
|
return result;
|
|
@@ -121,6 +127,8 @@ export async function runTask(id, options) {
|
|
|
121
127
|
now,
|
|
122
128
|
runWorkflowStepsImpl,
|
|
123
129
|
historyReserved: attempt.historyReserved,
|
|
130
|
+
...(options.setTimeoutFn ? { setTimeoutFn: options.setTimeoutFn } : {}),
|
|
131
|
+
...(options.clearTimeoutFn ? { clearTimeoutFn: options.clearTimeoutFn } : {}),
|
|
124
132
|
});
|
|
125
133
|
}
|
|
126
134
|
if (task.target.kind === "command") {
|
|
@@ -225,6 +233,7 @@ async function runCommandTask(input) {
|
|
|
225
233
|
logPath,
|
|
226
234
|
fileText: `${logLines.join("\n")}\n`,
|
|
227
235
|
dbLines,
|
|
236
|
+
redactNames: task.redact,
|
|
228
237
|
});
|
|
229
238
|
const status = exitCode === 0 ? "completed" : "failed";
|
|
230
239
|
const result = {
|
|
@@ -248,14 +257,53 @@ function resolveNestedAkmCommand(cmd) {
|
|
|
248
257
|
return [...cmd.slice(0, akmIndex), ...resolveAkmInvocation().argv, ...cmd.slice(akmIndex + 1)];
|
|
249
258
|
}
|
|
250
259
|
// ── workflow target ─────────────────────────────────────────────────────────
|
|
260
|
+
/**
|
|
261
|
+
* Whole-run timeout applied to a workflow-bound task that does not declare its
|
|
262
|
+
* own `timeoutMs` — six hours.
|
|
263
|
+
*
|
|
264
|
+
* `akm workflow run` deliberately has NO default `--timeout`: a human is
|
|
265
|
+
* watching, and Ctrl-C aborts the very same signal the flag's timer would.
|
|
266
|
+
* A scheduled task has nobody watching. Without a default, its only bound is
|
|
267
|
+
* the per-unit timeout — and a frozen plan may set `timeout: null` (unbounded),
|
|
268
|
+
* so one wedged agent unit hangs the run until the machine reboots, holding the
|
|
269
|
+
* run lease and silently skipping every later firing (issue 11).
|
|
270
|
+
*
|
|
271
|
+
* Six hours is deliberately generous rather than tight: the abort is graceful
|
|
272
|
+
* (the engine breaks at the next step boundary and the run stays resumable), so
|
|
273
|
+
* the cost of over-waiting is bounded while the cost of cutting a legitimate
|
|
274
|
+
* long run short is a lost step. It matches the 6h idle window `akm health`
|
|
275
|
+
* already uses to call a run stale (`commands/health/report-view-model.ts`),
|
|
276
|
+
* and it lands well inside a `@daily` cadence, so a wedged run can never still
|
|
277
|
+
* be holding the lease when the next day's firing arrives.
|
|
278
|
+
*
|
|
279
|
+
* An explicit `timeoutMs:` in the task file always wins; `timeoutMs: null` is
|
|
280
|
+
* the explicit opt-out back to unbounded.
|
|
281
|
+
*/
|
|
282
|
+
export const DEFAULT_WORKFLOW_TASK_TIMEOUT_MS = 6 * 60 * 60 * 1000;
|
|
251
283
|
async function runWorkflowTask(input) {
|
|
252
284
|
const { task, logPath, startedAt, now, runWorkflowStepsImpl, historyReserved } = input;
|
|
253
285
|
if (task.target.kind !== "workflow")
|
|
254
286
|
throw new Error("invariant: workflow target");
|
|
255
|
-
const
|
|
287
|
+
const workflowTarget = task.target;
|
|
288
|
+
const ref = parseRefInput(workflowTarget.ref);
|
|
256
289
|
if (ref.type !== "workflow") {
|
|
257
|
-
throw new NotFoundError(`Task "${task.id}" workflow target must be a workflow ref (got "${
|
|
258
|
-
}
|
|
290
|
+
throw new NotFoundError(`Task "${task.id}" workflow target must be a workflow ref (got "${workflowTarget.ref}").`, "WORKFLOW_NOT_FOUND");
|
|
291
|
+
}
|
|
292
|
+
// Unset → the unattended default; `null` → the explicit no-timeout opt-out.
|
|
293
|
+
const timeoutMs = workflowTarget.timeoutMs === undefined ? DEFAULT_WORKFLOW_TASK_TIMEOUT_MS : workflowTarget.timeoutMs;
|
|
294
|
+
// The shared deadline `akm workflow run --timeout` also arms
|
|
295
|
+
// ({@link armAbortDeadline}): one AbortController for the run's lifetime,
|
|
296
|
+
// aborted by a timer. The engine reads `options.signal` at every step
|
|
297
|
+
// boundary and breaks GRACEFULLY — in-flight units are cancelled, the journal
|
|
298
|
+
// and the run lease are retained, and the run is left `active`, i.e.
|
|
299
|
+
// resumable with `akm workflow resume`.
|
|
300
|
+
const controller = new AbortController();
|
|
301
|
+
const deadline = armAbortDeadline(controller, {
|
|
302
|
+
timeoutMs,
|
|
303
|
+
reason: `Workflow task "${task.id}" timed out after ${timeoutMs}ms.`,
|
|
304
|
+
...(input.setTimeoutFn ? { setTimeoutFn: input.setTimeoutFn } : {}),
|
|
305
|
+
...(input.clearTimeoutFn ? { clearTimeoutFn: input.clearTimeoutFn } : {}),
|
|
306
|
+
});
|
|
259
307
|
let detail;
|
|
260
308
|
let gateError;
|
|
261
309
|
let error;
|
|
@@ -263,7 +311,13 @@ async function runWorkflowTask(input) {
|
|
|
263
311
|
// task must leave the same trace rather than silently using a chosen engine.
|
|
264
312
|
let runWarnings = [];
|
|
265
313
|
try {
|
|
266
|
-
const execution = await runWorkflowStepsImpl({
|
|
314
|
+
const execution = await runWorkflowStepsImpl({
|
|
315
|
+
target: workflowTarget.ref,
|
|
316
|
+
params: workflowTarget.params,
|
|
317
|
+
signal: controller.signal,
|
|
318
|
+
...(workflowTarget.maxSteps !== undefined ? { maxSteps: workflowTarget.maxSteps } : {}),
|
|
319
|
+
...(workflowTarget.maxRetries !== undefined ? { maxRetries: workflowTarget.maxRetries } : {}),
|
|
320
|
+
});
|
|
267
321
|
detail = execution.run;
|
|
268
322
|
runWarnings = execution.warnings ?? [];
|
|
269
323
|
if (execution.gateRejection) {
|
|
@@ -275,13 +329,37 @@ async function runWorkflowTask(input) {
|
|
|
275
329
|
throw e;
|
|
276
330
|
error = e instanceof Error ? e : new Error(String(e));
|
|
277
331
|
}
|
|
332
|
+
finally {
|
|
333
|
+
deadline.disarm();
|
|
334
|
+
}
|
|
335
|
+
// A timeout is a failed ATTEMPT even though the engine stopped cleanly: the
|
|
336
|
+
// aborted run comes back `active` (resumable), which on its own would map to
|
|
337
|
+
// task status "active" and a 0 exit code, telling the OS scheduler nothing
|
|
338
|
+
// went wrong. Surface it like the command target's `timed_out=true` instead.
|
|
339
|
+
//
|
|
340
|
+
// Unless the run COMPLETED anyway. The abort is observed between steps, so a
|
|
341
|
+
// deadline landing in the run's final bookkeeping can set the flag on a run
|
|
342
|
+
// that then finishes — and reporting that as a failure would tell an operator
|
|
343
|
+
// to resume a run with nothing left to resume.
|
|
344
|
+
const ranToCompletion = detail?.status === "completed";
|
|
345
|
+
const timedOutAfterMs = deadline.timedOut() && timeoutMs !== null && !ranToCompletion ? timeoutMs : undefined;
|
|
346
|
+
const timeoutError = timedOutAfterMs === undefined
|
|
347
|
+
? undefined
|
|
348
|
+
: new Error(`Workflow run timed out after ${timedOutAfterMs}ms and was aborted at a step boundary` +
|
|
349
|
+
(detail?.id ? ` — resume it with \`akm workflow resume ${detail.id}\`.` : "."));
|
|
350
|
+
// One failure value for the three sinks below (status, log line, history
|
|
351
|
+
// detail): a thrown error outranks a gate rejection, which outranks the
|
|
352
|
+
// deadline. Re-laddering per sink is how a log line ends up naming a
|
|
353
|
+
// different cause than the history row it was written beside.
|
|
354
|
+
const failure = error ?? (gateError ? new Error(gateError) : timeoutError);
|
|
278
355
|
const finishedAt = finishAttempt(startedAt, now());
|
|
279
|
-
const status =
|
|
356
|
+
const status = failure ? "failed" : mapWorkflowStatus(detail?.status);
|
|
280
357
|
const log = renderWorkflowLog({
|
|
281
358
|
task,
|
|
282
359
|
detail,
|
|
283
|
-
error:
|
|
360
|
+
error: failure,
|
|
284
361
|
warnings: runWarnings,
|
|
362
|
+
...(timedOutAfterMs !== undefined ? { timedOutAfterMs } : {}),
|
|
285
363
|
});
|
|
286
364
|
persistRunLog({
|
|
287
365
|
taskId: task.id,
|
|
@@ -290,6 +368,7 @@ async function runWorkflowTask(input) {
|
|
|
290
368
|
logPath,
|
|
291
369
|
fileText: log.fileText,
|
|
292
370
|
dbLines: log.dbLines,
|
|
371
|
+
redactNames: task.redact,
|
|
293
372
|
});
|
|
294
373
|
const result = {
|
|
295
374
|
id: task.id,
|
|
@@ -301,7 +380,7 @@ async function runWorkflowTask(input) {
|
|
|
301
380
|
target: { kind: "workflow", ref: task.target.ref },
|
|
302
381
|
detail: {
|
|
303
382
|
runId: detail?.id,
|
|
304
|
-
...(
|
|
383
|
+
...(failure ? { error: failure.message } : {}),
|
|
305
384
|
},
|
|
306
385
|
};
|
|
307
386
|
appendHistory(result, historyReserved);
|
|
@@ -345,6 +424,9 @@ function renderWorkflowLog(input) {
|
|
|
345
424
|
];
|
|
346
425
|
for (const warning of input.warnings ?? [])
|
|
347
426
|
dbLines.push({ level: "warn", line: warning });
|
|
427
|
+
if (input.timedOutAfterMs !== undefined) {
|
|
428
|
+
dbLines.push({ level: "error", line: `timed_out=true timeout_ms=${input.timedOutAfterMs}` });
|
|
429
|
+
}
|
|
348
430
|
if (input.detail) {
|
|
349
431
|
dbLines.push({ line: `run_id=${input.detail.id} status=${input.detail.status}` });
|
|
350
432
|
dbLines.push({ line: `workflow_title=${input.detail.workflowTitle}` });
|
|
@@ -432,6 +514,7 @@ async function runPromptTask(input) {
|
|
|
432
514
|
logPath,
|
|
433
515
|
fileText: log.fileText,
|
|
434
516
|
dbLines: log.dbLines,
|
|
517
|
+
redactNames: task.redact,
|
|
435
518
|
});
|
|
436
519
|
const status = result.ok ? "completed" : "failed";
|
|
437
520
|
const out = {
|
|
@@ -535,11 +618,56 @@ function streamLines(text, stream, level) {
|
|
|
535
618
|
* The DB write is best-effort, mirroring {@link appendHistory}: an unwritable
|
|
536
619
|
* logs.db must never fail a task run.
|
|
537
620
|
*/
|
|
621
|
+
/**
|
|
622
|
+
* Exact secret values to scrub from this run's persisted output (#755).
|
|
623
|
+
*
|
|
624
|
+
* Best-effort by construction: this runs on the persistence path of a run that
|
|
625
|
+
* has already finished, so a config that will not load must degrade to
|
|
626
|
+
* "pattern-based redaction only" rather than fail the run. It does NOT degrade
|
|
627
|
+
* to "log it anyway with no redaction at all" — `redactCredentialPatterns`
|
|
628
|
+
* still runs unconditionally in the caller.
|
|
629
|
+
*/
|
|
630
|
+
function taskLogSensitiveValues(redactNames) {
|
|
631
|
+
try {
|
|
632
|
+
return collectTaskLogSensitiveValues({
|
|
633
|
+
env: process.env,
|
|
634
|
+
config: loadConfig(),
|
|
635
|
+
declaredNames: redactNames,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
rethrowIfTestIsolationError(error);
|
|
640
|
+
// No config — the name heuristic and the task's own `redact:` list still apply.
|
|
641
|
+
try {
|
|
642
|
+
return collectTaskLogSensitiveValues({ env: process.env, declaredNames: redactNames });
|
|
643
|
+
}
|
|
644
|
+
catch (fallbackError) {
|
|
645
|
+
rethrowIfTestIsolationError(fallbackError);
|
|
646
|
+
return [];
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
538
650
|
function persistRunLog(input) {
|
|
539
|
-
|
|
540
|
-
|
|
651
|
+
// Two arms, and both are needed. `redactCredentialPatterns` catches
|
|
652
|
+
// credential SHAPES nobody listed; the exact-value pass catches configured
|
|
653
|
+
// secrets whose value is shaped like nothing in particular (#755). The
|
|
654
|
+
// command target had only the first, so a scheduled command that echoed an
|
|
655
|
+
// ordinary-looking secret persisted it verbatim to both sinks. Applying the
|
|
656
|
+
// exact pass here — the one sink all three target kinds funnel through —
|
|
657
|
+
// covers every arm once rather than per-arm; prompt/workflow runs already
|
|
658
|
+
// scrub upstream, and redaction is idempotent, so the overlap is free.
|
|
659
|
+
const sensitive = taskLogSensitiveValues(input.redactNames);
|
|
660
|
+
const scrub = (text) => sensitive.length > 0
|
|
661
|
+
? redactSensitiveText(redactCredentialPatterns(text), sensitive)
|
|
662
|
+
: redactCredentialPatterns(text);
|
|
663
|
+
const fileText = scrub(input.fileText);
|
|
664
|
+
const dbLines = input.dbLines.map((entry) => ({ ...entry, line: scrub(entry.line) }));
|
|
541
665
|
if (input.logPath) {
|
|
542
666
|
try {
|
|
667
|
+
// Written at the process umask. #756 pinned 0600/0700 here; that went out
|
|
668
|
+
// with the rest of akm's permission enforcement (#791) — the operator owns
|
|
669
|
+
// the mode of their own data directory, and akm neither sets nor reports
|
|
670
|
+
// on it.
|
|
543
671
|
fs.mkdirSync(path.dirname(input.logPath), { recursive: true });
|
|
544
672
|
fs.writeFileSync(input.logPath, fileText);
|
|
545
673
|
}
|
package/dist/tasks/schema.js
CHANGED
|
@@ -12,7 +12,35 @@
|
|
|
12
12
|
* Tasks are stored as pure YAML files at `<stash>/tasks/<id>.yml`. Multi-line
|
|
13
13
|
* inline prompts use a YAML block scalar (`prompt: |`).
|
|
14
14
|
*/
|
|
15
|
+
import { parse as parseYaml } from "yaml";
|
|
16
|
+
import { WORKFLOW_MAX_EXEC_PASS_ENV, WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/resource-limits.js";
|
|
15
17
|
export const TASK_SCHEMA_VERSION = 2;
|
|
18
|
+
/** The ONE recognized on-disk task extension (spec §6 task row). */
|
|
19
|
+
export const TASK_EXTENSION = ".yml";
|
|
20
|
+
/**
|
|
21
|
+
* The near-miss spelling. `.yaml` is NOT a task extension: the indexer's
|
|
22
|
+
* `tasks` matcher (`indexer/walk/matchers.ts`) gates on `.yml`, so a
|
|
23
|
+
* `tasks/<id>.yaml` file is never indexed, never scheduled, and never runs.
|
|
24
|
+
* It is recognized HERE only so lint can say so out loud instead of walking
|
|
25
|
+
* past it (issue #760).
|
|
26
|
+
*/
|
|
27
|
+
export const TASK_NEAR_MISS_EXTENSION = ".yaml";
|
|
28
|
+
/**
|
|
29
|
+
* Largest expressible `timeoutMs` — `setTimeout`'s 32-bit signed ceiling
|
|
30
|
+
* (2^31-1, ~24.8 days). A larger delay overflows and fires almost immediately,
|
|
31
|
+
* which would silently abort a run seconds after it started instead of hours
|
|
32
|
+
* later. One definition with the workflow bound (`WORKFLOW_MAX_TIMEOUT_MS`) —
|
|
33
|
+
* it is a platform fact, not a per-surface policy. Mirrored as `maximum` on
|
|
34
|
+
* `timeoutMs` in `schemas/akm-task.json`.
|
|
35
|
+
*/
|
|
36
|
+
export const TASK_MAX_TIMEOUT_MS = WORKFLOW_MAX_TIMEOUT_MS;
|
|
37
|
+
/**
|
|
38
|
+
* Most names a task's `redact:` list may carry. Shares its bound with exec
|
|
39
|
+
* units' `pass_env:` — both are "name the one or two the defaults miss", not a
|
|
40
|
+
* way to declare the whole environment secret. Mirrored as `maxItems` on
|
|
41
|
+
* `redact` in `schemas/akm-task.json`.
|
|
42
|
+
*/
|
|
43
|
+
export const TASK_MAX_REDACT_NAMES = WORKFLOW_MAX_EXEC_PASS_ENV;
|
|
16
44
|
/**
|
|
17
45
|
* Lint-level shape problems for a parsed task YAML mapping: the field rules
|
|
18
46
|
* `src/tasks/parser.ts` enforces at load time, phrased as diagnostics. The ONE
|
|
@@ -36,3 +64,40 @@ export function taskFieldProblems(data) {
|
|
|
36
64
|
problems.push("enabled (must be a boolean when present)");
|
|
37
65
|
return problems;
|
|
38
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Parse a task YAML document into a plain mapping. Non-mapping documents (a
|
|
69
|
+
* scalar, a sequence, an empty file) are NOT a parse failure — they parse fine
|
|
70
|
+
* and simply carry no fields, which the field rules above already describe.
|
|
71
|
+
*
|
|
72
|
+
* The ONE parse used by all three task-lint surfaces (`commands/lint/index.ts`'s
|
|
73
|
+
* akm sweep, the `akm` adapter's `validate`, and the `akm-task` adapter) so a
|
|
74
|
+
* malformed file cannot be a finding on one surface and silence on another.
|
|
75
|
+
*/
|
|
76
|
+
export function parseTaskYaml(raw) {
|
|
77
|
+
try {
|
|
78
|
+
const doc = parseYaml(raw);
|
|
79
|
+
if (doc && typeof doc === "object" && !Array.isArray(doc))
|
|
80
|
+
return { ok: true, data: doc };
|
|
81
|
+
return { ok: true, data: {} };
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
// yaml's errors carry a multi-line source excerpt; keep the first line so
|
|
85
|
+
// the diagnostic stays one readable finding.
|
|
86
|
+
const message = (e instanceof Error ? e.message : String(e)).split("\n")[0]?.trim() ?? "unknown parse error";
|
|
87
|
+
return { ok: false, data: {}, error: message };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** The `invalid-task-yaml` detail for a file whose YAML could not be parsed. */
|
|
91
|
+
export function taskYamlParseDetail(error) {
|
|
92
|
+
return `task YAML does not parse: ${error}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The `invalid-task-yaml` detail for a task file using the `.yaml` near-miss
|
|
96
|
+
* spelling. See {@link TASK_NEAR_MISS_EXTENSION} for why this is an error and
|
|
97
|
+
* not a style nit.
|
|
98
|
+
*/
|
|
99
|
+
export function taskExtensionDetail(relPath) {
|
|
100
|
+
const base = relPath.replace(/\.yaml$/i, "");
|
|
101
|
+
return (`task file uses the ${TASK_NEAR_MISS_EXTENSION} extension; akm recognizes tasks only as ` +
|
|
102
|
+
`${TASK_EXTENSION}, so this file is never indexed or scheduled — rename it to ${base}${TASK_EXTENSION}.`);
|
|
103
|
+
}
|
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import os from "node:os";
|
|
5
|
-
|
|
5
|
+
import { isLoopbackEndpoint } from "../core/loopback.js";
|
|
6
|
+
import { WORKFLOW_MAX_CONCURRENCY } from "./resource-limits.js";
|
|
7
|
+
/**
|
|
8
|
+
* Run-level ceiling on `workflow.maxConcurrency`. It is deliberately the SAME
|
|
9
|
+
* value the frozen-plan decoder enforces on `execution.maxConcurrency` and on
|
|
10
|
+
* per-step `map.concurrency` — a clamp above the decoder's bound would freeze
|
|
11
|
+
* plans the decoder then rejects — so it reads the single shared constant
|
|
12
|
+
* (`./resource-limits`) rather than repeating the literal.
|
|
13
|
+
*/
|
|
14
|
+
export const WORKFLOW_MAX_CONCURRENCY_CEILING = WORKFLOW_MAX_CONCURRENCY;
|
|
6
15
|
export function cpuDerivedUnitConcurrency(cpuCount = os.cpus()?.length ?? 4) {
|
|
7
16
|
return Math.min(16, Math.max(1, cpuCount - 2));
|
|
8
17
|
}
|
|
@@ -13,3 +22,88 @@ export function clampMaxConcurrency(value) {
|
|
|
13
22
|
export function workflowMaxConcurrency(configured, cpuCount = os.cpus()?.length ?? 4) {
|
|
14
23
|
return configured === undefined ? cpuDerivedUnitConcurrency(cpuCount) : clampMaxConcurrency(configured);
|
|
15
24
|
}
|
|
25
|
+
// ── Fan-out defaults ─────────────────────────────────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// Four independent limits clamp a `map` step's real width, and the effective
|
|
28
|
+
// value is their minimum:
|
|
29
|
+
//
|
|
30
|
+
// 1. the step's own `map.concurrency` (this file's default below)
|
|
31
|
+
// 2. the run's frozen `execution.maxConcurrency` ({@link workflowMaxConcurrency})
|
|
32
|
+
// 3. the selected LLM engine's frozen concurrency ({@link defaultLlmEngineConcurrency})
|
|
33
|
+
// 4. the CURRENT host's CPU safety cap ({@link cpuDerivedUnitConcurrency})
|
|
34
|
+
//
|
|
35
|
+
// (1) and (3) both defaulted to 1 before 0.9.1, which made every fan-out serial
|
|
36
|
+
// unless the author opted in at BOTH layers — so (2) and (4), the limits that
|
|
37
|
+
// actually encode machine capacity, never bound anything. The defaults below
|
|
38
|
+
// replace those two 1s. They are deliberately modest rather than "as wide as
|
|
39
|
+
// the host allows": a `map` is independent by construction, but its units call
|
|
40
|
+
// out to rate-limited providers and RAM-hungry agent processes, so the value
|
|
41
|
+
// that a plan freezes should be one a laptop and a CI box can both survive.
|
|
42
|
+
/**
|
|
43
|
+
* Default width of a `map` step that declares no `concurrency:` (0.9.1+).
|
|
44
|
+
*
|
|
45
|
+
* 4 is chosen over the host cap on purpose. It is a real, predictable speedup
|
|
46
|
+
* (4× on any fan-out longer than four items) while staying below
|
|
47
|
+
* {@link cpuDerivedUnitConcurrency} on every machine with ≥6 cores, so the
|
|
48
|
+
* frozen number — not the host — is what an author reasons about, and a plan
|
|
49
|
+
* frozen on a 32-core CI box behaves the same when it resumes on a laptop.
|
|
50
|
+
*
|
|
51
|
+
* Overridable in both directions:
|
|
52
|
+
* - per step: `map.concurrency: <n>` (an explicit `1` still means serial),
|
|
53
|
+
* - per install: `workflow.defaultMapConcurrency` — set it to `1` to restore
|
|
54
|
+
* the pre-0.9.1 serial default for every workflow at once.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_MAP_CONCURRENCY = 4;
|
|
57
|
+
/**
|
|
58
|
+
* Default `engines.<name>.concurrency` for an LLM engine on a LOOPBACK
|
|
59
|
+
* endpoint. Stays at 1, matching `getDefaultLlmConcurrency`
|
|
60
|
+
* (`src/indexer/indexer.ts`) and AGENTS.md's "lowest common denominator — a
|
|
61
|
+
* slow local model on a single-threaded server" rule. A local model server
|
|
62
|
+
* (LM Studio, Ollama) holds ONE loaded model; parallel inference triggers
|
|
63
|
+
* reload thrash and HTTP 500s, which is a hard failure, not a slow one.
|
|
64
|
+
*/
|
|
65
|
+
export const DEFAULT_LOCAL_LLM_ENGINE_CONCURRENCY = 1;
|
|
66
|
+
/**
|
|
67
|
+
* Default `engines.<name>.concurrency` for an LLM engine on a REMOTE endpoint.
|
|
68
|
+
*
|
|
69
|
+
* Deliberately equal to {@link DEFAULT_MAP_CONCURRENCY} so this limit does not
|
|
70
|
+
* silently re-serialize a fan-out the author already asked for: the step's own
|
|
71
|
+
* `concurrency:` stays the number that decides. Indexing's remote default is a
|
|
72
|
+
* lower 2 because indexing fans out implicitly over the whole stash; a
|
|
73
|
+
* workflow `map` is an explicit, bounded, author-declared fan-out, and four
|
|
74
|
+
* concurrent completions sit far inside any hosted provider's entry tier.
|
|
75
|
+
* Rate-limited installs set `engines.<name>.concurrency` to pin their own.
|
|
76
|
+
*/
|
|
77
|
+
export const DEFAULT_REMOTE_LLM_ENGINE_CONCURRENCY = 4;
|
|
78
|
+
// ── Loopback classification ──────────────────────────────────────────────────
|
|
79
|
+
//
|
|
80
|
+
// Everything above turns on ONE question: does this endpoint name a model
|
|
81
|
+
// server running on THIS machine? The classifier lives in `core/loopback.ts`
|
|
82
|
+
// (shared with the indexer's LLM pool default); the re-export keeps this
|
|
83
|
+
// module the policy surface workflow callers and the boundary-case table in
|
|
84
|
+
// `tests/workflows/concurrency-defaults.test.ts` import from. The check is
|
|
85
|
+
// purely syntactic — no DNS, no interface list — so freeze produces the same
|
|
86
|
+
// plan on a laptop, on CI, and on a machine with no network.
|
|
87
|
+
export { isLoopbackEndpoint, isLoopbackHost } from "../core/loopback.js";
|
|
88
|
+
/**
|
|
89
|
+
* Concurrency to freeze for an LLM engine. An explicit
|
|
90
|
+
* `engines.<name>.concurrency` always wins (clamped into the decoder's
|
|
91
|
+
* `[1, 64]` range so a fat-fingered config cannot freeze an unloadable plan);
|
|
92
|
+
* otherwise the endpoint decides.
|
|
93
|
+
*/
|
|
94
|
+
export function defaultLlmEngineConcurrency(endpoint, configured) {
|
|
95
|
+
if (typeof configured === "number" && Number.isFinite(configured))
|
|
96
|
+
return clampMaxConcurrency(configured);
|
|
97
|
+
return isLoopbackEndpoint(endpoint) ? DEFAULT_LOCAL_LLM_ENGINE_CONCURRENCY : DEFAULT_REMOTE_LLM_ENGINE_CONCURRENCY;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Width to freeze for a `map` step that declared no `concurrency:`. `configured`
|
|
101
|
+
* is `workflow.defaultMapConcurrency`; unset means {@link DEFAULT_MAP_CONCURRENCY}.
|
|
102
|
+
* An explicit `map.concurrency` never reaches this function — the caller keeps
|
|
103
|
+
* "author wrote 1" distinguishable from "author wrote nothing".
|
|
104
|
+
*/
|
|
105
|
+
export function defaultMapConcurrency(configured) {
|
|
106
|
+
return configured === undefined || !Number.isFinite(configured)
|
|
107
|
+
? DEFAULT_MAP_CONCURRENCY
|
|
108
|
+
: clampMaxConcurrency(configured);
|
|
109
|
+
}
|