akm-cli 0.9.2-alpha.4 → 0.9.2
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 +493 -0
- package/STABILITY.md +23 -5
- package/dist/assets/hints/cli-hints-full.md +12 -7
- package/dist/assets/tasks/core/extract.yml +3 -5
- package/dist/assets/tasks/core/improve.yml +3 -5
- package/dist/assets/tasks/core/index-refresh.yml +3 -5
- package/dist/assets/tasks/core/sync.yml +3 -5
- package/dist/assets/tasks/core/version-check.yml +3 -5
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +3 -5
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -6
- package/dist/assets/tasks/improve/akm-improve-consolidate.yml +3 -5
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +3 -5
- package/dist/assets/tasks/improve/akm-improve-nightly.yml +3 -5
- package/dist/cli/unknown-flags.js +12 -1
- package/dist/cli.js +8 -1
- package/dist/commands/command/command-execution.js +23 -2
- package/dist/commands/health/improve-metrics.js +38 -0
- package/dist/commands/health/windows.js +8 -4
- package/dist/commands/health.js +8 -4
- package/dist/commands/lint/index.js +1 -1
- package/dist/commands/migrate-cli.js +130 -24
- package/dist/commands/proposal/validators/proposal-validators.js +7 -2
- package/dist/commands/tasks/explain.js +304 -0
- package/dist/commands/tasks/tasks-cli.js +185 -3
- package/dist/commands/tasks/tasks.js +265 -52
- package/dist/commands/workflow/plan.js +159 -0
- package/dist/commands/workflow-cli.js +94 -2
- package/dist/core/activation-policy.js +2 -12
- package/dist/core/adapter/adapters/akm-lint.js +7 -4
- package/dist/core/adapter/adapters/akm-metadata.js +26 -14
- package/dist/core/adapter/adapters/akm-task-adapter.js +13 -10
- package/dist/core/errors.js +45 -0
- package/dist/core/json-schema.js +15 -5
- package/dist/core/state/migrations.js +57 -0
- package/dist/core/state-db.js +16 -14
- package/dist/core/subprocess.js +47 -13
- package/dist/execution/guarded-source.js +44 -0
- package/dist/execution/input-contract.js +250 -0
- package/dist/execution/target-ref.js +63 -0
- package/dist/indexer/usage/usage-events.js +14 -3
- package/dist/integrations/agent/execution-lowering.js +12 -1
- package/dist/output/shapes/passthrough.js +2 -0
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/migrate.js +12 -3
- package/dist/output/text/workflow-format.js +192 -10
- package/dist/output/text/workflow.js +2 -1
- package/dist/runtime.js +1 -0
- package/dist/scripts/akm-migrate-node.js +11838 -10118
- package/dist/scripts/akm-migrate.js +11828 -10117
- package/dist/setup/steps/tasks.js +34 -17
- package/dist/storage/repositories/task-history-repository.js +5 -1
- package/dist/storage/repositories/workflow-runs-repository.js +144 -6
- package/dist/tasks/backends/launchd.js +31 -84
- package/dist/tasks/embedded.js +13 -7
- package/dist/tasks/model/invocation.js +4 -0
- package/dist/tasks/prepare/prepare-script-target.js +9 -0
- package/dist/tasks/prepare/prepare-support.js +154 -0
- package/dist/tasks/prepare/prepare.js +117 -0
- package/dist/tasks/prepare/prepared-execution.js +4 -0
- package/dist/tasks/prepare/script-capture.js +80 -0
- package/dist/tasks/run/attempt-lifecycle.js +165 -0
- package/dist/tasks/run/load-task.js +117 -0
- package/dist/tasks/run/provenance.js +20 -0
- package/dist/tasks/run/run-command-task.js +92 -0
- package/dist/tasks/run/run-native-task.js +222 -0
- package/dist/tasks/run/run-task.js +99 -0
- package/dist/tasks/run/run-workflow-task.js +222 -0
- package/dist/tasks/run/task-history.js +134 -0
- package/dist/tasks/run/task-log.js +179 -0
- package/dist/tasks/run/task-result.js +19 -0
- package/dist/tasks/scheduler-binding.js +66 -2
- package/dist/tasks/scheduler-invocation.js +63 -3
- package/dist/tasks/scheduler-sync.js +77 -14
- package/dist/tasks/source/bounded-document.js +455 -0
- package/dist/tasks/source/parse-task-source.js +59 -0
- package/dist/tasks/source/project-v4.js +62 -0
- package/dist/tasks/source/task-input-diagnostics.js +36 -0
- package/dist/tasks/source/task-source-v4.js +626 -0
- package/dist/tasks/source-v3.js +10 -733
- package/dist/tasks/task-run-reserved-flags.js +79 -0
- package/dist/workflows/authoring/authoring.js +17 -8
- package/dist/workflows/exec/child-invocation.js +34 -0
- package/dist/workflows/exec/child-workflow.js +370 -0
- package/dist/workflows/exec/exec-unit.js +50 -170
- package/dist/workflows/exec/frozen-judge.js +19 -2
- package/dist/workflows/exec/native-executor.js +49 -27
- package/dist/workflows/exec/param-secrets.js +12 -0
- package/dist/workflows/exec/run-workflow.js +48 -59
- package/dist/workflows/exec/step-work.js +222 -80
- package/dist/workflows/exec/unit-dispatch.js +72 -0
- package/dist/workflows/freeze/child-output-references.js +94 -0
- package/dist/workflows/freeze/environment.js +174 -0
- package/dist/workflows/freeze/identity.js +22 -0
- package/dist/workflows/freeze/resolve-steps.js +78 -0
- package/dist/workflows/freeze/source-freeze.js +57 -0
- package/dist/workflows/freeze/step-values.js +68 -0
- package/dist/workflows/freeze/targets/child-workflow.js +206 -0
- package/dist/workflows/freeze/targets/command.js +81 -0
- package/dist/workflows/freeze/targets/script.js +57 -0
- package/dist/workflows/freeze/targets/shell.js +31 -0
- package/dist/workflows/freeze/targets/task.js +179 -0
- package/dist/workflows/freeze/task-bindings.js +180 -0
- package/dist/workflows/ir/compile.js +59 -11
- package/dist/workflows/ir/environment-v4.js +3 -3
- package/dist/workflows/ir/freeze-v4.js +41 -7
- package/dist/workflows/ir/params.js +58 -131
- package/dist/workflows/ir/plan-hash.js +3 -3
- package/dist/workflows/ir/schema-v4.js +246 -17
- package/dist/workflows/parser.js +74 -2
- package/dist/workflows/program/schema.js +5 -2
- package/dist/workflows/resource-limits.js +20 -0
- package/dist/workflows/runtime/plan-classifier.js +24 -7
- package/dist/workflows/runtime/run-outputs.js +103 -0
- package/dist/workflows/runtime/runs.js +114 -9
- package/dist/workflows/runtime/workflow-asset-loader.js +14 -6
- package/dist/workflows/source-files.js +5 -5
- package/dist/workflows/source-ir/compare.js +17 -0
- package/dist/workflows/source-ir/compile.js +7 -3
- package/dist/workflows/source-ir/github-yaml.js +64 -17
- package/dist/workflows/source-ir/schema.js +69 -21
- package/dist/workflows/source-ir/semantics.js +7 -25
- package/dist/workflows/source-ir/triggers.js +79 -0
- package/dist/workflows/source-ir/uses.js +33 -7
- package/docs/migration/README.md +1 -1
- package/docs/migration/release-notes/0.9.2.md +87 -11
- package/docs/migration/release-notes/README.md +3 -2
- package/docs/migration/v0.8-to-v0.9.md +13 -11
- package/docs/migration/v0.9.0-troubleshooting.md +20 -13
- package/docs/migration/v0.9.1-to-v0.9.2.md +598 -49
- package/docs/reference/README.md +1 -1
- package/docs/reference/cli.md +140 -46
- package/docs/reference/configuration.md +6 -5
- package/docs/reference/supported-formats.md +9 -5
- package/docs/reference/tasks.md +338 -75
- package/docs/reference/workflow-schema.md +290 -16
- package/docs/reference/workflows.md +57 -7
- package/package.json +1 -1
- package/schemas/akm-task.json +173 -118
- package/schemas/akm-workflow.json +28 -0
- package/dist/tasks/runner.js +0 -941
- package/dist/tasks/runtime-v3.js +0 -281
- package/dist/workflows/ir/source-freeze-v4.js +0 -506
- package/dist/workflows/source-ir/ordering.js +0 -38
|
@@ -9,15 +9,18 @@ import { makeBundleRef } from "../core/asset/asset-ref.js";
|
|
|
9
9
|
import { UsageError } from "../core/errors.js";
|
|
10
10
|
import { canonicalizeWorkflowName, WORKFLOW_EXTENSIONS } from "../core/recognition-util.js";
|
|
11
11
|
import { captureGuardedDirectoryManifest, captureGuardedExecutionSource, GuardedExecutionSourceCollector, } from "../execution/guarded-source.js";
|
|
12
|
+
import { applyInputDefaults, validateInputs } from "../execution/input-contract.js";
|
|
12
13
|
import { compileWorkflowPlan } from "../workflows/ir/compile.js";
|
|
13
14
|
import { compileResolveFreezeWorkflowV4 } from "../workflows/ir/freeze-v4.js";
|
|
14
15
|
import { canonicalJson, computePlanHash } from "../workflows/ir/plan-hash.js";
|
|
15
16
|
import { WorkflowSourceCollisionError, WorkflowSourceNameError, WorkflowSourceRejectionError, workflowNameForSourcePath, } from "../workflows/source-files.js";
|
|
16
17
|
import { compileWorkflowSource } from "../workflows/source-ir/compile.js";
|
|
17
|
-
import { prepareTaskV3Execution } from "./
|
|
18
|
+
import { prepareTaskV3Execution } from "./prepare/prepare.js";
|
|
18
19
|
import { parseSchedule } from "./schedule.js";
|
|
19
20
|
import { assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, compileWorkflowSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeBindingId, } from "./scheduler-binding.js";
|
|
20
|
-
import {
|
|
21
|
+
import { parseTaskSource } from "./source/parse-task-source.js";
|
|
22
|
+
import { projectTaskSourceV4 } from "./source/project-v4.js";
|
|
23
|
+
import { taskSourceErrorDetail } from "./source-v3.js";
|
|
21
24
|
export function computeSchedulerExecutionEvidenceDigest(planHash, sourceReadSet) {
|
|
22
25
|
const envelope = canonicalJson({
|
|
23
26
|
version: 1,
|
|
@@ -249,7 +252,7 @@ async function compileDesiredSourceSet(input, collector) {
|
|
|
249
252
|
await compileTaskSources(input, collector, bindings, failures);
|
|
250
253
|
await compileWorkflowSources(input, collector, bindings, executableWorkflows, failures);
|
|
251
254
|
if (failures.length > 0) {
|
|
252
|
-
throw new UsageError(`Scheduler sync rejected the desired source set before mutation:\n${failures.map((failure) => `- ${failure}`).join("\n")}`, "
|
|
255
|
+
throw new UsageError(`Scheduler sync rejected the desired source set before mutation:\n${failures.map((failure) => `- ${failure}`).join("\n")}`, "TASK_SOURCE_INVALID");
|
|
253
256
|
}
|
|
254
257
|
return Object.freeze({
|
|
255
258
|
desired: Object.freeze(bindings),
|
|
@@ -272,12 +275,44 @@ async function compileTaskSources(input, collector, out, failures) {
|
|
|
272
275
|
throw new UsageError(`Task sources ${JSON.stringify(priorOwner)} and ${JSON.stringify(sourcePath)} resolve to the same physical source identity; refusing canonical task identity collision.`, "RESOURCE_ALREADY_EXISTS");
|
|
273
276
|
}
|
|
274
277
|
physicalOwners.set(physicalIdentity, sourcePath);
|
|
275
|
-
|
|
278
|
+
// Project BEFORE prepareTaskV3Execution so projectability is checked —
|
|
279
|
+
// but build the scheduler bindings from the ORIGINAL task source v4
|
|
280
|
+
// document, not the projection, which deliberately drops per-entry
|
|
281
|
+
// `enabled` and `schedule[i].inputs` (D2-N5, project-v4.ts) —
|
|
282
|
+
// schedule-supplied inputs are delivered through the scheduler
|
|
283
|
+
// binding's own compiled invocation tail (P2b Lane B, spec §4.4,
|
|
284
|
+
// B-N3), not through the prepare-seam projection. A task source v4
|
|
285
|
+
// document has no document-level `akm.enabled`, so `enabled: true` is
|
|
286
|
+
// passed at the document level and every entry's own `enabled`
|
|
287
|
+
// (always present, defaulted at parse time) decides.
|
|
288
|
+
const parsed = parseTaskSource({
|
|
276
289
|
yaml: guarded.content,
|
|
277
290
|
filePath: sourcePath,
|
|
278
291
|
workspaceRoot: input.sourceRoot,
|
|
279
292
|
});
|
|
293
|
+
const document = projectTaskSourceV4(parsed.v4);
|
|
280
294
|
const qualifiedRef = makeBundleRef(input.bundleName, conceptId);
|
|
295
|
+
// P2b Lane B (spec docs/plans/specs/p2b-input-bindings.md §4.4, rows
|
|
296
|
+
// B-50/F-B2): validate each v4 schedule entry's inputs against the
|
|
297
|
+
// task's OWN declared contract WITH DEFAULTS APPLIED — the same
|
|
298
|
+
// applyInputDefaults + validateInputs pair akm task run uses
|
|
299
|
+
// (src/tasks/run/load-task.ts). parseTaskSource's own parse-time check
|
|
300
|
+
// (task-source-v4.ts's parseScheduleEntry) already rejects an
|
|
301
|
+
// unknown/malformed entry against the RAW supplied values; this is a
|
|
302
|
+
// deliberate second, independent gate over the DEFAULTED view — the
|
|
303
|
+
// exact set of values the compiled invocation below actually delivers
|
|
304
|
+
// — so a violation fails HERE, recorded as a task failure at sync,
|
|
305
|
+
// rather than surfacing for the first time when the scheduler fires
|
|
306
|
+
// the compiled invocation.
|
|
307
|
+
const contract = parsed.v4.inputs ?? {};
|
|
308
|
+
for (const scheduleEntry of parsed.v4.schedule) {
|
|
309
|
+
const defaultedInputs = applyInputDefaults(contract, { ...scheduleEntry.inputs });
|
|
310
|
+
const errors = validateInputs(contract, defaultedInputs);
|
|
311
|
+
if (errors.length > 0) {
|
|
312
|
+
throw new UsageError(`Task ${JSON.stringify(qualifiedRef)} schedule[${scheduleEntry.ordinal}].inputs does not satisfy ` +
|
|
313
|
+
`its declared inputs once defaults are applied: ${errors.join("; ")}`, "TASK_SOURCE_INVALID");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
281
316
|
await prepareTaskV3Execution(document, {
|
|
282
317
|
taskId: id,
|
|
283
318
|
taskRef: qualifiedRef,
|
|
@@ -296,14 +331,22 @@ async function compileTaskSources(input, collector, out, failures) {
|
|
|
296
331
|
: loadAdapterExecutionSource(ref, "persona", guardedOptions);
|
|
297
332
|
},
|
|
298
333
|
});
|
|
334
|
+
const relSource = toPosix(path.relative(input.sourceRoot, sourcePath));
|
|
299
335
|
const sourceBindings = compileTaskSchedulerBindings({
|
|
300
336
|
id,
|
|
301
337
|
qualifiedRef,
|
|
302
338
|
...(input.bundleTarget ? { bundleTarget: input.bundleTarget } : {}),
|
|
303
|
-
enabled:
|
|
304
|
-
schedules:
|
|
305
|
-
|
|
306
|
-
|
|
339
|
+
enabled: true,
|
|
340
|
+
schedules: parsed.v4.schedule.map((schedule) => ({
|
|
341
|
+
cron: schedule.cron,
|
|
342
|
+
ordinal: schedule.ordinal,
|
|
343
|
+
enabled: schedule.enabled,
|
|
344
|
+
source: `${relSource}:${schedule.source}`,
|
|
345
|
+
// P2b Lane B (spec §4.4, B-N3): delivered through the compiled
|
|
346
|
+
// binding's own invocation tail below — the F-B2 flip that closes
|
|
347
|
+
// the P2a B-38 "validated but not yet delivered" gap this comment
|
|
348
|
+
// used to describe.
|
|
349
|
+
inputs: schedule.inputs,
|
|
307
350
|
})),
|
|
308
351
|
});
|
|
309
352
|
for (const binding of sourceBindings) {
|
|
@@ -335,11 +378,11 @@ async function compileWorkflowSources(input, collector, out, evidence, failures)
|
|
|
335
378
|
if (!compiled.ok) {
|
|
336
379
|
throw new UsageError(compiled.errors
|
|
337
380
|
.map((error) => `${error.path}:${error.line ?? 1} [${error.code}] ${error.message}`)
|
|
338
|
-
.join("; "), "
|
|
381
|
+
.join("; "), "WORKFLOW_SOURCE_INVALID");
|
|
339
382
|
}
|
|
340
383
|
const planDraft = compileWorkflowPlan(compiled.ir, canonicalName);
|
|
341
384
|
if (!planDraft.ok) {
|
|
342
|
-
throw new UsageError(planDraft.errors.map((error) => `${guarded.relativePath}:${error.line} ${error.message}`).join("; "), "
|
|
385
|
+
throw new UsageError(planDraft.errors.map((error) => `${guarded.relativePath}:${error.line} ${error.message}`).join("; "), "WORKFLOW_SOURCE_INVALID");
|
|
343
386
|
}
|
|
344
387
|
const conceptId = input.adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName;
|
|
345
388
|
const qualifiedRef = makeBundleRef(input.bundleName, conceptId);
|
|
@@ -359,7 +402,7 @@ async function compileWorkflowSources(input, collector, out, evidence, failures)
|
|
|
359
402
|
const executionEvidenceDigest = computeSchedulerExecutionEvidenceDigest(planHash, frozen.plan.sourceReadSet);
|
|
360
403
|
evidence.push(Object.freeze({
|
|
361
404
|
ref: qualifiedRef,
|
|
362
|
-
irVersion:
|
|
405
|
+
irVersion: 5,
|
|
363
406
|
planHash,
|
|
364
407
|
sourceReadSet: frozen.plan.sourceReadSet,
|
|
365
408
|
executionEvidenceDigest,
|
|
@@ -435,6 +478,21 @@ function installOptionsFor(input, current) {
|
|
|
435
478
|
return input.installOptions ? Object.freeze({ ...input.installOptions }) : undefined;
|
|
436
479
|
}
|
|
437
480
|
function belongsToBundle(entry, input) {
|
|
481
|
+
if (input.bundlePath !== undefined && entry.target === input.bundleName) {
|
|
482
|
+
// Path-scoped (#846), primary/unconfigured-bundle sync only: the name
|
|
483
|
+
// already matches, but a display name derived from a directory
|
|
484
|
+
// basename is not an identity — two unrelated bundles can legitimately
|
|
485
|
+
// share one. Require the entry's own scheduler-context descriptor to
|
|
486
|
+
// additionally confirm the resolved path. An entry whose owning path
|
|
487
|
+
// cannot be established is never assumed to be ours — that silent
|
|
488
|
+
// assumption is exactly what let an isolated/foreign bundle's sync
|
|
489
|
+
// reach for another bundle's real scheduler entries. (`bundlePath` is
|
|
490
|
+
// only set for a primary sync — a `--bundle <target>` entry's
|
|
491
|
+
// descriptor reflects the invoking process's OWN primary directory,
|
|
492
|
+
// not the targeted bundle's, so it is not a meaningful signal there;
|
|
493
|
+
// that case keeps relying on config-name uniqueness below.)
|
|
494
|
+
return entry.ownerBundlePath !== undefined && entry.ownerBundlePath === input.bundlePath;
|
|
495
|
+
}
|
|
438
496
|
if (entry.target === input.bundleName || entry.target === input.bundleTarget)
|
|
439
497
|
return true;
|
|
440
498
|
return false;
|
|
@@ -444,8 +502,13 @@ function assertNoForeignIds(desired, input) {
|
|
|
444
502
|
const foreign = input.installed.find((entry) => wanted.has(entry.id) && !belongsToBundle(entry, input));
|
|
445
503
|
if (!foreign)
|
|
446
504
|
return;
|
|
447
|
-
const where = foreign.
|
|
448
|
-
|
|
505
|
+
const where = foreign.ownerBundlePath
|
|
506
|
+
? `the bundle at ${JSON.stringify(foreign.ownerBundlePath)}`
|
|
507
|
+
: foreign.target
|
|
508
|
+
? `bundle ${JSON.stringify(foreign.target)}`
|
|
509
|
+
: "the default bundle";
|
|
510
|
+
const mine = input.bundlePath ? ` (this sync is scoped to ${JSON.stringify(input.bundlePath)})` : "";
|
|
511
|
+
throw new UsageError(`Scheduler id ${JSON.stringify(foreign.id)} is already scheduled from ${where}${mine}; desired source ids must not collide across bundles.`, "RESOURCE_ALREADY_EXISTS");
|
|
449
512
|
}
|
|
450
513
|
function assertUniqueDesiredIds(desired) {
|
|
451
514
|
const seen = new Set();
|
|
@@ -466,7 +529,7 @@ function assertUniqueInstalledIds(installed) {
|
|
|
466
529
|
}
|
|
467
530
|
}
|
|
468
531
|
function taskFailure(file, cause) {
|
|
469
|
-
const detail =
|
|
532
|
+
const detail = taskSourceErrorDetail(cause);
|
|
470
533
|
return detail === errorMessage(cause) ? `${file}: ${detail}` : detail;
|
|
471
534
|
}
|
|
472
535
|
function errorMessage(cause) {
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* The bounded-document front end and field helpers both task source grammars
|
|
6
|
+
* share (spec docs/plans/specs/p2a-task-source-v4.md §1.5 D2-N4, §3.1).
|
|
7
|
+
*
|
|
8
|
+
* This is the actual D2-N4 move, not a parallel reimplementation: every
|
|
9
|
+
* helper below — including the `TASK_V3_MAX_*` resource bounds and
|
|
10
|
+
* `assertBoundedTaskYamlDocument` — is now OWNED here, body-intact from
|
|
11
|
+
* `src/tasks/source-v3.ts`. `source-v3.ts` imports every one of them (and
|
|
12
|
+
* re-exports `assertBoundedTaskYamlDocument` / the `TASK_V3_MAX_*` constants
|
|
13
|
+
* at their existing names, since those were already part of its public
|
|
14
|
+
* surface) instead of declaring its own copies. §9's acceptance criterion is
|
|
15
|
+
* structural: "src/tasks/source/bounded-document.ts exists and owns the
|
|
16
|
+
* D2-N4 helpers; src/tasks/source-v3.ts imports them and contains no copy of
|
|
17
|
+
* any of them" — `tests/tasks/bounded-document.test.ts`'s last describe
|
|
18
|
+
* block pins this with an AST scan of `source-v3.ts`, not just a runtime
|
|
19
|
+
* behavior check (a copy-instead-of-move implementation would pass every
|
|
20
|
+
* OTHER test in that file and in `tests/tasks/source-v3.test.ts`, because
|
|
21
|
+
* both files would still call functions they have in scope either way).
|
|
22
|
+
*
|
|
23
|
+
* This module deliberately imports NOTHING from `../source-v3` (not even a
|
|
24
|
+
* type) — `tests/architecture/import-cycle-ratchet.test.ts` is an ABSOLUTE
|
|
25
|
+
* no-cycle gate over all of `src/**` (its baseline emptied at chunk-8 and
|
|
26
|
+
* counts type-only imports as real graph edges), and `source-v3.ts` now
|
|
27
|
+
* imports from this file, so a reverse edge here would close a cycle. Where
|
|
28
|
+
* a moved helper's signature referenced a v3-only type
|
|
29
|
+
* (`TaskV3AkmOptions["tools"]`, `TaskV3Environment`) the return type is
|
|
30
|
+
* inlined structurally instead — invisible at runtime, and TypeScript's
|
|
31
|
+
* structural typing makes the inlined shape and the named alias
|
|
32
|
+
* interchangeable at every call site.
|
|
33
|
+
*
|
|
34
|
+
* `parseTimeout` and `parseTools` stay v3-only in the sense that they
|
|
35
|
+
* hardcode the `["akm", …]` field path (byte-identical to v3's existing
|
|
36
|
+
* behavior — that hardcoding is exactly what D2-N4's binding resolution asks
|
|
37
|
+
* to preserve). Task source v4's top-level `timeout:`/`tools:` fields have
|
|
38
|
+
* their own siblings in `src/tasks/source/task-source-v4.ts`
|
|
39
|
+
* (`parseTimeoutTopLevel`/`parseToolsTopLevel`) with the same accept/reject
|
|
40
|
+
* semantics at a different, un-prefixed field path — v3 imports the two
|
|
41
|
+
* below rather than declaring them itself, purely because D2-N4 homes every
|
|
42
|
+
* one of these named helpers here. `nullableSelector` is the same kind of
|
|
43
|
+
* `["akm", …]`-hardcoding helper but is NOT in D2-N4's named list and stays
|
|
44
|
+
* declared directly in `source-v3.ts`.
|
|
45
|
+
*/
|
|
46
|
+
import fs from "node:fs";
|
|
47
|
+
import path from "node:path";
|
|
48
|
+
import { types as utilTypes } from "node:util";
|
|
49
|
+
import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
|
|
50
|
+
import { UsageError } from "../../core/errors.js";
|
|
51
|
+
import { DURATION_UNITS, parseDuration } from "../../core/time.js";
|
|
52
|
+
import { EXECUTION_MAX_TIMEOUT_MS } from "../../execution/limits.js";
|
|
53
|
+
import { snapshotStrictRecord } from "../../execution/record.js";
|
|
54
|
+
import { WORKFLOW_ENV_VAR_NAME_PATTERN } from "../../workflows/resource-limits.js";
|
|
55
|
+
// ── Resource bounds (D2-N4: owned here, re-exported by source-v3.ts) ───────
|
|
56
|
+
export const TASK_V3_MAX_SOURCE_BYTES = 1024 * 1024;
|
|
57
|
+
export const TASK_V3_MAX_JSON_DEPTH = 64;
|
|
58
|
+
export const TASK_V3_MAX_JSON_NODES = 10_000;
|
|
59
|
+
export const TASK_V3_MAX_COLLECTION_ITEMS = 1024;
|
|
60
|
+
export const TASK_V3_MAX_OBJECT_KEYS = 256;
|
|
61
|
+
export const TASK_V3_MAX_STRING_BYTES = 256 * 1024;
|
|
62
|
+
export const TASK_V3_MAX_SCHEDULES = 64;
|
|
63
|
+
export function own(value, key) {
|
|
64
|
+
return Object.hasOwn(value, key);
|
|
65
|
+
}
|
|
66
|
+
export function utf8Bytes(value) {
|
|
67
|
+
return new TextEncoder().encode(value).byteLength;
|
|
68
|
+
}
|
|
69
|
+
export function wellFormedUnicode(value) {
|
|
70
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
71
|
+
const code = value.charCodeAt(index);
|
|
72
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
73
|
+
const next = value.charCodeAt(index + 1);
|
|
74
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
75
|
+
return false;
|
|
76
|
+
index += 1;
|
|
77
|
+
}
|
|
78
|
+
else if (code >= 0xdc00 && code <= 0xdfff)
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
/** The one per-field error funnel both grammars render through, distinguished only by `ctx.sourceLabel` (D2-N4). */
|
|
84
|
+
export function sourceError(ctx, fieldPath, detail) {
|
|
85
|
+
const dotted = fieldPath.length === 0
|
|
86
|
+
? "$"
|
|
87
|
+
: fieldPath.reduce((display, segment) => typeof segment === "number"
|
|
88
|
+
? `${display}[${segment}]`
|
|
89
|
+
: display.length > 0
|
|
90
|
+
? `${display}.${segment}`
|
|
91
|
+
: segment, "");
|
|
92
|
+
const line = ctx.lineAt?.(fieldPath);
|
|
93
|
+
const location = `${ctx.filePath}${line === undefined ? "" : `:${line}`}`;
|
|
94
|
+
throw new UsageError(`Invalid ${ctx.sourceLabel} at ${location}: ${dotted} ${detail}`, "TASK_SOURCE_INVALID");
|
|
95
|
+
}
|
|
96
|
+
export function cloneBoundedJson(value, ctx, fieldPath, state, depth = 0, ancestors = new Set()) {
|
|
97
|
+
state.nodes += 1;
|
|
98
|
+
if (state.nodes > TASK_V3_MAX_JSON_NODES)
|
|
99
|
+
sourceError(ctx, fieldPath, `exceeds the ${TASK_V3_MAX_JSON_NODES}-node limit.`);
|
|
100
|
+
if (depth > TASK_V3_MAX_JSON_DEPTH)
|
|
101
|
+
sourceError(ctx, fieldPath, `exceeds the nesting depth of ${TASK_V3_MAX_JSON_DEPTH}.`);
|
|
102
|
+
if (value === null || typeof value === "boolean")
|
|
103
|
+
return value;
|
|
104
|
+
if (typeof value === "number") {
|
|
105
|
+
if (!Number.isFinite(value))
|
|
106
|
+
sourceError(ctx, fieldPath, "must be a finite JSON number.");
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
if (typeof value === "string") {
|
|
110
|
+
if (!wellFormedUnicode(value))
|
|
111
|
+
sourceError(ctx, fieldPath, "must contain well-formed Unicode.");
|
|
112
|
+
if (utf8Bytes(value) > TASK_V3_MAX_STRING_BYTES) {
|
|
113
|
+
sourceError(ctx, fieldPath, `exceeds the ${TASK_V3_MAX_STRING_BYTES}-byte string limit.`);
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
if (value === undefined)
|
|
118
|
+
sourceError(ctx, fieldPath, "must be omitted instead of set to undefined.");
|
|
119
|
+
if (typeof value !== "object")
|
|
120
|
+
sourceError(ctx, fieldPath, "must be JSON-safe.");
|
|
121
|
+
if (utilTypes.isProxy(value))
|
|
122
|
+
sourceError(ctx, fieldPath, "must not be a Proxy object.");
|
|
123
|
+
if (ancestors.has(value))
|
|
124
|
+
sourceError(ctx, fieldPath, "must not contain a cycle.");
|
|
125
|
+
const nextAncestors = new Set(ancestors).add(value);
|
|
126
|
+
if (Array.isArray(value)) {
|
|
127
|
+
if (Object.getPrototypeOf(value) !== Array.prototype)
|
|
128
|
+
sourceError(ctx, fieldPath, "array must use the standard prototype.");
|
|
129
|
+
const rawLength = Reflect.getOwnPropertyDescriptor(value, "length")?.value;
|
|
130
|
+
if (typeof rawLength !== "number" ||
|
|
131
|
+
!Number.isInteger(rawLength) ||
|
|
132
|
+
rawLength < 0 ||
|
|
133
|
+
rawLength > TASK_V3_MAX_COLLECTION_ITEMS) {
|
|
134
|
+
sourceError(ctx, fieldPath, `array exceeds the ${TASK_V3_MAX_COLLECTION_ITEMS}-item limit.`);
|
|
135
|
+
}
|
|
136
|
+
const length = rawLength;
|
|
137
|
+
const keys = Reflect.ownKeys(value);
|
|
138
|
+
if (keys.length !== length + 1)
|
|
139
|
+
sourceError(ctx, fieldPath, "array must be dense and contain no extra fields.");
|
|
140
|
+
const result = [];
|
|
141
|
+
for (let index = 0; index < length; index += 1) {
|
|
142
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, String(index));
|
|
143
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
|
144
|
+
sourceError(ctx, [...fieldPath, index], "array item must be an enumerable data property in a dense array.");
|
|
145
|
+
}
|
|
146
|
+
result.push(cloneBoundedJson(descriptor.value, ctx, [...fieldPath, index], state, depth + 1, nextAncestors));
|
|
147
|
+
}
|
|
148
|
+
return Object.freeze(result);
|
|
149
|
+
}
|
|
150
|
+
let snapshot;
|
|
151
|
+
try {
|
|
152
|
+
snapshot = snapshotStrictRecord(value, fieldPath.map(String).join(".") || "task source");
|
|
153
|
+
}
|
|
154
|
+
catch (cause) {
|
|
155
|
+
sourceError(ctx, fieldPath, cause instanceof Error ? cause.message : String(cause));
|
|
156
|
+
}
|
|
157
|
+
const entries = Object.entries(snapshot);
|
|
158
|
+
if (entries.length > TASK_V3_MAX_OBJECT_KEYS) {
|
|
159
|
+
sourceError(ctx, fieldPath, `mapping exceeds the ${TASK_V3_MAX_OBJECT_KEYS}-key limit.`);
|
|
160
|
+
}
|
|
161
|
+
const result = Object.create(null);
|
|
162
|
+
for (const [key, child] of entries) {
|
|
163
|
+
if (!wellFormedUnicode(key))
|
|
164
|
+
sourceError(ctx, fieldPath, "contains a mapping key with malformed Unicode.");
|
|
165
|
+
if (utf8Bytes(key) > TASK_V3_MAX_STRING_BYTES) {
|
|
166
|
+
sourceError(ctx, fieldPath, `contains a mapping key exceeding the ${TASK_V3_MAX_STRING_BYTES}-byte string limit.`);
|
|
167
|
+
}
|
|
168
|
+
Object.defineProperty(result, key, {
|
|
169
|
+
value: cloneBoundedJson(child, ctx, [...fieldPath, key], state, depth + 1, nextAncestors),
|
|
170
|
+
enumerable: true,
|
|
171
|
+
configurable: false,
|
|
172
|
+
writable: false,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return Object.freeze(result);
|
|
176
|
+
}
|
|
177
|
+
export function asRecord(value, ctx, fieldPath) {
|
|
178
|
+
if (value === null || Array.isArray(value) || typeof value !== "object")
|
|
179
|
+
sourceError(ctx, fieldPath, "must be a mapping.");
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
export function checkKeys(value, allowed, ctx, fieldPath) {
|
|
183
|
+
const allow = new Set(allowed);
|
|
184
|
+
const firstUnknown = Object.keys(value).find((key) => !allow.has(key));
|
|
185
|
+
if (firstUnknown !== undefined)
|
|
186
|
+
sourceError(ctx, [...fieldPath, firstUnknown], "is an unsupported field.");
|
|
187
|
+
}
|
|
188
|
+
export function presentJsonValue(value, ctx, fieldPath) {
|
|
189
|
+
if (value === undefined)
|
|
190
|
+
sourceError(ctx, fieldPath, "must be omitted instead of set to undefined.");
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
export function stringField(value, ctx, fieldPath, options = {}) {
|
|
194
|
+
if (value === null && options.nullable)
|
|
195
|
+
return null;
|
|
196
|
+
if (typeof value !== "string")
|
|
197
|
+
sourceError(ctx, fieldPath, options.nullable ? "must be a string or null." : "must be a string.");
|
|
198
|
+
if (options.nonempty && value.trim().length === 0)
|
|
199
|
+
sourceError(ctx, fieldPath, "must be a non-empty string.");
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
export function noGithubExpression(value, ctx, fieldPath) {
|
|
203
|
+
if (value.includes("${{"))
|
|
204
|
+
sourceError(ctx, fieldPath, "contains an unsupported GitHub expression.");
|
|
205
|
+
}
|
|
206
|
+
/** `env:` is already a top-level field in both grammars — reused body-intact, field path unchanged. */
|
|
207
|
+
export function parseEnvironment(value, ctx) {
|
|
208
|
+
const environment = asRecord(value, ctx, ["env"]);
|
|
209
|
+
for (const [key, child] of Object.entries(environment)) {
|
|
210
|
+
if (!WORKFLOW_ENV_VAR_NAME_PATTERN.test(key))
|
|
211
|
+
sourceError(ctx, ["env", key], "has an invalid environment variable name.");
|
|
212
|
+
if (typeof child !== "string" && typeof child !== "number" && typeof child !== "boolean") {
|
|
213
|
+
sourceError(ctx, ["env", key], "must be a string, finite number, or boolean.");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return environment;
|
|
217
|
+
}
|
|
218
|
+
export function parseStringArray(value, ctx, fieldPath, options = {}) {
|
|
219
|
+
if (!Array.isArray(value))
|
|
220
|
+
sourceError(ctx, fieldPath, "must be an array of strings.");
|
|
221
|
+
if (options.max !== undefined && value.length > options.max)
|
|
222
|
+
sourceError(ctx, fieldPath, `accepts at most ${options.max} items.`);
|
|
223
|
+
const strings = [];
|
|
224
|
+
for (const [index, entry] of value.entries()) {
|
|
225
|
+
if (typeof entry !== "string" || entry.length === 0)
|
|
226
|
+
sourceError(ctx, [...fieldPath, index], "must be a non-empty string.");
|
|
227
|
+
if (options.pattern && !options.pattern.test(entry))
|
|
228
|
+
sourceError(ctx, [...fieldPath, index], "has an invalid value.");
|
|
229
|
+
strings.push(entry);
|
|
230
|
+
}
|
|
231
|
+
return Object.freeze(strings);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* `akm.timeout` — v3-only (hardcodes the `["akm", "timeout"]` field path, see
|
|
235
|
+
* this module's header). Body-intact from `source-v3.ts`.
|
|
236
|
+
*/
|
|
237
|
+
export function parseTimeout(value, ctx) {
|
|
238
|
+
if (value === null)
|
|
239
|
+
return null;
|
|
240
|
+
if (typeof value === "string" && value.trim() !== value) {
|
|
241
|
+
sourceError(ctx, ["akm", "timeout"], "must not contain surrounding whitespace.");
|
|
242
|
+
}
|
|
243
|
+
const milliseconds = typeof value === "string" ? parseDuration(value, DURATION_UNITS) : value;
|
|
244
|
+
if (milliseconds === null ||
|
|
245
|
+
typeof milliseconds !== "number" ||
|
|
246
|
+
!Number.isSafeInteger(milliseconds) ||
|
|
247
|
+
milliseconds < 0 ||
|
|
248
|
+
milliseconds > EXECUTION_MAX_TIMEOUT_MS) {
|
|
249
|
+
sourceError(ctx, ["akm", "timeout"], `must be null, 0 through ${EXECUTION_MAX_TIMEOUT_MS} milliseconds, or a common duration such as 20m.`);
|
|
250
|
+
}
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* `akm.tools` — v3-only (hardcodes the `["akm", "tools"]` field path, see
|
|
255
|
+
* this module's header). Body-intact from `source-v3.ts`; the return type is
|
|
256
|
+
* inlined (was `TaskV3AkmOptions["tools"]`) so this module imports nothing
|
|
257
|
+
* from `../source-v3` (see header — the import-cycle ratchet is absolute).
|
|
258
|
+
*/
|
|
259
|
+
export function parseTools(value, ctx) {
|
|
260
|
+
if (value === null || typeof value === "string")
|
|
261
|
+
return value;
|
|
262
|
+
if (Array.isArray(value)) {
|
|
263
|
+
if (value.some((entry) => typeof entry !== "string"))
|
|
264
|
+
sourceError(ctx, ["akm", "tools"], "array values must be strings.");
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
if (typeof value === "object")
|
|
268
|
+
return value;
|
|
269
|
+
sourceError(ctx, ["akm", "tools"], "must be a string, string array, mapping, or null.");
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* `working-directory:` is already a top-level field in both grammars —
|
|
273
|
+
* reused body-intact, field path unchanged (`source-v3.ts:677-714`).
|
|
274
|
+
*/
|
|
275
|
+
export function validateWorkingDirectory(value, ctx) {
|
|
276
|
+
if (value.trim().length === 0 ||
|
|
277
|
+
value.includes("\0") ||
|
|
278
|
+
path.posix.isAbsolute(value.replaceAll("\\", "/")) ||
|
|
279
|
+
/^[A-Za-z]:[\\/]/.test(value) ||
|
|
280
|
+
value.startsWith("\\\\")) {
|
|
281
|
+
sourceError(ctx, ["working-directory"], "must be a non-empty relative path contained by the workspace root.");
|
|
282
|
+
}
|
|
283
|
+
const segments = value.replaceAll("\\", "/").split("/");
|
|
284
|
+
if (segments.some((segment) => segment === ".." || segment.length === 0)) {
|
|
285
|
+
sourceError(ctx, ["working-directory"], "must not contain empty or escaping path segments.");
|
|
286
|
+
}
|
|
287
|
+
if (!ctx.workspaceRoot) {
|
|
288
|
+
sourceError(ctx, ["working-directory"], "requires a workspace root so physical containment can be verified.");
|
|
289
|
+
}
|
|
290
|
+
let realRoot;
|
|
291
|
+
let realCandidate;
|
|
292
|
+
try {
|
|
293
|
+
realRoot = fs.realpathSync(ctx.workspaceRoot);
|
|
294
|
+
const candidate = path.resolve(realRoot, value);
|
|
295
|
+
const stat = fs.statSync(candidate);
|
|
296
|
+
if (!stat.isDirectory())
|
|
297
|
+
sourceError(ctx, ["working-directory"], "must resolve to a directory.");
|
|
298
|
+
realCandidate = fs.realpathSync(candidate);
|
|
299
|
+
}
|
|
300
|
+
catch (cause) {
|
|
301
|
+
if (cause instanceof UsageError)
|
|
302
|
+
throw cause;
|
|
303
|
+
sourceError(ctx, ["working-directory"], `cannot be physically verified: ${cause instanceof Error ? cause.message : String(cause)}.`);
|
|
304
|
+
}
|
|
305
|
+
const relative = path.relative(realRoot, realCandidate);
|
|
306
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
307
|
+
sourceError(ctx, ["working-directory"], "resolves outside the workspace root and is not physically contained.");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
export function yamlProblem(message) {
|
|
311
|
+
return message.split("\n")[0]?.trim() || "invalid YAML";
|
|
312
|
+
}
|
|
313
|
+
export function yamlAstError(options, node, detail) {
|
|
314
|
+
const range = node?.range;
|
|
315
|
+
const line = range && options.lineCounter ? options.lineCounter.linePos(range[0] ?? 0).line : undefined;
|
|
316
|
+
throw new UsageError(`Invalid ${options.sourceLabel} at ${options.filePath}${line === undefined ? "" : `:${line}`}: ${detail}`, "TASK_SOURCE_INVALID");
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Bound and close the YAML AST before `toJS` can allocate or recurse through
|
|
320
|
+
* it. This is shared by the v3 parser, task source v4, and the explicit v2
|
|
321
|
+
* migration reader.
|
|
322
|
+
*/
|
|
323
|
+
export function assertBoundedTaskYamlDocument(document, options) {
|
|
324
|
+
const stack = [{ node: document.contents, depth: 0 }];
|
|
325
|
+
let nodes = 0;
|
|
326
|
+
while (stack.length > 0) {
|
|
327
|
+
const current = stack.pop();
|
|
328
|
+
if (!current)
|
|
329
|
+
break;
|
|
330
|
+
const node = current.node;
|
|
331
|
+
if (node === null || node === undefined)
|
|
332
|
+
continue;
|
|
333
|
+
nodes += 1;
|
|
334
|
+
if (nodes > TASK_V3_MAX_JSON_NODES) {
|
|
335
|
+
yamlAstError(options, node, `YAML exceeds the ${TASK_V3_MAX_JSON_NODES}-node limit.`);
|
|
336
|
+
}
|
|
337
|
+
if (current.depth > TASK_V3_MAX_JSON_DEPTH) {
|
|
338
|
+
yamlAstError(options, node, `YAML exceeds the nesting depth of ${TASK_V3_MAX_JSON_DEPTH}.`);
|
|
339
|
+
}
|
|
340
|
+
if (isAlias(node))
|
|
341
|
+
yamlAstError(options, node, "YAML aliases are unsupported.");
|
|
342
|
+
if (node.anchor !== undefined) {
|
|
343
|
+
yamlAstError(options, node, "YAML anchors are unsupported.");
|
|
344
|
+
}
|
|
345
|
+
if (node.tag) {
|
|
346
|
+
yamlAstError(options, node, "custom or explicit YAML tags are unsupported.");
|
|
347
|
+
}
|
|
348
|
+
if (isScalar(node))
|
|
349
|
+
continue;
|
|
350
|
+
if (isSeq(node)) {
|
|
351
|
+
if (node.items.length > TASK_V3_MAX_COLLECTION_ITEMS) {
|
|
352
|
+
yamlAstError(options, node, `YAML sequence exceeds the ${TASK_V3_MAX_COLLECTION_ITEMS}-item limit.`);
|
|
353
|
+
}
|
|
354
|
+
for (let index = node.items.length - 1; index >= 0; index -= 1) {
|
|
355
|
+
stack.push({ node: node.items[index], depth: current.depth + 1 });
|
|
356
|
+
}
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (isMap(node)) {
|
|
360
|
+
if (node.items.length > TASK_V3_MAX_OBJECT_KEYS) {
|
|
361
|
+
yamlAstError(options, node, `YAML mapping exceeds the ${TASK_V3_MAX_OBJECT_KEYS}-key limit.`);
|
|
362
|
+
}
|
|
363
|
+
for (let index = node.items.length - 1; index >= 0; index -= 1) {
|
|
364
|
+
const pair = node.items[index];
|
|
365
|
+
if (!pair)
|
|
366
|
+
yamlAstError(options, node, "sparse YAML mappings are unsupported.");
|
|
367
|
+
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
|
|
368
|
+
yamlAstError(options, pair.key, "non-string YAML mapping keys are unsupported.");
|
|
369
|
+
}
|
|
370
|
+
if (!wellFormedUnicode(pair.key.value)) {
|
|
371
|
+
yamlAstError(options, pair.key, "YAML mapping key must contain well-formed Unicode.");
|
|
372
|
+
}
|
|
373
|
+
if (utf8Bytes(pair.key.value) > TASK_V3_MAX_STRING_BYTES) {
|
|
374
|
+
yamlAstError(options, pair.key, `YAML mapping key exceeds the ${TASK_V3_MAX_STRING_BYTES}-byte string limit.`);
|
|
375
|
+
}
|
|
376
|
+
if (pair.key.value === "<<")
|
|
377
|
+
yamlAstError(options, pair.key, "YAML merge keys are unsupported.");
|
|
378
|
+
stack.push({ node: pair.value, depth: current.depth + 1 });
|
|
379
|
+
stack.push({ node: pair.key, depth: current.depth + 1 });
|
|
380
|
+
}
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
yamlAstError(options, node, "unsupported YAML node kind.");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Parse hostile YAML without aliases/tags/merges, then hand back the bounded
|
|
388
|
+
* `{root, lineAt}` pair both grammars that read a task document consume —
|
|
389
|
+
* originally lifted from the now-deleted `parseTaskV3Yaml`
|
|
390
|
+
* (`source-v3.ts:894-952`), generalized only by `sourceLabel`.
|
|
391
|
+
*
|
|
392
|
+
* P4 (docs/plans/specs/p4-deletions-closeout.md §3.2) deleted task v3
|
|
393
|
+
* acceptance from `src` entirely — `parseTaskV3Yaml` and its `"task v3
|
|
394
|
+
* source"` label no longer exist here (the grammar survives only in the
|
|
395
|
+
* vendored, frozen `scripts/akm-migrate/migrate/task-source-v3-frozen.ts`
|
|
396
|
+
* copy, which does not call this function). The two live `src` callers
|
|
397
|
+
* today: `parseTaskSourceV4`'s standalone YAML-string entry
|
|
398
|
+
* (`task-source-v4.ts:790`) passes `sourceLabel: "task source v4"`; the
|
|
399
|
+
* version router (`parse-task-source.ts:61`) calls this directly with
|
|
400
|
+
* `sourceLabel: "task source"` — not "task v3 source" — because its front
|
|
401
|
+
* end runs before `root.version` is even read, so it cannot yet know which
|
|
402
|
+
* schema version the document will turn out to be.
|
|
403
|
+
*/
|
|
404
|
+
export function readBoundedTaskSourceYaml(input, options) {
|
|
405
|
+
const sourceLabel = options.sourceLabel;
|
|
406
|
+
// P4 (docs/plans/specs/p4-deletions-closeout.md §5.2, row R-R8): these six
|
|
407
|
+
// throws used to omit their `code` argument and rely on the constructor's
|
|
408
|
+
// `INVALID_FLAG_VALUE` default (a pre-P4 ratchet-gaming trick that kept the
|
|
409
|
+
// literal string out of the grep-style count while the effective code
|
|
410
|
+
// stayed generic). §5.2's target table closes that gap: every pre-version
|
|
411
|
+
// failure in this bounded YAML front end (not a string, too large, YAML
|
|
412
|
+
// parse/expansion) is a task-source defect, not a flag-parsing one, so each
|
|
413
|
+
// now carries `TASK_SOURCE_INVALID` explicitly.
|
|
414
|
+
if (typeof input.yaml !== "string") {
|
|
415
|
+
throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: source must be a string.`, "TASK_SOURCE_INVALID");
|
|
416
|
+
}
|
|
417
|
+
if (utf8Bytes(input.yaml) > TASK_V3_MAX_SOURCE_BYTES) {
|
|
418
|
+
throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: source exceeds the 1 MiB (${TASK_V3_MAX_SOURCE_BYTES}-byte) resource limit.`, "TASK_SOURCE_INVALID");
|
|
419
|
+
}
|
|
420
|
+
const lineCounter = new LineCounter();
|
|
421
|
+
let document;
|
|
422
|
+
try {
|
|
423
|
+
document = parseDocument(input.yaml, { lineCounter, uniqueKeys: true });
|
|
424
|
+
}
|
|
425
|
+
catch (cause) {
|
|
426
|
+
throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: YAML parsing failed: ${cause instanceof Error ? cause.message : String(cause)}`, "TASK_SOURCE_INVALID");
|
|
427
|
+
}
|
|
428
|
+
const [problem] = document.errors;
|
|
429
|
+
if (problem) {
|
|
430
|
+
const offset = Array.isArray(problem.pos) ? problem.pos[0] : 0;
|
|
431
|
+
throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}:${lineCounter.linePos(offset).line}: ${yamlProblem(problem.message)}`, "TASK_SOURCE_INVALID");
|
|
432
|
+
}
|
|
433
|
+
const [warning] = document.warnings;
|
|
434
|
+
if (warning) {
|
|
435
|
+
throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: unsupported YAML construct: ${yamlProblem(warning.message)}`, "TASK_SOURCE_INVALID");
|
|
436
|
+
}
|
|
437
|
+
assertBoundedTaskYamlDocument(document, { filePath: input.filePath, sourceLabel, lineCounter });
|
|
438
|
+
let root;
|
|
439
|
+
try {
|
|
440
|
+
root = document.toJS({ maxAliasCount: 0 });
|
|
441
|
+
}
|
|
442
|
+
catch (cause) {
|
|
443
|
+
throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: YAML expansion failed: ${cause instanceof Error ? cause.message : String(cause)}`, "TASK_SOURCE_INVALID");
|
|
444
|
+
}
|
|
445
|
+
const lineAt = (fieldPath) => {
|
|
446
|
+
for (let depth = fieldPath.length; depth >= 0; depth -= 1) {
|
|
447
|
+
const node = depth === 0 ? document.contents : document.getIn(fieldPath.slice(0, depth), true);
|
|
448
|
+
const range = node?.range;
|
|
449
|
+
if (range)
|
|
450
|
+
return lineCounter.linePos(range[0]).line;
|
|
451
|
+
}
|
|
452
|
+
return undefined;
|
|
453
|
+
};
|
|
454
|
+
return { root, lineAt };
|
|
455
|
+
}
|