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.
Files changed (109) hide show
  1. package/CHANGELOG.md +707 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/commands/agent/contribute-cli.js +1 -1
  5. package/dist/commands/feedback-cli.js +7 -1
  6. package/dist/commands/health/llm-usage.js +2 -1
  7. package/dist/commands/health/surfaces.js +4 -77
  8. package/dist/commands/health.js +65 -11
  9. package/dist/commands/improve/distill/quality-gate.js +6 -1
  10. package/dist/commands/improve/eligibility.js +7 -1
  11. package/dist/commands/improve/improve.js +126 -10
  12. package/dist/commands/improve/locks.js +7 -0
  13. package/dist/commands/improve/memory/memory-improve.js +8 -0
  14. package/dist/commands/improve/run-context.js +5 -0
  15. package/dist/commands/improve/session-asset.js +4 -0
  16. package/dist/commands/lint/base-linter.js +31 -7
  17. package/dist/commands/lint/index.js +200 -50
  18. package/dist/commands/lint/types.js +22 -1
  19. package/dist/commands/proposal/repository.js +17 -1
  20. package/dist/commands/sources/info.js +12 -2
  21. package/dist/commands/sources/installed-stashes.js +6 -1
  22. package/dist/commands/tasks/tasks.js +8 -2
  23. package/dist/commands/workflow-cli.js +17 -11
  24. package/dist/core/abort-deadline.js +28 -0
  25. package/dist/core/adapter/adapters/agent-skills-adapter.js +67 -5
  26. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  27. package/dist/core/adapter/adapters/akm-lint.js +72 -20
  28. package/dist/core/adapter/adapters/akm-task-adapter.js +40 -19
  29. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  30. package/dist/core/asset/frontmatter.js +4 -0
  31. package/dist/core/common.js +66 -6
  32. package/dist/core/concurrent.js +32 -0
  33. package/dist/core/config/schema/workflow.js +11 -0
  34. package/dist/core/errors.js +25 -0
  35. package/dist/core/events.js +30 -24
  36. package/dist/core/file-lock.js +7 -1
  37. package/dist/core/improve-result.js +5 -0
  38. package/dist/core/json-schema.js +327 -9
  39. package/dist/core/loopback.js +89 -0
  40. package/dist/core/migration-operation.js +17 -2
  41. package/dist/core/path-access.js +107 -0
  42. package/dist/core/redaction.js +64 -17
  43. package/dist/core/spawn-env.js +234 -0
  44. package/dist/core/state-db-scope.js +134 -0
  45. package/dist/core/subprocess.js +181 -37
  46. package/dist/core/write-provenance.js +85 -0
  47. package/dist/core/write-source.js +7 -0
  48. package/dist/indexer/db/graph-db.js +17 -6
  49. package/dist/indexer/ensure-index.js +10 -3
  50. package/dist/indexer/index-written-assets.js +17 -2
  51. package/dist/indexer/indexer.js +55 -15
  52. package/dist/indexer/passes/memory-inference.js +4 -0
  53. package/dist/indexer/search/db-search.js +8 -15
  54. package/dist/integrations/agent/engine-resolution.js +24 -11
  55. package/dist/integrations/agent/model-aliases.js +1 -1
  56. package/dist/integrations/agent/profiles.js +9 -1
  57. package/dist/integrations/agent/spawn.js +15 -87
  58. package/dist/integrations/lockfile.js +45 -2
  59. package/dist/output/text/lint-format.js +17 -4
  60. package/dist/scripts/akm-migrate-node.js +1435 -753
  61. package/dist/scripts/akm-migrate.js +1403 -721
  62. package/dist/sources/providers/git-stash.js +19 -0
  63. package/dist/sources/providers/git.js +1 -1
  64. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  65. package/dist/storage/database.js +6 -1
  66. package/dist/storage/managed-db.js +19 -0
  67. package/dist/storage/repositories/index-connection.js +28 -3
  68. package/dist/storage/repositories/index-entries-repository.js +6 -1
  69. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  70. package/dist/tasks/log-redaction.js +156 -0
  71. package/dist/tasks/parser.js +82 -5
  72. package/dist/tasks/runner.js +139 -11
  73. package/dist/tasks/schema.js +65 -0
  74. package/dist/workflows/concurrency-policy.js +95 -1
  75. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  76. package/dist/workflows/exec/exec-unit.js +542 -0
  77. package/dist/workflows/exec/frozen-judge.js +114 -42
  78. package/dist/workflows/exec/native-executor.js +457 -238
  79. package/dist/workflows/exec/param-secrets.js +4 -3
  80. package/dist/workflows/exec/run-workflow.js +424 -219
  81. package/dist/workflows/exec/step-work.js +496 -165
  82. package/dist/workflows/exec/unit-dispatch.js +31 -1
  83. package/dist/workflows/exec/unit-writer.js +53 -13
  84. package/dist/workflows/exec/worktree.js +454 -41
  85. package/dist/workflows/ir/compile.js +26 -2
  86. package/dist/workflows/ir/freeze.js +82 -15
  87. package/dist/workflows/ir/schema.js +105 -20
  88. package/dist/workflows/parser.js +216 -18
  89. package/dist/workflows/program/schema.js +24 -0
  90. package/dist/workflows/renderer.js +32 -4
  91. package/dist/workflows/resource-limits.js +182 -0
  92. package/dist/workflows/runtime/runs.js +146 -6
  93. package/dist/workflows/validate-summary.js +17 -2
  94. package/docs/README.md +74 -32
  95. package/docs/migration/release-notes/0.9.0.md +2 -1
  96. package/docs/migration/v0.7-to-v0.8.md +2 -1
  97. package/docs/migration/v0.8-to-v0.9.md +3 -1
  98. package/docs/reference/README.md +11 -4
  99. package/docs/reference/bundle-types.md +19 -0
  100. package/docs/reference/cli.md +105 -16
  101. package/docs/reference/configuration.md +15 -2
  102. package/docs/reference/data-and-telemetry.md +30 -10
  103. package/docs/reference/supported-formats.md +50 -0
  104. package/docs/reference/workflow-schema.md +1014 -0
  105. package/docs/reference/workflows.md +37 -633
  106. package/package.json +13 -6
  107. package/schemas/akm-config.json +8 -0
  108. package/schemas/akm-task.json +27 -5
  109. package/schemas/akm-workflow.json +85 -10
@@ -4,6 +4,7 @@
4
4
  import { insertEvent, readStateEvents } from "../storage/repositories/events-repository.js";
5
5
  import { rethrowIfTestIsolationError } from "./errors.js";
6
6
  import { getStateDbPath, openStateDatabase, withStateDb } from "./state-db.js";
7
+ import { borrowScopedStateDb } from "./state-db-scope.js";
7
8
  import { error } from "./warn.js";
8
9
  /**
9
10
  * Resolve the state.db path from context:
@@ -28,36 +29,41 @@ function resolveNow(ctx) {
28
29
  * function writes directly to that handle without opening or closing the DB.
29
30
  * This eliminates per-event open/migrate/close overhead for high-frequency
30
31
  * callers such as `akmImprove`.
32
+ *
33
+ * The same fast path is taken IMPLICITLY inside a `withStateDbScope` /
34
+ * `withWorkflowRunsConnection` scope (`core/state-db-scope.ts`): the ambient
35
+ * scoped handle for this event's resolved `dbPath` is borrowed, so a workflow
36
+ * step's `workflow_unit_started` / `workflow_unit_finished` pair rides the same
37
+ * connection its journal rows do instead of opening state.db twice per unit.
38
+ * The scope owns that handle's lifetime; `appendEvent` never closes a borrowed
39
+ * connection.
31
40
  */
32
41
  export function appendEvent(input, ctx) {
33
42
  const now = resolveNow(ctx);
34
43
  const ts = new Date(now()).toISOString();
35
- // Fast path: caller provided a long-lived connection use it directly.
36
- if (ctx?.db) {
37
- try {
38
- insertEvent(ctx.db, {
39
- eventType: input.eventType,
40
- ts,
41
- ref: input.ref,
42
- metadata: input.metadata,
43
- });
44
+ const row = { eventType: input.eventType, ts, ref: input.ref, metadata: input.metadata };
45
+ // One try covers EVERY path — including resolving the state.db path, which
46
+ // reads the environment and throws where no data dir can be derived — so the
47
+ // best-effort contract ("a write failure never propagates") holds no matter
48
+ // which handle this event lands on.
49
+ try {
50
+ // Fast path: an explicitly supplied long-lived connection. Resolution is
51
+ // skipped outright, which is what "`dbPath` is ignored when `db` is
52
+ // provided" has to mean for a caller that already holds an open handle.
53
+ if (ctx?.db) {
54
+ insertEvent(ctx.db, row);
55
+ return;
44
56
  }
45
- catch (err) {
46
- error(`akm: appendEvent failed: ${String(err)}`);
57
+ const dbPath = resolveDbPath(ctx);
58
+ // The ambient scoped handle for this path, when a scope is open — borrowed
59
+ // exactly like the explicit one, and never closed here.
60
+ const borrowed = borrowScopedStateDb(dbPath);
61
+ if (borrowed) {
62
+ insertEvent(borrowed, row);
63
+ return;
47
64
  }
48
- return;
49
- }
50
- // Default path: open, insert, close.
51
- const dbPath = resolveDbPath(ctx);
52
- try {
53
- withStateDb((db) => {
54
- insertEvent(db, {
55
- eventType: input.eventType,
56
- ts,
57
- ref: input.ref,
58
- metadata: input.metadata,
59
- });
60
- }, { path: dbPath });
65
+ // Default path: open, insert, close.
66
+ withStateDb((db) => insertEvent(db, row), { path: dbPath });
61
67
  }
62
68
  catch (err) {
63
69
  // Never mask the bun-test isolation guard as a silent "events failed".
@@ -139,7 +139,13 @@ export function probeLock(lockPath, opts) {
139
139
  try {
140
140
  snapshot = readLockSnapshot(lockPath);
141
141
  }
142
- catch {
142
+ catch (error) {
143
+ // A permission error means the lock may be genuinely HELD by someone we
144
+ // cannot see. Reporting it as stale would invite a caller to steal a live
145
+ // lease (#791) — `unreadable` stays for the corrupt/undecodable case.
146
+ const code = error?.code;
147
+ if (code === "EACCES" || code === "EPERM")
148
+ return { state: "inaccessible", code };
143
149
  return { state: "stale", reason: "unreadable" };
144
150
  }
145
151
  if (!snapshot)
@@ -40,6 +40,7 @@ const COMMON_FIELDS = [
40
40
  "cycleMetrics",
41
41
  "runId",
42
42
  "sync",
43
+ "writtenPaths",
43
44
  "terminated",
44
45
  ];
45
46
  const V2_FIELDS = new Set([...COMMON_FIELDS, "strategy", "strategyFilteredRefs"]);
@@ -83,10 +84,14 @@ function validateCommon(value) {
83
84
  "extract",
84
85
  "coverageGaps",
85
86
  "deadUrls",
87
+ "writtenPaths",
86
88
  ]) {
87
89
  if (value[field] !== undefined && !Array.isArray(value[field]))
88
90
  fail(`${field} must be an array`);
89
91
  }
92
+ if (Array.isArray(value.writtenPaths) && value.writtenPaths.some((entry) => typeof entry !== "string")) {
93
+ fail("writtenPaths must be an array of strings");
94
+ }
90
95
  for (const field of [
91
96
  "cyclesRun",
92
97
  "evalCasesWritten",
@@ -14,22 +14,271 @@
14
14
  * Supported: `type` (string | string[] — string, number, integer, boolean,
15
15
  * object, array, null), `properties`, `required`, `items`,
16
16
  * `additionalProperties: false`, `enum` (primitives), `minItems`,
17
- * `maxItems`, `minLength`, `maxLength`, `minimum`, `maximum`.
17
+ * `maxItems`, `minLength`, `maxLength`, `minimum`, `maximum`, and the
18
+ * combinators `allOf`, `anyOf`, `oneOf`, `not`.
18
19
  *
19
- * Ignored (permissive): `$ref`, `allOf`/`anyOf`/`oneOf`/`not`, `pattern`,
20
- * `format`, and every other keyword. Unknown keywords never throw — a
21
- * schema using them simply constrains less. Callers needing full JSON
22
- * Schema semantics should validate downstream.
20
+ * Ignored (permissive): `$ref`, `pattern`, `format`, `patternProperties`,
21
+ * tuple-form `items`, schema-form `additionalProperties`, and every other
22
+ * keyword. Unknown keywords never throw a schema using them simply
23
+ * constrains less. Callers needing full JSON Schema semantics should
24
+ * validate downstream.
25
+ *
26
+ * `pattern` is deliberately NOT evaluated. Matching an author-supplied regex
27
+ * with the platform `RegExp` inside a synchronous gate decision would need a
28
+ * static safety analysis to stay bounded, and any such analysis rejects
29
+ * legitimate patterns — machinery that makes authoring fail for no benefit
30
+ * the workflows here ask for. `pattern` is a recognized-but-unsupported
31
+ * keyword instead: {@link checkJsonSchemaDefinition} reports it loudly at
32
+ * authoring time, so nobody writes one believing it is enforced.
33
+ *
34
+ * ## Totality and bounds
35
+ *
36
+ * Evaluation is TOTAL and BOUNDED. Recursion is capped at
37
+ * {@link MAX_DEFINITION_DEPTH} and the whole evaluation shares a single
38
+ * node-visit budget ({@link MAX_VALIDATION_NODES}); exhausting either emits an
39
+ * explicit error rather than silently accepting the value — the subset never
40
+ * fails open. The schema tree is finite and acyclic (no `$ref`), so combinator
41
+ * branching multiplies work by schema size, never exponentially.
23
42
  *
24
43
  * Returns a flat list of human-readable error strings (empty = valid), each
25
44
  * prefixed with a JSON-pointer-ish path — the shape `runStructured`'s
26
45
  * corrective-feedback builder wants.
46
+ *
47
+ * {@link checkJsonSchemaDefinition} is the companion DEFINITION checker: it
48
+ * walks an author-declared schema OBJECT (not a value) and reports typo'd
49
+ * `type` names / structurally malformed keywords as `"malformed"` issues, and
50
+ * recognized JSON Schema keywords this subset silently ignores as
51
+ * `"unsupported"` issues — so a schema that would constrain nothing at
52
+ * runtime can be rejected loudly at authoring time (the workflow parser does
53
+ * exactly that for `output:` and `params` schemas). It deliberately does NOT
54
+ * change {@link validateJsonSchemaSubset}'s permissive evaluation semantics.
27
55
  */
56
+ /**
57
+ * Deepest schema nesting either walk descends into — {@link
58
+ * validateJsonSchemaSubset} evaluating a value, and {@link
59
+ * checkJsonSchemaDefinition} checking the schema object itself. Both walk the
60
+ * same schema tree, so they share one bound.
61
+ */
62
+ const MAX_DEFINITION_DEPTH = 64;
63
+ /** Total (schema node × value node) visits one {@link validateJsonSchemaSubset} call may make. */
64
+ const MAX_VALIDATION_NODES = 100_000;
28
65
  export function validateJsonSchemaSubset(value, schema) {
29
66
  const errors = [];
30
- validateNode(value, schema, "$", errors);
67
+ const budget = { nodes: MAX_VALIDATION_NODES };
68
+ validateNode(value, schema, "$", { errors, budget, depth: 0 });
69
+ if (budget.nodes < 0) {
70
+ errors.push(`$: schema evaluation exceeded the limit of ${MAX_VALIDATION_NODES} checks and was stopped`);
71
+ }
31
72
  return errors;
32
73
  }
74
+ /** Human-readable list of the keywords {@link validateJsonSchemaSubset} enforces (for error messages). */
75
+ export const JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS = "type, enum, properties, required, items, additionalProperties: false, minItems, maxItems, " +
76
+ "minLength, maxLength, minimum, maximum, allOf, anyOf, oneOf, not";
77
+ const KNOWN_TYPE_NAMES = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]);
78
+ // Annotation keywords (`title`, `description`, `default`, `examples`,
79
+ // `$schema`, `$id`, `$comment`, `deprecated`, `readOnly`, `writeOnly`) are
80
+ // deliberately NOT in the unsupported set below: they constrain nothing in
81
+ // full JSON Schema either, so the subset ignoring them loses no semantics.
82
+ // Like any other unrecognized keyword (e.g. `x-…` extensions), they fall
83
+ // through the checker unreported — JSON Schema's own open-keyword behavior.
84
+ /**
85
+ * Recognized JSON Schema keywords {@link validateJsonSchemaSubset} silently
86
+ * ignores — a schema relying on one of these constrains LESS at runtime than
87
+ * its author intended, so definition checking reports each as `"unsupported"`.
88
+ */
89
+ const UNSUPPORTED_KEYWORDS = new Set([
90
+ "$ref",
91
+ "$defs",
92
+ "definitions",
93
+ "$anchor",
94
+ "$dynamicRef",
95
+ "$dynamicAnchor",
96
+ "if",
97
+ "then",
98
+ "else",
99
+ "const",
100
+ "pattern",
101
+ "format",
102
+ "patternProperties",
103
+ "propertyNames",
104
+ "additionalItems",
105
+ "prefixItems",
106
+ "contains",
107
+ "minContains",
108
+ "maxContains",
109
+ "uniqueItems",
110
+ "multipleOf",
111
+ "exclusiveMinimum",
112
+ "exclusiveMaximum",
113
+ "minProperties",
114
+ "maxProperties",
115
+ "dependencies",
116
+ "dependentRequired",
117
+ "dependentSchemas",
118
+ "unevaluatedItems",
119
+ "unevaluatedProperties",
120
+ "contentEncoding",
121
+ "contentMediaType",
122
+ "contentSchema",
123
+ ]);
124
+ /**
125
+ * Per-keyword follow-up for the `"unsupported"` message. Where the subset can
126
+ * express the same intent, name the keyword that does it; where it cannot, say
127
+ * so plainly rather than sending the author to another unsupported keyword.
128
+ */
129
+ const UNSUPPORTED_KEYWORD_HINTS = new Map([
130
+ ["$ref", `inline the referenced schema (the subset resolves no references, so it cannot follow "$ref")`],
131
+ ["$defs", `inline the definitions at their use sites — "$ref" is not resolved, so "$defs" can never be reached`],
132
+ [
133
+ "definitions",
134
+ `inline the definitions at their use sites — "$ref" is not resolved, so "definitions" can never be reached`,
135
+ ],
136
+ ["const", `use a single-value "enum" (e.g. enum: [pass])`],
137
+ [
138
+ "pattern",
139
+ `use "enum" when the allowed strings can be listed, or "minLength"/"maxLength" for a size bound — ` +
140
+ `a regular-expression constraint is not expressible in the subset; check the shape in the step's gate rubric instead`,
141
+ ],
142
+ [
143
+ "format",
144
+ `"format" is annotation-only in JSON Schema 2020-12 and the subset enforces no string-shape keyword — ` +
145
+ `use "enum" when the allowed values can be listed, otherwise check the shape in the step's gate rubric`,
146
+ ],
147
+ ["patternProperties", `declare the properties explicitly under "properties", or drop the constraint`],
148
+ ["if", `use "anyOf"/"oneOf" to express the alternatives directly`],
149
+ ["then", `use "anyOf"/"oneOf" to express the alternatives directly`],
150
+ ["else", `use "anyOf"/"oneOf" to express the alternatives directly`],
151
+ ["uniqueItems", `drop the constraint, or validate uniqueness in the step's gate rubric`],
152
+ ]);
153
+ /**
154
+ * Check a JSON Schema DEFINITION (the schema object itself, not a value)
155
+ * against the subset {@link validateJsonSchemaSubset} enforces. Returns
156
+ * accumulated issues (empty = the schema is a well-formed subset schema).
157
+ * Keywords that are neither subset-enforced, unsupported-but-recognized, nor
158
+ * annotations are ignored, matching JSON Schema's own open-keyword behavior.
159
+ */
160
+ export function checkJsonSchemaDefinition(schema) {
161
+ const issues = [];
162
+ checkDefinitionNode(schema, [], issues, 0);
163
+ return issues;
164
+ }
165
+ function pointerFor(path) {
166
+ return path.length === 0 ? "$" : `$.${path.map(String).join(".")}`;
167
+ }
168
+ function pushIssue(issues, path, keyword, kind, message) {
169
+ issues.push({ path: [...path], pointer: pointerFor(path), keyword, kind, message });
170
+ }
171
+ function isPlainObject(value) {
172
+ return typeof value === "object" && value !== null && !Array.isArray(value);
173
+ }
174
+ function checkDefinitionNode(schema, path, issues, depth) {
175
+ if (depth > MAX_DEFINITION_DEPTH) {
176
+ pushIssue(issues, path, "(depth)", "malformed", `schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`);
177
+ return;
178
+ }
179
+ for (const keyword of Object.keys(schema)) {
180
+ if (UNSUPPORTED_KEYWORDS.has(keyword)) {
181
+ const hint = UNSUPPORTED_KEYWORD_HINTS.get(keyword);
182
+ pushIssue(issues, [...path, keyword], keyword, "unsupported", `keyword "${keyword}" is not enforced by the workflow schema subset — the schema would silently not ` +
183
+ `constrain what it looks like it constrains${hint ? `; ${hint}` : ""}`);
184
+ }
185
+ }
186
+ const declared = schema.type;
187
+ if (declared !== undefined) {
188
+ const names = Array.isArray(declared) ? declared : [declared];
189
+ if (names.length === 0) {
190
+ pushIssue(issues, [...path, "type"], "type", "malformed", `"type" must name at least one type`);
191
+ }
192
+ for (const [index, name] of names.entries()) {
193
+ const namePath = Array.isArray(declared) ? [...path, "type", index] : [...path, "type"];
194
+ if (typeof name !== "string") {
195
+ pushIssue(issues, namePath, "type", "malformed", `"type" must be a string or an array of strings`);
196
+ }
197
+ else if (!KNOWN_TYPE_NAMES.has(name)) {
198
+ pushIssue(issues, namePath, "type", "malformed", `unknown type ${JSON.stringify(name)} (valid types: ${[...KNOWN_TYPE_NAMES].join(", ")})`);
199
+ }
200
+ }
201
+ }
202
+ if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length === 0)) {
203
+ pushIssue(issues, [...path, "enum"], "enum", "malformed", `"enum" must be a non-empty array of allowed values`);
204
+ }
205
+ for (const keyword of ["allOf", "anyOf", "oneOf"]) {
206
+ const branches = schema[keyword];
207
+ if (branches === undefined)
208
+ continue;
209
+ if (!Array.isArray(branches) || branches.length === 0) {
210
+ pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a non-empty array of schema objects`);
211
+ continue;
212
+ }
213
+ branches.forEach((branch, index) => {
214
+ if (isPlainObject(branch)) {
215
+ checkDefinitionNode(branch, [...path, keyword, index], issues, depth + 1);
216
+ }
217
+ else {
218
+ pushIssue(issues, [...path, keyword, index], keyword, "malformed", `"${keyword}[${index}]" must be a schema object`);
219
+ }
220
+ });
221
+ }
222
+ if (schema.not !== undefined) {
223
+ if (isPlainObject(schema.not)) {
224
+ checkDefinitionNode(schema.not, [...path, "not"], issues, depth + 1);
225
+ }
226
+ else {
227
+ pushIssue(issues, [...path, "not"], "not", "malformed", `"not" must be a schema object`);
228
+ }
229
+ }
230
+ if (schema.required !== undefined) {
231
+ if (!Array.isArray(schema.required) || !schema.required.every((key) => typeof key === "string")) {
232
+ pushIssue(issues, [...path, "required"], "required", "malformed", `"required" must be an array of property-name strings`);
233
+ }
234
+ }
235
+ if (schema.properties !== undefined) {
236
+ if (!isPlainObject(schema.properties)) {
237
+ pushIssue(issues, [...path, "properties"], "properties", "malformed", `"properties" must be an object mapping property names to schemas`);
238
+ }
239
+ else {
240
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
241
+ if (isPlainObject(propSchema)) {
242
+ checkDefinitionNode(propSchema, [...path, "properties", key], issues, depth + 1);
243
+ }
244
+ else {
245
+ pushIssue(issues, [...path, "properties", key], "properties", "malformed", `property ${JSON.stringify(key)} must be a schema object`);
246
+ }
247
+ }
248
+ }
249
+ }
250
+ if (schema.items !== undefined) {
251
+ if (isPlainObject(schema.items)) {
252
+ checkDefinitionNode(schema.items, [...path, "items"], issues, depth + 1);
253
+ }
254
+ else if (Array.isArray(schema.items)) {
255
+ pushIssue(issues, [...path, "items"], "items", "unsupported", `tuple-form "items" (an array of schemas) is not enforced by the workflow schema subset — use a single schema object`);
256
+ }
257
+ else {
258
+ pushIssue(issues, [...path, "items"], "items", "malformed", `"items" must be a schema object`);
259
+ }
260
+ }
261
+ if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") {
262
+ if (isPlainObject(schema.additionalProperties)) {
263
+ pushIssue(issues, [...path, "additionalProperties"], "additionalProperties", "unsupported", `schema-form "additionalProperties" is not enforced by the workflow schema subset — only "additionalProperties: false" is`);
264
+ }
265
+ else {
266
+ pushIssue(issues, [...path, "additionalProperties"], "additionalProperties", "malformed", `"additionalProperties" must be a boolean (only "false" is enforced)`);
267
+ }
268
+ }
269
+ for (const keyword of ["minItems", "maxItems", "minLength", "maxLength"]) {
270
+ const value = schema[keyword];
271
+ if (value !== undefined && (typeof value !== "number" || !Number.isInteger(value) || value < 0)) {
272
+ pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a non-negative integer`);
273
+ }
274
+ }
275
+ for (const keyword of ["minimum", "maximum"]) {
276
+ const value = schema[keyword];
277
+ if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value))) {
278
+ pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a finite number`);
279
+ }
280
+ }
281
+ }
33
282
  function typeOf(value) {
34
283
  if (value === null)
35
284
  return "null";
@@ -52,7 +301,66 @@ function matchesType(actual, expected) {
52
301
  // JSON Schema: every integer is also a number.
53
302
  return expected === "number" && actual === "integer";
54
303
  }
55
- function validateNode(value, schema, path, errors) {
304
+ /** Evaluate `schema` against `value` in a scratch error list, sharing the caller's budget. */
305
+ function branchErrors(value, schema, path, ctx) {
306
+ const errors = [];
307
+ validateNode(value, schema, path, { ...ctx, errors, depth: ctx.depth + 1 });
308
+ return errors;
309
+ }
310
+ /** The schemas of a combinator keyword, or `[]` when the keyword is absent/malformed (permissive). */
311
+ function combinatorBranches(schema, keyword) {
312
+ const raw = schema[keyword];
313
+ if (!Array.isArray(raw))
314
+ return [];
315
+ return raw.filter(isPlainObject);
316
+ }
317
+ /** First error of each failing branch, truncated — enough to act on without dumping every branch. */
318
+ function summarizeBranchFailures(failures) {
319
+ const shown = failures.slice(0, 3).map((f) => `${f.index + 1}: ${f.errors[0] ?? "no match"}`);
320
+ if (failures.length > shown.length)
321
+ shown.push(`…${failures.length - shown.length} more`);
322
+ return shown.join("; ");
323
+ }
324
+ function validateCombinators(value, schema, path, ctx) {
325
+ for (const branch of combinatorBranches(schema, "allOf")) {
326
+ // `allOf` failures ARE the value's failures — surface them verbatim.
327
+ ctx.errors.push(...branchErrors(value, branch, path, ctx));
328
+ }
329
+ for (const keyword of ["anyOf", "oneOf"]) {
330
+ const branches = combinatorBranches(schema, keyword);
331
+ if (branches.length === 0)
332
+ continue;
333
+ const failures = [];
334
+ const matched = [];
335
+ branches.forEach((branch, index) => {
336
+ const errors = branchErrors(value, branch, path, ctx);
337
+ if (errors.length === 0)
338
+ matched.push(index + 1);
339
+ else
340
+ failures.push({ index, errors });
341
+ });
342
+ if (matched.length === 0) {
343
+ ctx.errors.push(`${path}: value matches none of the ${branches.length} "${keyword}" schemas (${summarizeBranchFailures(failures)})`);
344
+ }
345
+ else if (keyword === "oneOf" && matched.length > 1) {
346
+ ctx.errors.push(`${path}: value matches ${matched.length} "oneOf" schemas (branches ${matched.join(", ")}); exactly one must match`);
347
+ }
348
+ }
349
+ const not = schema.not;
350
+ if (isPlainObject(not) && branchErrors(value, not, path, ctx).length === 0) {
351
+ ctx.errors.push(`${path}: value must not match the "not" schema`);
352
+ }
353
+ }
354
+ function validateNode(value, schema, path, ctx) {
355
+ const errors = ctx.errors;
356
+ if (ctx.depth > MAX_DEFINITION_DEPTH) {
357
+ errors.push(`${path}: schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`);
358
+ return;
359
+ }
360
+ // Fail CLOSED: a truncated evaluation never returns "valid" — the counter
361
+ // going negative is what the wrapper turns into a top-level error.
362
+ if (--ctx.budget.nodes < 0)
363
+ return;
56
364
  const actual = typeOf(value);
57
365
  const declared = schema.type;
58
366
  if (typeof declared === "string" || Array.isArray(declared)) {
@@ -69,6 +377,10 @@ function validateNode(value, schema, path, errors) {
69
377
  return;
70
378
  }
71
379
  }
380
+ // Combinators are type-agnostic, so they run BEFORE the per-type branches
381
+ // below (each of which returns). A schema with no combinator keyword is
382
+ // untouched by this call.
383
+ validateCombinators(value, schema, path, ctx);
72
384
  if (actual === "string" && typeof value === "string") {
73
385
  if (typeof schema.minLength === "number" && value.length < schema.minLength) {
74
386
  errors.push(`${path}: string shorter than minLength ${schema.minLength}`);
@@ -97,7 +409,10 @@ function validateNode(value, schema, path, errors) {
97
409
  const items = schema.items;
98
410
  if (items && typeof items === "object" && !Array.isArray(items)) {
99
411
  value.forEach((element, index) => {
100
- validateNode(element, items, `${path}[${index}]`, errors);
412
+ validateNode(element, items, `${path}[${index}]`, {
413
+ ...ctx,
414
+ depth: ctx.depth + 1,
415
+ });
101
416
  });
102
417
  }
103
418
  return;
@@ -122,7 +437,10 @@ function validateNode(value, schema, path, errors) {
122
437
  if (!Object.hasOwn(record, key))
123
438
  continue;
124
439
  if (propSchema && typeof propSchema === "object" && !Array.isArray(propSchema)) {
125
- validateNode(record[key], propSchema, `${path}.${key}`, errors);
440
+ validateNode(record[key], propSchema, `${path}.${key}`, {
441
+ ...ctx,
442
+ depth: ctx.depth + 1,
443
+ });
126
444
  }
127
445
  }
128
446
  }
@@ -0,0 +1,89 @@
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
+ * Loopback classification for LLM endpoints: does this endpoint name a model
6
+ * server running on THIS machine? A host is loopback when it is `localhost` or
7
+ * a `*.localhost` name (RFC 6761 §6.3), anywhere in `127.0.0.0/8`, an
8
+ * unspecified address (`0.0.0.0` / `::` — a client connecting there reaches
9
+ * local loopback), IPv6 `::1`, or the dotted IPv4-mapped spelling of a
10
+ * `127.0.0.0/8` address (`::ffff:127.0.0.1`). Address shapes are validated by
11
+ * `node:net`'s `isIP`, so a near-miss NAME like `127.0.0.1.evil.com` is remote.
12
+ *
13
+ * The check is purely syntactic — no DNS, no interface list — so the same
14
+ * config classifies the same on a laptop, on CI, and on a machine with no
15
+ * network. Ambiguity resolves toward LOOPBACK: misclassifying a local server
16
+ * as remote widens a concurrency pool onto a single-threaded local model
17
+ * (one loaded model, reload thrash, HTTP 500 — a hard failure); the reverse
18
+ * only costs throughput. The remedy in either direction is one line of config:
19
+ * `engines.<name>.concurrency`.
20
+ *
21
+ * The ONE local-vs-remote answer for concurrency defaults — the workflow
22
+ * engine's frozen engine concurrency (`workflows/concurrency-policy.ts`) and
23
+ * the indexer's LLM pool (`indexer/indexer.ts` `getDefaultLlmConcurrency`)
24
+ * both classify through here. NOT shared with the website snapshot fetcher's
25
+ * SSRF policy (`sources/snapshot-fetchers/host-guard.ts`), which deliberately
26
+ * draws different lines for a different threat model.
27
+ *
28
+ * @module core/loopback
29
+ */
30
+ import { isIP } from "node:net";
31
+ /**
32
+ * The IPv4-mapped IPv6 spellings of `127.0.0.0/8`: the dotted form, whose
33
+ * capture is validated by `isIP`, and the hex form `::ffff:7fxx:xxxx`. Both are
34
+ * needed because WHATWG `URL` re-serializes `[::ffff:127.0.0.1]` to the hex
35
+ * form, so an endpoint written the dotted way arrives here as hex.
36
+ */
37
+ const IPV4_MAPPED_IPV6 = /^::ffff:(?:([\d.]+)|7f[\da-f]{2}:[\da-f]{1,4})$/;
38
+ /** IPv6 groups before the last one, all of which must be zero (or elided). */
39
+ const ZERO_GROUP = /^0+$/;
40
+ /** The last IPv6 group of `::` / `::1`, in any zero-padding. */
41
+ const LOOPBACK_LAST_GROUP = /^0*[01]?$/;
42
+ /**
43
+ * True when `host` — a URL host component, with or without IPv6 brackets —
44
+ * names this machine WITHOUT resolving anything.
45
+ *
46
+ * Not recognized: the IPv4-compatible form (`::127.0.0.1`), and any NAME that
47
+ * merely resolves to loopback.
48
+ *
49
+ * Exported for the boundary-case table in
50
+ * `tests/workflows/concurrency-defaults.test.ts`, which has to reach the host
51
+ * predicate directly: several of its cases (a bare `""`, a malformed literal
52
+ * like `1::2::3`) cannot round-trip through a URL without changing meaning.
53
+ */
54
+ export function isLoopbackHost(host) {
55
+ // Strip IPv6 brackets (`URL.hostname` keeps them) and the DNS root label. An
56
+ // empty host (`new URL("localhost:1234")` parses as scheme `localhost:` with
57
+ // no host) has nothing to judge, so it fails safe like an unparseable one.
58
+ const name = host.trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
59
+ if (name === "" || name === "localhost" || name.endsWith(".localhost"))
60
+ return true;
61
+ const mapped = IPV4_MAPPED_IPV6.exec(name);
62
+ if (mapped) {
63
+ const quad = mapped[1];
64
+ // The hex branch already matched 127.x in the pattern; the dotted one still
65
+ // needs `isIP` so `::ffff:127.0.0.256` stays remote.
66
+ return quad === undefined || (isIP(quad) === 4 && quad.startsWith("127."));
67
+ }
68
+ const version = isIP(name);
69
+ if (version === 4)
70
+ return name.startsWith("127.") || name === "0.0.0.0";
71
+ if (version !== 6)
72
+ return false;
73
+ const groups = name.split(":");
74
+ const last = groups.pop() ?? "";
75
+ return groups.every((group) => group === "" || ZERO_GROUP.test(group)) && LOOPBACK_LAST_GROUP.test(last);
76
+ }
77
+ /** True when `endpoint` points at this machine (see {@link isLoopbackHost}). */
78
+ export function isLoopbackEndpoint(endpoint) {
79
+ if (!endpoint)
80
+ return true;
81
+ try {
82
+ return isLoopbackHost(new URL(endpoint).hostname);
83
+ }
84
+ catch {
85
+ // An unparseable endpoint is treated as local: guessing "remote" here would
86
+ // widen the pool on exactly the configs we understand least.
87
+ return true;
88
+ }
89
+ }
@@ -2,9 +2,9 @@
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 crypto from "node:crypto";
5
- import fs from "node:fs";
6
5
  import path from "node:path";
7
6
  import { ConfigError } from "./errors.js";
7
+ import { classifyPathAccess, describeInaccessiblePath } from "./path-access.js";
8
8
  import { getConfigPath, getDataDir } from "./paths.js";
9
9
  let afterPendingCheckHook;
10
10
  /** TEST-ONLY: run once after a clear pending-operation check. */
@@ -45,12 +45,27 @@ export function getMigrationApplyJournalPath() {
45
45
  export function getMigrationGeneratedConfigPath() {
46
46
  return path.join(getMigrationOperationRoot(), "generated-config.json");
47
47
  }
48
+ /**
49
+ * Refuse canonical config/database access while a migration or restore is
50
+ * mid-flight.
51
+ *
52
+ * This is a SAFETY gate, so "I could not tell" must fail the same way "yes"
53
+ * does. `fs.existsSync` answered `false` for an unreadable journal exactly as
54
+ * for an absent one (#791), which meant a permission fault on the journal
55
+ * silently CLEARED the gate and let akm open the canonical databases on top of
56
+ * a half-applied migration. Absence is the only answer that may open the door.
57
+ */
48
58
  export function assertNoPendingMigrationOperation() {
49
59
  for (const [kind, journalPath] of [
50
60
  ["restore", getMigrationRestoreJournalPath()],
51
61
  ["migration apply", getMigrationApplyJournalPath()],
52
62
  ]) {
53
- if (fs.existsSync(journalPath)) {
63
+ const { access, code } = classifyPathAccess(journalPath);
64
+ if (access === "inaccessible") {
65
+ throw new ConfigError(`Cannot determine whether an AKM ${kind} recovery is pending: ${describeInaccessiblePath(journalPath, code)}. ` +
66
+ "Refusing canonical config/database access — proceeding could write on top of a half-applied migration.", "DATA_DIR_UNREADABLE");
67
+ }
68
+ if (access === "present") {
54
69
  throw new ConfigError(`AKM ${kind} recovery is pending at ${journalPath}; refusing canonical config/database access until recovery completes.`, "INVALID_CONFIG_FILE");
55
70
  }
56
71
  }