akm-cli 0.9.11 → 0.9.13
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 +227 -0
- package/STABILITY.md +6 -1
- package/dist/assets/hints/cli-hints-full.md +1 -1
- package/dist/assets/improve-strategies/consolidate.json +1 -1
- package/dist/assets/improve-strategies/default.json +1 -1
- package/dist/assets/improve-strategies/thorough.json +1 -2
- package/dist/assets/workflows/workflow-template.md +4 -0
- package/dist/cli/shared.js +16 -4
- package/dist/cli.js +15 -13
- package/dist/commands/agent/agent-dispatch.js +8 -0
- package/dist/commands/command/execution-source-loader.js +25 -22
- package/dist/commands/command/portable-template.js +4 -26
- package/dist/commands/config-cli.js +10 -4
- package/dist/commands/env/env-binding.js +10 -3
- package/dist/commands/env/env-cli.js +7 -0
- package/dist/commands/env/secret-cli.js +15 -4
- package/dist/commands/health/checks.js +186 -71
- package/dist/commands/health.js +16 -4
- package/dist/commands/improve/distill/quality-gate.js +2 -2
- package/dist/commands/improve/distill.js +28 -12
- package/dist/commands/improve/execution.js +1 -2
- package/dist/commands/improve/extract.js +82 -56
- package/dist/commands/improve/improve-strategies.js +26 -8
- package/dist/commands/improve/improve.js +14 -0
- package/dist/commands/improve/preparation.js +9 -6
- package/dist/commands/improve/reflect.js +61 -77
- package/dist/commands/lint/base-linter.js +10 -0
- package/dist/commands/lint/index.js +3 -1
- package/dist/commands/migrate-cli.js +6 -4
- package/dist/commands/proposal/drain-policies.js +22 -2
- package/dist/commands/proposal/drain.js +48 -6
- package/dist/commands/proposal/proposal-cli.js +1 -0
- package/dist/commands/proposal/repository.js +4 -4
- package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
- package/dist/commands/proposal/validators/proposals.js +10 -19
- package/dist/commands/read/show.js +42 -31
- package/dist/commands/registry-cli.js +4 -2
- package/dist/commands/sources/init.js +4 -8
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/source-clone.js +5 -7
- package/dist/commands/sources/sources-cli.js +3 -5
- package/dist/commands/tasks/tasks-cli.js +4 -12
- package/dist/commands/tasks/tasks.js +38 -35
- package/dist/commands/workflow-cli.js +17 -15
- package/dist/core/activation-policy.js +31 -3
- package/dist/core/adapter/execution-source.js +39 -11
- package/dist/core/asset/stash-meta.js +7 -41
- package/dist/core/common.js +8 -17
- package/dist/core/config/config-schema.js +3 -23
- package/dist/core/config/config-walker.js +56 -6
- package/dist/core/config/config.js +42 -17
- package/dist/core/config/legacy-source-shape-shim.js +79 -0
- package/dist/core/config/schema/embedding.js +2 -2
- package/dist/core/config/schema/engines.js +2 -2
- package/dist/core/config/schema/index-config.js +19 -21
- package/dist/core/config/schema/primitives.js +27 -10
- package/dist/core/config/schema/sources-bundles.js +1 -6
- package/dist/core/errors.js +4 -3
- package/dist/core/improve-types.js +17 -0
- package/dist/core/json-schema.js +1 -11
- package/dist/core/maintenance-barrier.js +17 -2
- package/dist/core/paths.js +12 -15
- package/dist/core/state/migrations.js +28 -0
- package/dist/core/state-db.js +28 -1
- package/dist/core/write-source.js +6 -6
- package/dist/indexer/bundle-identity-guard.js +3 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/indexer.js +11 -3
- package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
- package/dist/indexer/passes/metadata.js +16 -5
- package/dist/indexer/search/search-fields.js +1 -30
- package/dist/integrations/agent/engine-resolution.js +15 -1
- package/dist/integrations/agent/model-map.js +16 -10
- package/dist/integrations/agent/prompts.js +13 -6
- package/dist/integrations/lockfile.js +22 -7
- package/dist/llm/client.js +28 -8
- package/dist/llm/embedders/remote.js +3 -2
- package/dist/llm/index-passes.js +3 -2
- package/dist/output/shapes/passthrough.js +9 -3
- package/dist/output/shapes.js +50 -3
- package/dist/output/text/proposal-format.js +5 -0
- package/dist/output/text/workflow-format.js +8 -1
- package/dist/scripts/akm-migrate-node.js +1737 -1392
- package/dist/scripts/akm-migrate.js +1736 -1391
- package/dist/setup/setup.js +14 -21
- package/dist/sources/include.js +150 -20
- package/dist/sources/providers/git-install.js +14 -12
- package/dist/sources/providers/git-provider.js +3 -3
- package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
- package/dist/sources/website-url.js +12 -4
- package/dist/storage/engines/sqlite-migrations.js +40 -10
- package/dist/storage/like-pattern.js +7 -0
- package/dist/storage/repositories/extract-sessions-repository.js +23 -0
- package/dist/storage/repositories/index-connection.js +27 -10
- package/dist/storage/repositories/index-entry-schema.js +19 -2
- package/dist/storage/repositories/index-schema.js +30 -9
- package/dist/storage/repositories/proposals-repository.js +2 -1
- package/dist/storage/repositories/task-history-repository.js +14 -7
- package/dist/storage/repositories/workflow-runs-repository.js +133 -11
- package/dist/storage/sqlite-read-snapshot.js +11 -9
- package/dist/tasks/backends/cron.js +34 -5
- package/dist/tasks/backends/launchd.js +23 -26
- package/dist/tasks/backends/schtasks.js +50 -3
- package/dist/tasks/frozen-script.js +2 -0
- package/dist/tasks/prepare/prepare.js +2 -7
- package/dist/tasks/prepare/script-capture.js +38 -6
- package/dist/tasks/schedule.js +154 -13
- package/dist/tasks/source/task-source-v3-frozen.js +0 -1
- package/dist/tasks/source/task-source-v4.js +0 -1
- package/dist/workflows/exec/child-workflow.js +2 -3
- package/dist/workflows/exec/exec-unit.js +3 -4
- package/dist/workflows/exec/run-workflow.js +20 -11
- package/dist/workflows/exec/step-work.js +76 -56
- package/dist/workflows/freeze/resolve-steps.js +19 -11
- package/dist/workflows/freeze/source-freeze.js +7 -0
- package/dist/workflows/freeze/targets/child-workflow.js +12 -18
- package/dist/workflows/freeze/targets/command.js +14 -2
- package/dist/workflows/ir/environment-v4.js +4 -2
- package/dist/workflows/ir/freeze-v4.js +2 -5
- package/dist/workflows/ir/plan-hash.js +0 -3
- package/dist/workflows/ir/schema-v4.js +14 -9
- package/dist/workflows/ir/schema.js +1 -3
- package/dist/workflows/parser.js +1 -1
- package/dist/workflows/resource-limits.js +35 -48
- package/dist/workflows/runtime/plan-classifier.js +89 -41
- package/dist/workflows/runtime/run-outputs.js +1 -21
- package/dist/workflows/runtime/runs.js +104 -154
- package/dist/workflows/source-files.js +28 -54
- package/dist/workflows/source-ir/program.js +2 -2
- package/dist/workflows/source-ir/semantics.js +5 -23
- package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
- package/docs/reference/cli.md +92 -17
- package/package.json +1 -1
- package/schemas/akm-config.json +5 -10
package/dist/setup/setup.js
CHANGED
|
@@ -27,7 +27,7 @@ import { listTopLevelConfigKeys } from "../core/config/config-schema.js";
|
|
|
27
27
|
import { deepMergeConfig, isPlainObject } from "../core/config/deep-merge.js";
|
|
28
28
|
import { ConfigError, UsageError } from "../core/errors.js";
|
|
29
29
|
import { getConfigPath, getDefaultStashDir, isTransientStashPath } from "../core/paths.js";
|
|
30
|
-
import { warn } from "../core/warn.js";
|
|
30
|
+
import { warn, warnOnce } from "../core/warn.js";
|
|
31
31
|
import { akmIndex } from "../indexer/indexer.js";
|
|
32
32
|
import { detectAgentCliProfiles, pickDefaultAgentProfile } from "../integrations/agent/index.js";
|
|
33
33
|
import { defaultProfileName } from "../integrations/harnesses/index.js";
|
|
@@ -51,32 +51,25 @@ import { stepStashDir } from "./steps/stashdir.js";
|
|
|
51
51
|
import { stepScheduledTasks } from "./steps/tasks.js";
|
|
52
52
|
// ── Setup sandbox guard ─────────────────────────────────────────────────────
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* escape hatch is on, `applyStashIsolationToEnv` below also pre-sets
|
|
65
|
-
* `AKM_BUNDLE_DIR` so that the `getConfigDir` / `getCacheDir` isolation
|
|
66
|
-
* rules fire and config + cache writes route into `$stashDir/.akm/`
|
|
67
|
-
* instead of the user's host `~/.config/akm`.
|
|
54
|
+
* Warn when an explicit `--dir /tmp/...` is persisted as the default bundle
|
|
55
|
+
* path — `akm setup --dir $(mktemp -d)` resolves under this family on every
|
|
56
|
+
* macOS machine (`/var/folders/...`), and used to be refused outright with
|
|
57
|
+
* no discoverable remedy. The OS may reap the directory at any time, so the
|
|
58
|
+
* operator is told up front; `applyStashIsolationToEnv` below still pre-sets
|
|
59
|
+
* `AKM_BUNDLE_DIR` so the `getConfigDir` / `getCacheDir` isolation rules
|
|
60
|
+
* (`core/paths.ts`) route config + cache writes into `$stashDir/.akm/`
|
|
61
|
+
* instead of the user's host `~/.config/akm`, which is what actually
|
|
62
|
+
* protects the host config (the 2026-05-23 setup-clobbers-user-config
|
|
63
|
+
* incident this guard traces to).
|
|
68
64
|
*/
|
|
69
65
|
export function assertSetupSandbox(stashDir, dirExplicitlyProvided) {
|
|
70
66
|
if (!dirExplicitlyProvided)
|
|
71
67
|
return;
|
|
72
|
-
if (process.env.AKM_FORCE_SETUP_TMP_STASH === "1")
|
|
73
|
-
return;
|
|
74
68
|
if (!isTransientStashPath(stashDir))
|
|
75
69
|
return;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"(setup will also auto-isolate config + cache writes into $stashDir/.akm/ so the host config is preserved).", "SETUP_TMP_STASH_REFUSED");
|
|
70
|
+
warnOnce(`setup-tmp-stash:${stashDir}`, `\`akm setup --dir ${stashDir}\` targets a transient/sandbox directory the OS may reap at any time; ` +
|
|
71
|
+
`the next run would then point at a deleted bundle. Config and cache writes are isolated into ` +
|
|
72
|
+
`${stashDir}/.akm/ so the host config is unaffected, but treat this stash as disposable.`);
|
|
80
73
|
}
|
|
81
74
|
/**
|
|
82
75
|
* Propagate the explicit `--dir <stashDir>` choice to the env so that the
|
package/dist/sources/include.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { isWithin } from "../core/common.js";
|
|
7
|
+
import { warnOnce } from "../core/warn.js";
|
|
7
8
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
8
9
|
/** Key to check in package.json for akm include configuration. */
|
|
9
10
|
const INCLUDE_CONFIG_KEYS = ["akm"];
|
|
@@ -64,51 +65,180 @@ export function findNearestIncludeConfig(startDir, boundary) {
|
|
|
64
65
|
}
|
|
65
66
|
return undefined;
|
|
66
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Does `entry` use glob syntax (`*`/`?`)? Everything else is a literal path,
|
|
70
|
+
* resolved and copied exactly as before — this only changes behavior for
|
|
71
|
+
* entries that actually contain a wildcard.
|
|
72
|
+
*/
|
|
73
|
+
function isIncludeEntryPattern(entry) {
|
|
74
|
+
return /[*?]/.test(entry);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Translate one `akm.include` entry to a regular expression matched against a
|
|
78
|
+
* `/`-joined path relative to `sourceDir`. Supports the subset package.json
|
|
79
|
+
* authors reach for in practice: `*` (any run of characters within one path
|
|
80
|
+
* segment), `?` (one character), and `**` (any run of characters across
|
|
81
|
+
* segments, including none) — e.g. `skills/*` or `skills/**\/*.md`. Not a
|
|
82
|
+
* full glob implementation (no brace expansion, character classes, or
|
|
83
|
+
* negation); those fall through to being treated as a literal, almost
|
|
84
|
+
* certainly non-matching path, same as an ordinary typo would.
|
|
85
|
+
*/
|
|
86
|
+
function includeEntryToRegExp(entry) {
|
|
87
|
+
const normalized = entry.split(path.sep).join("/");
|
|
88
|
+
let pattern = "";
|
|
89
|
+
for (let i = 0; i < normalized.length;) {
|
|
90
|
+
if (normalized.startsWith("**", i)) {
|
|
91
|
+
const consumesSlash = normalized[i + 2] === "/";
|
|
92
|
+
pattern += consumesSlash ? "(?:.*/)?" : ".*";
|
|
93
|
+
i += consumesSlash ? 3 : 2;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const char = normalized[i];
|
|
97
|
+
if (char === "*")
|
|
98
|
+
pattern += "[^/]*";
|
|
99
|
+
else if (char === "?")
|
|
100
|
+
pattern += "[^/]";
|
|
101
|
+
else
|
|
102
|
+
pattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
103
|
+
i += 1;
|
|
104
|
+
}
|
|
105
|
+
return new RegExp(`^${pattern}$`);
|
|
106
|
+
}
|
|
107
|
+
/** A symlink whose lstat succeeds but whose target does not exist — `copyPath` reports this case with its own specific warning. */
|
|
108
|
+
function isBrokenSymlink(candidate) {
|
|
109
|
+
try {
|
|
110
|
+
return fs.lstatSync(candidate).isSymbolicLink();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Every path under `dir`, `/`-joined and relative to `base`, without following symlinked directories. */
|
|
117
|
+
function listAllRelativePaths(dir, base) {
|
|
118
|
+
const out = [];
|
|
119
|
+
let entries;
|
|
120
|
+
try {
|
|
121
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (entry.name === ".git")
|
|
128
|
+
continue;
|
|
129
|
+
const absolute = path.join(dir, entry.name);
|
|
130
|
+
out.push(path.relative(base, absolute).split(path.sep).join("/"));
|
|
131
|
+
// `isDirectory()` on a `Dirent` reflects lstat, so a symlinked directory
|
|
132
|
+
// is listed as an entry (and handled by copyPath's own symlink handling)
|
|
133
|
+
// but never recursed into here.
|
|
134
|
+
if (entry.isDirectory())
|
|
135
|
+
out.push(...listAllRelativePaths(absolute, base));
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Resolve one `akm.include` entry to the absolute candidate paths it names.
|
|
141
|
+
* A literal entry resolves to itself, whether or not it exists on disk — the
|
|
142
|
+
* caller checks that (after re-verifying containment, which needs to see
|
|
143
|
+
* the literal candidate regardless). A pattern entry expands to every
|
|
144
|
+
* existing matching path under `sourceDir`, so matching nothing already
|
|
145
|
+
* naturally comes back as an empty array.
|
|
146
|
+
*/
|
|
147
|
+
function resolveIncludeEntry(entry, sourceDir) {
|
|
148
|
+
if (!isIncludeEntryPattern(entry))
|
|
149
|
+
return [path.resolve(sourceDir, entry)];
|
|
150
|
+
const pattern = includeEntryToRegExp(entry);
|
|
151
|
+
return listAllRelativePaths(sourceDir, sourceDir)
|
|
152
|
+
.filter((relative) => pattern.test(relative))
|
|
153
|
+
.map((relative) => path.join(sourceDir, relative));
|
|
154
|
+
}
|
|
67
155
|
/**
|
|
68
156
|
* Copy each glob/path in `includeGlobs` from `sourceDir` to `destDir`.
|
|
69
157
|
*
|
|
70
158
|
* Uses `isWithin()` to prevent path-traversal attacks: any entry that escapes
|
|
71
|
-
* `sourceDir` throws immediately
|
|
159
|
+
* `sourceDir` throws immediately — this is the operator's own config
|
|
160
|
+
* (`akm.include` in a package.json under a source they added), not
|
|
161
|
+
* discovered content, so a deliberate `../` here is treated as the mistake
|
|
162
|
+
* (or attack) it looks like, not skipped.
|
|
72
163
|
*
|
|
73
|
-
*
|
|
164
|
+
* A missing entry, or a pattern that matches nothing, is skipped with a
|
|
165
|
+
* warning instead of failing the whole install — one bad entry in a large
|
|
166
|
+
* `include` list should not leave zero assets installed.
|
|
74
167
|
*/
|
|
75
168
|
export function copyIncludedPaths(includeGlobs, sourceDir, destDir) {
|
|
76
169
|
for (const entry of includeGlobs) {
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
if (!fs.existsSync(resolvedSource)) {
|
|
82
|
-
throw new Error(`Path in akm.include does not exist: ${entry}`);
|
|
83
|
-
}
|
|
84
|
-
if (path.basename(resolvedSource) === ".git") {
|
|
170
|
+
const resolvedPaths = resolveIncludeEntry(entry, sourceDir);
|
|
171
|
+
if (resolvedPaths.length === 0) {
|
|
172
|
+
warnOnce(`akm-include-missing:${sourceDir}:${entry}`, `[akm] Skipping "${entry}" in akm.include: no matching file or directory.`);
|
|
85
173
|
continue;
|
|
86
174
|
}
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
175
|
+
for (const resolvedSource of resolvedPaths) {
|
|
176
|
+
if (!isWithin(resolvedSource, sourceDir)) {
|
|
177
|
+
throw new Error(`Path in akm.include escapes the package root: ${entry}`);
|
|
178
|
+
}
|
|
179
|
+
if (!fs.existsSync(resolvedSource) && !isBrokenSymlink(resolvedSource)) {
|
|
180
|
+
warnOnce(`akm-include-missing:${sourceDir}:${entry}`, `[akm] Skipping "${entry}" in akm.include: no matching file or directory.`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (path.basename(resolvedSource) === ".git")
|
|
184
|
+
continue;
|
|
185
|
+
const relativePath = path.relative(sourceDir, resolvedSource);
|
|
186
|
+
if (!relativePath || relativePath === ".") {
|
|
187
|
+
copyDirectoryContents(sourceDir, destDir, sourceDir);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
copyPath(resolvedSource, path.join(destDir, relativePath), sourceDir);
|
|
91
191
|
}
|
|
92
|
-
copyPath(resolvedSource, path.join(destDir, relativePath));
|
|
93
192
|
}
|
|
94
193
|
}
|
|
95
194
|
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
96
|
-
function copyDirectoryContents(sourceDir, destinationDir) {
|
|
195
|
+
function copyDirectoryContents(sourceDir, destinationDir, containmentRoot) {
|
|
97
196
|
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
98
197
|
if (entry.name === ".git")
|
|
99
198
|
continue;
|
|
100
|
-
copyPath(path.join(sourceDir, entry.name), path.join(destinationDir, entry.name));
|
|
199
|
+
copyPath(path.join(sourceDir, entry.name), path.join(destinationDir, entry.name), containmentRoot);
|
|
101
200
|
}
|
|
102
201
|
}
|
|
103
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Copy one path. A symlink is followed and its TARGET's content copied
|
|
204
|
+
* (never re-materialized as a symlink) rather than refused outright — a
|
|
205
|
+
* published package with, say, `README.md -> docs/README.md` is ordinary,
|
|
206
|
+
* not an attack. The resolved target must still stay within
|
|
207
|
+
* `containmentRoot`: this is the same containment guarantee `isWithin`
|
|
208
|
+
* enforces on every literal `akm.include` entry, just applied to a target we
|
|
209
|
+
* discovered by following a link rather than one the operator typed — a
|
|
210
|
+
* symlink escaping the package (to `/etc/passwd`, say) is skipped with a
|
|
211
|
+
* warning, not copied.
|
|
212
|
+
*/
|
|
213
|
+
function copyPath(sourcePath, destinationPath, containmentRoot) {
|
|
104
214
|
const stat = fs.lstatSync(sourcePath);
|
|
105
215
|
if (stat.isSymbolicLink()) {
|
|
106
|
-
|
|
216
|
+
let resolved;
|
|
217
|
+
try {
|
|
218
|
+
resolved = fs.realpathSync(sourcePath);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
warnOnce(`akm-include-broken-symlink:${sourcePath}`, `[akm] Skipping broken symlink in akm.include: ${path.relative(containmentRoot, sourcePath)}`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (!isWithin(resolved, containmentRoot)) {
|
|
225
|
+
warnOnce(`akm-include-symlink-escape:${sourcePath}`, `[akm] Skipping symlink in akm.include that points outside the package: ${path.relative(containmentRoot, sourcePath)}`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const resolvedStat = fs.statSync(resolved);
|
|
229
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
230
|
+
if (resolvedStat.isDirectory()) {
|
|
231
|
+
fs.mkdirSync(destinationPath, { recursive: true });
|
|
232
|
+
copyDirectoryContents(resolved, destinationPath, containmentRoot);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
fs.copyFileSync(resolved, destinationPath);
|
|
236
|
+
return;
|
|
107
237
|
}
|
|
108
238
|
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
109
239
|
if (stat.isDirectory()) {
|
|
110
240
|
fs.mkdirSync(destinationPath, { recursive: true });
|
|
111
|
-
copyDirectoryContents(sourcePath, destinationPath);
|
|
241
|
+
copyDirectoryContents(sourcePath, destinationPath, containmentRoot);
|
|
112
242
|
return;
|
|
113
243
|
}
|
|
114
244
|
fs.copyFileSync(sourcePath, destinationPath);
|
|
@@ -350,25 +350,27 @@ export function syncExistingWritableCheckout(parsed, resolved, contentRoot, sync
|
|
|
350
350
|
targetRevision = fallbackTarget.stdout.trim();
|
|
351
351
|
}
|
|
352
352
|
const relation = gitRelation(repoDir, targetRevision);
|
|
353
|
-
if (relation.ahead > 0) {
|
|
354
|
-
throw new UsageError(`Writable Git install at ${root} has local commits that are not in the requested upstream revision; push or reconcile them before update.`);
|
|
355
|
-
}
|
|
356
|
-
assertNoIgnoredPathOverwrite(repoDir, targetRevision);
|
|
357
353
|
const rootsToPreserve = [...new Set([root, ...requiredRoots.map((candidate) => path.resolve(candidate))])];
|
|
358
354
|
for (const requiredRoot of rootsToPreserve) {
|
|
359
355
|
if (!isWithin(requiredRoot, repoDir)) {
|
|
360
356
|
throw new UsageError(`Configured Git component root ${requiredRoot} resolves outside ${repoDir}.`);
|
|
361
357
|
}
|
|
362
|
-
const relative = path.relative(repoDir, requiredRoot).replaceAll(path.sep, "/");
|
|
363
|
-
if (!relative)
|
|
364
|
-
continue;
|
|
365
|
-
const tree = runGit(["-C", repoDir, "ls-tree", "-d", "-z", "--name-only", targetRevision, "--", relative]);
|
|
366
|
-
const names = tree.stdout.split("\0").filter(Boolean);
|
|
367
|
-
if (tree.status !== 0 || !names.includes(relative)) {
|
|
368
|
-
throw new UsageError(`Writable Git update would remove configured content root ${requiredRoot}; the existing checkout was left unchanged.`);
|
|
369
|
-
}
|
|
370
358
|
}
|
|
371
359
|
if (relation.behind > 0) {
|
|
360
|
+
if (relation.ahead > 0) {
|
|
361
|
+
throw new UsageError(`Writable Git install at ${root} has local commits that are not in the requested upstream revision; push or reconcile them before update.`);
|
|
362
|
+
}
|
|
363
|
+
assertNoIgnoredPathOverwrite(repoDir, targetRevision);
|
|
364
|
+
for (const requiredRoot of rootsToPreserve) {
|
|
365
|
+
const relative = path.relative(repoDir, requiredRoot).replaceAll(path.sep, "/");
|
|
366
|
+
if (!relative)
|
|
367
|
+
continue;
|
|
368
|
+
const tree = runGit(["-C", repoDir, "ls-tree", "-d", "-z", "--name-only", targetRevision, "--", relative]);
|
|
369
|
+
const names = tree.stdout.split("\0").filter(Boolean);
|
|
370
|
+
if (tree.status !== 0 || !names.includes(relative)) {
|
|
371
|
+
throw new UsageError(`Writable Git update would remove configured content root ${requiredRoot}; the existing checkout was left unchanged.`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
372
374
|
const statusBeforeMerge = runGit(["-C", repoDir, "status", "--porcelain"]);
|
|
373
375
|
if (statusBeforeMerge.status !== 0 || statusBeforeMerge.stdout.trim()) {
|
|
374
376
|
throw new UsageError(`Writable Git install at ${root} changed while its update was prepared; local work was preserved.`);
|
|
@@ -127,10 +127,10 @@ function pullRepo(repoDir) {
|
|
|
127
127
|
throw new UsageError(`Writable Git source at ${repoDir} has uncommitted changes; refusing to update it.`);
|
|
128
128
|
}
|
|
129
129
|
const relation = inspectGitUpstream(repoDir);
|
|
130
|
-
if (relation.ahead > 0) {
|
|
131
|
-
throw new UsageError(`Writable Git source at ${repoDir} has unpushed commits; refusing to update it.`);
|
|
132
|
-
}
|
|
133
130
|
if (relation.behind > 0 && relation.upstream) {
|
|
131
|
+
if (relation.ahead > 0) {
|
|
132
|
+
throw new UsageError(`Writable Git source at ${repoDir} has unpushed commits; refusing to update it.`);
|
|
133
|
+
}
|
|
134
134
|
const statusBeforeMerge = runGit(["-C", repoDir, "status", "--porcelain"]);
|
|
135
135
|
if (statusBeforeMerge.status !== 0 || statusBeforeMerge.stdout.trim()) {
|
|
136
136
|
throw new UsageError(`Writable Git source at ${repoDir} changed while its update was prepared; refusing to merge.`);
|
|
@@ -6,7 +6,7 @@ import fs from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fetchWithRetry, isWithin, ResponseTooLargeError, readBodyWithByteCap, resolveStashDir, todayIso, } from "../../core/common.js";
|
|
8
8
|
import { ConfigError, UsageError } from "../../core/errors.js";
|
|
9
|
-
import { warn, warnVerbose } from "../../core/warn.js";
|
|
9
|
+
import { warn, warnOnce, warnVerbose } from "../../core/warn.js";
|
|
10
10
|
import { withFreshnessCache } from "../freshness.js";
|
|
11
11
|
import { sanitizeString } from "../providers/provider-utils.js";
|
|
12
12
|
import { getWebsiteCachePaths, normalizeSiteUrl, validateWebsiteInputUrl, validateWebsiteUrl, } from "../website-url.js";
|
|
@@ -324,19 +324,20 @@ async function scrapeWebsiteToStash(startUrl, stashDir, options) {
|
|
|
324
324
|
export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
|
|
325
325
|
const normalizedUrl = validateWebsiteInputUrl(rawUrl, { allowPrivateHosts: options?.allowPrivateHosts });
|
|
326
326
|
const parsedUrl = new URL(normalizedUrl);
|
|
327
|
+
const allowPrivateHosts = await resolveAllowPrivateStartHost(parsedUrl, options?.allowPrivateHosts);
|
|
327
328
|
const stashDir = resolveFetcherStashDir(options?.stashDir);
|
|
328
329
|
const context = {
|
|
329
330
|
stashDir: stashDir ?? "",
|
|
330
331
|
timeoutMs: options?.timeoutMs ?? 15_000,
|
|
331
332
|
signal: options?.signal,
|
|
332
333
|
...(options?.resolveSecret ? { resolveSecret: options.resolveSecret } : {}),
|
|
333
|
-
...(
|
|
334
|
+
...(allowPrivateHosts ? { allowPrivateHosts: true } : {}),
|
|
334
335
|
};
|
|
335
336
|
const snapshot = await dispatchSnapshotFetchers(parsedUrl, context, stashDir);
|
|
336
337
|
if (snapshot)
|
|
337
338
|
return websiteMarkdownSnapshotFromResult(snapshot);
|
|
338
339
|
const fetchedResponse = await fetchWebsiteResponse(normalizedUrl, 0, {
|
|
339
|
-
allowPrivateHosts
|
|
340
|
+
allowPrivateHosts,
|
|
340
341
|
signal: options?.signal,
|
|
341
342
|
});
|
|
342
343
|
const finalUrl = normalizeCrawlUrl(fetchedResponse.finalUrl) ?? normalizedUrl;
|
|
@@ -355,7 +356,7 @@ export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
|
|
|
355
356
|
}
|
|
356
357
|
}
|
|
357
358
|
const fetched = await websitePageFromResponse(fetchedResponse, normalizedUrl, {
|
|
358
|
-
allowPrivateHosts
|
|
359
|
+
allowPrivateHosts,
|
|
359
360
|
signal: options?.signal,
|
|
360
361
|
});
|
|
361
362
|
if (!fetched)
|
|
@@ -465,9 +466,12 @@ async function resolveCrawlRobotsDecision(robots, normalizedUrl, rawUrl) {
|
|
|
465
466
|
}
|
|
466
467
|
/**
|
|
467
468
|
* C-02/C-03: fail fast, before any page fetch, when the start URL itself is
|
|
468
|
-
* off-limits
|
|
469
|
-
*
|
|
470
|
-
*
|
|
469
|
+
* off-limits per an actually-parsed robots.txt rule. A 5xx on robots.txt
|
|
470
|
+
* itself no longer reaches this as `disallowAll`: `loadRobotsTxt` reports it the same way a
|
|
471
|
+
* 4xx is reported (`ALLOW_ALL_RULES`), with its own warning, rather than
|
|
472
|
+
* treating the site's own transient error as a Disallow. The check below
|
|
473
|
+
* stays as defense-in-depth for a `RobotsPolicy` built over some other
|
|
474
|
+
* loader that does still report `disallowAll`.
|
|
471
475
|
*
|
|
472
476
|
* Checks both `start`'s (normalized) URL and `rawStartUrl` — the URL exactly
|
|
473
477
|
* as the user supplied it in config, before `validateWebsiteUrl` ->
|
|
@@ -506,9 +510,37 @@ async function assertStartUrlAllowedByRobots(robots, start, rawStartUrl) {
|
|
|
506
510
|
`source to bypass robots.txt.`);
|
|
507
511
|
}
|
|
508
512
|
}
|
|
513
|
+
/**
|
|
514
|
+
* Decide whether to treat a fetch's start host — the URL an operator directly
|
|
515
|
+
* typed or configured (`akm bundle add`, `akm knowledge add`, a crawl's own
|
|
516
|
+
* start URL), never a link discovered while crawling — as private for this
|
|
517
|
+
* operation. `requested` (already true when the URL itself was
|
|
518
|
+
* loopback/private-literal, per `shouldAllowPrivateWebsiteUrlForTests`) wins
|
|
519
|
+
* outright. Otherwise, resolve the host: a name that only turns out to be
|
|
520
|
+
* private via DNS (a corporate wiki behind split-horizon DNS, a VPN hostname)
|
|
521
|
+
* would otherwise abort with no escape hatch even though the operator named
|
|
522
|
+
* this exact host. Callers scope the resulting bypass to same-origin work
|
|
523
|
+
* only — a link discovered mid-crawl is restricted to the start URL's origin
|
|
524
|
+
* before it ever reaches a guard, so this never extends trust to a host the
|
|
525
|
+
* fetched content merely points at.
|
|
526
|
+
*/
|
|
527
|
+
async function resolveAllowPrivateStartHost(start, requested) {
|
|
528
|
+
if (requested)
|
|
529
|
+
return true;
|
|
530
|
+
try {
|
|
531
|
+
await assertResolvedHostAllowed(start.hostname);
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
warnOnce(`website-private-start-host:${start.origin}`, `[akm] ${start.origin} is not a publicly routable host, but you added it as a source directly — proceeding. ` +
|
|
536
|
+
"Links to other hosts found in its content are still checked.");
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
509
540
|
async function crawlWebsite(startUrl, options) {
|
|
510
541
|
const start = new URL(normalizeSiteUrl(startUrl));
|
|
511
542
|
const allowedOrigin = start.origin;
|
|
543
|
+
const allowPrivateHosts = await resolveAllowPrivateStartHost(start, options.allowPrivateHosts);
|
|
512
544
|
const queue = [
|
|
513
545
|
{ url: start.toString(), rawUrl: options.rawStartUrl ?? start.toString(), depth: 0, deferrals: 0 },
|
|
514
546
|
];
|
|
@@ -532,7 +564,7 @@ async function crawlWebsite(startUrl, options) {
|
|
|
532
564
|
const crawlSignal = abortController.signal;
|
|
533
565
|
const robots = options.respectRobots === false
|
|
534
566
|
? createAllowAllRobotsPolicy()
|
|
535
|
-
: createRobotsPolicy((robotsUrl) => loadRobotsTxt(robotsUrl, { allowPrivateHosts
|
|
567
|
+
: createRobotsPolicy((robotsUrl) => loadRobotsTxt(robotsUrl, { allowPrivateHosts, signal: crawlSignal }));
|
|
536
568
|
await assertStartUrlAllowedByRobots(robots, start, options.rawStartUrl);
|
|
537
569
|
// llms.txt fast path: an increasing number of doc sites publish a curated,
|
|
538
570
|
// deduplicated link list at `/llms.txt` specifically for tools like this
|
|
@@ -545,7 +577,7 @@ async function crawlWebsite(startUrl, options) {
|
|
|
545
577
|
// whole site's manifest.
|
|
546
578
|
if (isOriginRootUrl(start)) {
|
|
547
579
|
const manifest = await fetchLlmsManifest(start, robots, {
|
|
548
|
-
allowPrivateHosts
|
|
580
|
+
allowPrivateHosts,
|
|
549
581
|
signal: crawlSignal,
|
|
550
582
|
});
|
|
551
583
|
if (manifest) {
|
|
@@ -623,7 +655,7 @@ async function crawlWebsite(startUrl, options) {
|
|
|
623
655
|
deferred.delete(normalized);
|
|
624
656
|
fetchAttempts++;
|
|
625
657
|
const fetched = await fetchWebsitePage(decision.fetchUrl, {
|
|
626
|
-
allowPrivateHosts
|
|
658
|
+
allowPrivateHosts,
|
|
627
659
|
robots,
|
|
628
660
|
signal: crawlSignal,
|
|
629
661
|
});
|
|
@@ -851,12 +883,18 @@ export async function loadRobotsTxt(robotsUrl, options) {
|
|
|
851
883
|
}
|
|
852
884
|
if (response.status >= 500 && response.status < 600) {
|
|
853
885
|
await response.body?.cancel().catch(() => undefined);
|
|
854
|
-
// RFC 9309 §2.3.1.4
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
886
|
+
// Guard-audit finding 12: RFC 9309 §2.3.1.4 recommends treating an
|
|
887
|
+
// unreachable robots.txt as a full disallow, but for a personal tool
|
|
888
|
+
// crawling a site the operator explicitly chose, a transient 5xx on
|
|
889
|
+
// the site's OWN robots.txt is not a site owner's Disallow — it is the
|
|
890
|
+
// site being flaky. `fetchWithRetry` already retried this once, so
|
|
891
|
+
// this is not a single blip; still, blocking the crawl outright over
|
|
892
|
+
// it serves the operator worse than proceeding unrestricted. Reported
|
|
893
|
+
// as "unavailable" (the same outcome a 4xx gets), which resolves to
|
|
894
|
+
// ALLOW_ALL_RULES rather than DISALLOW_ALL_RULES.
|
|
895
|
+
warn("[akm] robots.txt at %s returned %d; proceeding as if no robots.txt were published, rather than treating " +
|
|
896
|
+
"the crawl as fully disallowed. Set respectRobots: false on this website source to silence this warning.", robotsUrl, response.status);
|
|
897
|
+
return { kind: "unavailable" };
|
|
860
898
|
}
|
|
861
899
|
// 4xx (404 is the common, silent case), and any other non-2xx/5xx status.
|
|
862
900
|
await response.body?.cancel().catch(() => undefined);
|
|
@@ -12,20 +12,28 @@
|
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { ConfigError, UsageError } from "../core/errors.js";
|
|
15
|
+
import { classifyNetworkHostname } from "../core/network-policy.js";
|
|
15
16
|
import { getRegistryIndexCacheDir } from "../core/paths.js";
|
|
16
|
-
import {
|
|
17
|
+
import { warnOnce } from "../core/warn.js";
|
|
18
|
+
import { assertWebsiteRequestUrl } from "./snapshot-fetchers/host-guard.js";
|
|
17
19
|
export function shouldAllowPrivateWebsiteHostsForTests() {
|
|
18
20
|
return process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test";
|
|
19
21
|
}
|
|
20
22
|
export function shouldAllowPrivateWebsiteUrlForTests(rawUrl) {
|
|
21
|
-
|
|
22
|
-
return false;
|
|
23
|
+
let hostname;
|
|
23
24
|
try {
|
|
24
|
-
|
|
25
|
+
hostname = new URL(rawUrl).hostname.toLowerCase();
|
|
25
26
|
}
|
|
26
27
|
catch {
|
|
27
28
|
return false;
|
|
28
29
|
}
|
|
30
|
+
if (classifyNetworkHostname(hostname) === "public")
|
|
31
|
+
return false;
|
|
32
|
+
if (!shouldAllowPrivateWebsiteHostsForTests()) {
|
|
33
|
+
warnOnce(`website-private-host:${hostname}`, `[akm] "${hostname}" is not a publicly routable host, but you added it as a source directly — proceeding. ` +
|
|
34
|
+
"Links discovered elsewhere are still checked.");
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
29
37
|
}
|
|
30
38
|
export function getWebsiteCachePaths(siteUrl, cacheRootOverride) {
|
|
31
39
|
const key = createHash("sha256").update(normalizeSiteUrl(siteUrl)).digest("hex").slice(0, 16);
|
|
@@ -39,17 +39,28 @@ function inspectLedgerAgainst(db, registryIds) {
|
|
|
39
39
|
const migrationIds = rows.map((row) => row.id);
|
|
40
40
|
for (const [index, row] of rows.entries()) {
|
|
41
41
|
const expectedId = registryIds[index];
|
|
42
|
+
// Every id this binary knows matched in order and the ledger carries more:
|
|
43
|
+
// the database was migrated by a newer akm. Nothing here is applicable —
|
|
44
|
+
// this binary's whole registry is already applied — so this is version
|
|
45
|
+
// skew, not divergence.
|
|
42
46
|
if (!expectedId) {
|
|
43
|
-
return {
|
|
47
|
+
return {
|
|
48
|
+
status: "newer",
|
|
49
|
+
migrationIds,
|
|
50
|
+
detail: `applied migration ID${rows.length - registryIds.length === 1 ? "" : "s"} ${migrationIds
|
|
51
|
+
.slice(registryIds.length)
|
|
52
|
+
.join(", ")} unknown to this akm`,
|
|
53
|
+
};
|
|
44
54
|
}
|
|
55
|
+
// A mismatch at a position this binary has a migration for is divergence,
|
|
56
|
+
// whether or not the id is one this binary knows later: this binary's
|
|
57
|
+
// migration at `index` was never applied, and something else was.
|
|
45
58
|
if (row.id !== expectedId) {
|
|
46
|
-
const knownLater = registryIds.includes(row.id);
|
|
47
59
|
return {
|
|
48
|
-
status:
|
|
60
|
+
status: "inconsistent",
|
|
49
61
|
migrationIds,
|
|
50
|
-
detail:
|
|
51
|
-
|
|
52
|
-
: `unknown migration ID ${row.id}`,
|
|
62
|
+
detail: `migration ledger is not an exact ordered prefix at position ${index + 1} (found '${row.id}', expected '${expectedId}'). ` +
|
|
63
|
+
`Applied, in order: [${migrationIds.join(", ")}]. This akm's expected order: [${registryIds.join(", ")}].`,
|
|
53
64
|
};
|
|
54
65
|
}
|
|
55
66
|
}
|
|
@@ -62,13 +73,32 @@ export function inspectMigrationLedger(db, migrations) {
|
|
|
62
73
|
assertMigrationRegistry(migrations);
|
|
63
74
|
return inspectLedgerAgainst(db, migrations.map((migration) => migration.id));
|
|
64
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Reject only a ledger this binary cannot reason about at all.
|
|
78
|
+
*
|
|
79
|
+
* A `newer` ledger — an exact ordered prefix of this binary's registry plus
|
|
80
|
+
* migrations a later akm added — is NOT rejected. Two akm versions sharing one
|
|
81
|
+
* data directory is a supported deployment (a bundled CLI alongside a newer
|
|
82
|
+
* global install), and refusing the open bricked the older one for every
|
|
83
|
+
* command while protecting nothing: its entire registry is already applied, so
|
|
84
|
+
* it has no pending migration to run. Callers that want to tell an operator
|
|
85
|
+
* about the skew read {@link MigrationLedgerState.status}.
|
|
86
|
+
*
|
|
87
|
+
* A `inconsistent` ledger is different: this binary has a migration that was
|
|
88
|
+
* never applied and something else was applied in its place, so running the
|
|
89
|
+
* pending set could conflict with schema it cannot see. That still refuses.
|
|
90
|
+
*/
|
|
65
91
|
export function assertMigrationLedger(db, migrations) {
|
|
66
92
|
const state = inspectMigrationLedger(db, migrations);
|
|
67
|
-
if (state.status === "newer") {
|
|
68
|
-
throw new Error(`Refusing to open a database with a newer migration ledger: ${state.detail}.`);
|
|
69
|
-
}
|
|
70
93
|
if (state.status === "inconsistent") {
|
|
71
|
-
throw new Error(`Refusing a database whose migrations are not an exact ordered prefix: ${state.detail}
|
|
94
|
+
throw new Error(`Refusing a database whose migrations are not an exact ordered prefix: ${state.detail} ` +
|
|
95
|
+
"Applying this binary's missing migration now could run it against a schema a later migration already " +
|
|
96
|
+
"changed underneath it, which is a real risk of producing a wrong schema — not something akm can guess " +
|
|
97
|
+
"its way out of safely. This usually means the database was migrated by an incompatible akm build or " +
|
|
98
|
+
"fork, or schema_migrations was edited by hand. Restore this file from a backup taken before the " +
|
|
99
|
+
"divergence, or — if there is no backup and the data is not needed — delete it and let akm rebuild it " +
|
|
100
|
+
"from scratch (a derived index.db regenerates from your sources on the next 'akm index'; state.db loses " +
|
|
101
|
+
"durable history such as improve/proposal state and must be treated as a last resort).");
|
|
72
102
|
}
|
|
73
103
|
return state;
|
|
74
104
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
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
|
+
/** Escape `%`, `_`, and `\\` so a LIKE pattern matches `text` literally; pair with `ESCAPE '\\'`. */
|
|
5
|
+
export function escapeLikePattern(text) {
|
|
6
|
+
return text.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
7
|
+
}
|
|
@@ -73,6 +73,29 @@ export function getLastExtractRunAt(db, harness) {
|
|
|
73
73
|
const ms = Date.parse(row.last);
|
|
74
74
|
return Number.isFinite(ms) ? ms : null;
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* #914: outcome counts from the `extract_sessions_seen` ledger since
|
|
78
|
+
* `sinceIso`, grouped by harness/outcome/skipReason/engine. This is what lets
|
|
79
|
+
* `akm health`'s `session-extraction` check derive its verdict from what
|
|
80
|
+
* standalone `akm proposal extract` actually recorded — the only write path
|
|
81
|
+
* the hook-driven SessionEnd extraction uses (`improve_runs` is never
|
|
82
|
+
* populated by it). `skipReason`/`engine` are read from `metadata_json` via
|
|
83
|
+
* `json_extract` and come back `null` both for a non-skip outcome and for a
|
|
84
|
+
* row a release before #912/#913 wrote (no key at that path) — callers must
|
|
85
|
+
* treat a `null` reason as "skipped, reason unknown", not as evidence of a
|
|
86
|
+
* specific cause.
|
|
87
|
+
*/
|
|
88
|
+
export function getExtractOutcomeCountsSince(db, sinceIso) {
|
|
89
|
+
return db
|
|
90
|
+
.prepare(`SELECT harness, outcome,
|
|
91
|
+
json_extract(metadata_json, '$.skipReason') AS skipReason,
|
|
92
|
+
json_extract(metadata_json, '$.engine') AS engine,
|
|
93
|
+
COUNT(*) AS count
|
|
94
|
+
FROM extract_sessions_seen
|
|
95
|
+
WHERE processed_at >= ?
|
|
96
|
+
GROUP BY harness, outcome, skipReason, engine`)
|
|
97
|
+
.all(sinceIso);
|
|
98
|
+
}
|
|
76
99
|
/**
|
|
77
100
|
* Decide whether a session should be skipped because the extractor has already
|
|
78
101
|
* processed BYTE-IDENTICAL content (#602). The skip authority is the content
|