@malloy-publisher/server 0.8.5 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.docker.md +86 -2
- package/dist/app/api-doc.yaml +17 -1
- package/dist/app/assets/{EnvironmentPage-D0689eMH.js → EnvironmentPage-UMS7Py4Q.js} +1 -1
- package/dist/app/assets/{HomePage-CQwxmRju.js → HomePage-D3MvQaZn.js} +1 -1
- package/dist/app/assets/{LightMode-DzUuDGQu.js → LightMode-e1lNBfpL.js} +1 -1
- package/dist/app/assets/{MainPage-CWebuoie.js → MainPage-B0ozqtQO.js} +1 -1
- package/dist/app/assets/{ModelPage-DddcQtR_.js → ModelPage-lQj4Axqu.js} +2 -2
- package/dist/app/assets/{PackagePage-B_6x-Kp1.js → PackagePage-BT0YKNKU.js} +1 -1
- package/dist/app/assets/{RenderedResult-BRgIgxWh.es-B6GsiQaB.js → RenderedResult-BRgIgxWh.es-DiE7kPNX.js} +2 -2
- package/dist/app/assets/{RouteError-D85c0IXR.js → RouteError-BQnzPJvb.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-DPFZnuXS.js → ThemeEditorPage-D6t7w9Js.js} +1 -1
- package/dist/app/assets/{ToggleButtonGroup-B2KkD7kC.js → ToggleButtonGroup-CJT2JKTC.js} +1 -1
- package/dist/app/assets/index-ByieDZlo.js +23 -0
- package/dist/app/assets/{index-BbrJd6fQ.js → index-CCNqF0-G.js} +1 -1
- package/dist/app/assets/{index-125-Lr5Z.js → index-CKmZ4HG7.js} +388 -388
- package/dist/app/assets/{index-mS-LEGFh.js → index-CdExa3Pk.js} +1 -1
- package/dist/app/assets/{index-BSBGZfIc.js → index-TiWjmdoW.js} +4 -4
- package/dist/app/assets/{index-DSoCUF-f.js → index-ireHUsv_.js} +419 -422
- package/dist/app/assets/{index-BK_RS5eN.js → index-ufFZR_K-.js} +1 -1
- package/dist/app/assets/{index.es-Dixkq2YH.js → index.es-iUV0gCYk.js} +4 -4
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +147 -118
- package/dist/server.mjs +742 -915
- package/package.json +14 -14
- package/dist/app/assets/index-w_0OQJgZ.js +0 -23
package/dist/server.mjs
CHANGED
|
@@ -205143,9 +205143,54 @@ function logInternalFailure(summary, error2, level = "error") {
|
|
|
205143
205143
|
}
|
|
205144
205144
|
});
|
|
205145
205145
|
}
|
|
205146
|
-
function
|
|
205146
|
+
function filesystemAccessFailure(error2) {
|
|
205147
|
+
let current = error2;
|
|
205148
|
+
for (let depth = 0;depth < 8 && current instanceof Error; depth++) {
|
|
205149
|
+
const errno = current;
|
|
205150
|
+
if (typeof errno.code === "string" && errno.code in FILESYSTEM_ACCESS_DESCRIPTIONS && typeof errno.syscall === "string") {
|
|
205151
|
+
return errno;
|
|
205152
|
+
}
|
|
205153
|
+
current = errno.cause;
|
|
205154
|
+
}
|
|
205155
|
+
return;
|
|
205156
|
+
}
|
|
205157
|
+
function describeFilesystemAccessFailure(errno) {
|
|
205158
|
+
const code = errno.code ?? "";
|
|
205159
|
+
const target = errno.path ? ` '${errno.path}'` : "";
|
|
205160
|
+
return `${code}: ${FILESYSTEM_ACCESS_DESCRIPTIONS[code] ?? code}, ${errno.syscall}${target}`;
|
|
205161
|
+
}
|
|
205162
|
+
function messageWithFilesystemCause(error2) {
|
|
205163
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
205164
|
+
const access = filesystemAccessFailure(error2);
|
|
205165
|
+
if (!access || message.includes(access.code ?? ""))
|
|
205166
|
+
return message;
|
|
205167
|
+
return `${message}: ${describeFilesystemAccessFailure(access)}`;
|
|
205168
|
+
}
|
|
205169
|
+
function errnoWireFields(error2) {
|
|
205170
|
+
const { code, syscall, path } = error2;
|
|
205171
|
+
if (typeof code !== "string")
|
|
205172
|
+
return;
|
|
205173
|
+
return {
|
|
205174
|
+
code,
|
|
205175
|
+
...typeof syscall === "string" ? { syscall } : {},
|
|
205176
|
+
...typeof path === "string" ? { path } : {}
|
|
205177
|
+
};
|
|
205178
|
+
}
|
|
205179
|
+
function internalErrorToHttpError(error2, { log = true } = {}) {
|
|
205180
|
+
const logInternal = (...args) => {
|
|
205181
|
+
if (log)
|
|
205182
|
+
logInternalFailure(...args);
|
|
205183
|
+
};
|
|
205184
|
+
const access = filesystemAccessFailure(error2);
|
|
205185
|
+
if (access) {
|
|
205186
|
+
logInternal("Filesystem access refused", error2, "warn");
|
|
205187
|
+
return httpError(500, `The server cannot access a path it needs (${describeFilesystemAccessFailure(access)}). ` + `Give the user the server runs as access to it.`);
|
|
205188
|
+
}
|
|
205147
205189
|
if (error2 instanceof BadRequestError) {
|
|
205148
205190
|
return httpError(400, error2.message);
|
|
205191
|
+
} else if (error2 instanceof ServerConfigurationError) {
|
|
205192
|
+
logInternal("Server configuration error", error2, "warn");
|
|
205193
|
+
return httpError(500, error2.message);
|
|
205149
205194
|
} else if (error2 instanceof FrozenConfigError) {
|
|
205150
205195
|
return httpError(403, error2.message);
|
|
205151
205196
|
} else if (error2 instanceof AccessDeniedError) {
|
|
@@ -205191,7 +205236,7 @@ function internalErrorToHttpError(error2) {
|
|
|
205191
205236
|
if (error2.callerSafe) {
|
|
205192
205237
|
return httpError(502, error2.message);
|
|
205193
205238
|
}
|
|
205194
|
-
|
|
205239
|
+
logInternal("Upstream connection error", error2, "warn");
|
|
205195
205240
|
return httpError(502, GENERIC_UPSTREAM_MESSAGE);
|
|
205196
205241
|
} else if (error2 instanceof MaterializationNotFoundError) {
|
|
205197
205242
|
return httpError(404, error2.message);
|
|
@@ -205202,7 +205247,7 @@ function internalErrorToHttpError(error2) {
|
|
|
205202
205247
|
} else if (error2 instanceof WriteConflictError) {
|
|
205203
205248
|
return httpError(409, error2.message);
|
|
205204
205249
|
} else if (error2 instanceof WriteRolledBackError) {
|
|
205205
|
-
|
|
205250
|
+
logInternal("Dashboard write rolled back", error2, "warn");
|
|
205206
205251
|
return httpError(500, error2.message);
|
|
205207
205252
|
} else if (error2 instanceof ServiceUnavailableError) {
|
|
205208
205253
|
return httpError(503, error2.message);
|
|
@@ -205213,7 +205258,7 @@ function internalErrorToHttpError(error2) {
|
|
|
205213
205258
|
} else if (error2 instanceof NotImplementedError) {
|
|
205214
205259
|
return httpError(501, error2.message);
|
|
205215
205260
|
} else {
|
|
205216
|
-
|
|
205261
|
+
logInternal("Unhandled internal error", error2);
|
|
205217
205262
|
return httpError(500, GENERIC_INTERNAL_MESSAGE);
|
|
205218
205263
|
}
|
|
205219
205264
|
}
|
|
@@ -205235,10 +205280,15 @@ function toQueryTextProblem(problem) {
|
|
|
205235
205280
|
...problem.at ? { at: { range: problem.at.range } } : {}
|
|
205236
205281
|
};
|
|
205237
205282
|
}
|
|
205238
|
-
var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", MAX_LOGGED_DETAIL_CHARS = 2000, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, PublisherConfigError, FrozenConfigError, AccessDeniedError, QueryCompileError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
|
|
205283
|
+
var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", MAX_LOGGED_DETAIL_CHARS = 2000, FILESYSTEM_ACCESS_DESCRIPTIONS, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, PublisherConfigError, ServerConfigurationError, FrozenConfigError, AccessDeniedError, QueryCompileError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
|
|
205239
205284
|
var init_errors = __esm(() => {
|
|
205240
205285
|
init_constants();
|
|
205241
205286
|
init_logger();
|
|
205287
|
+
FILESYSTEM_ACCESS_DESCRIPTIONS = {
|
|
205288
|
+
EACCES: "permission denied",
|
|
205289
|
+
EPERM: "operation not permitted",
|
|
205290
|
+
EROFS: "read-only file system"
|
|
205291
|
+
};
|
|
205242
205292
|
NotImplementedError = class NotImplementedError extends Error {
|
|
205243
205293
|
constructor(message) {
|
|
205244
205294
|
super(message);
|
|
@@ -205259,8 +205309,8 @@ var init_errors = __esm(() => {
|
|
|
205259
205309
|
}
|
|
205260
205310
|
};
|
|
205261
205311
|
PackageNotFoundError = class PackageNotFoundError extends Error {
|
|
205262
|
-
constructor(message) {
|
|
205263
|
-
super(message);
|
|
205312
|
+
constructor(message, options) {
|
|
205313
|
+
super(message, options);
|
|
205264
205314
|
}
|
|
205265
205315
|
};
|
|
205266
205316
|
ModelNotFoundError = class ModelNotFoundError extends Error {
|
|
@@ -205331,6 +205381,12 @@ var init_errors = __esm(() => {
|
|
|
205331
205381
|
this.cause = cause;
|
|
205332
205382
|
}
|
|
205333
205383
|
};
|
|
205384
|
+
ServerConfigurationError = class ServerConfigurationError extends Error {
|
|
205385
|
+
constructor(message) {
|
|
205386
|
+
super(message);
|
|
205387
|
+
this.name = "ServerConfigurationError";
|
|
205388
|
+
}
|
|
205389
|
+
};
|
|
205334
205390
|
FrozenConfigError = class FrozenConfigError extends Error {
|
|
205335
205391
|
constructor(message = `Publisher config can't be updated when ${PUBLISHER_CONFIG_NAME} has { "frozenConfig": true }`) {
|
|
205336
205392
|
super(message);
|
|
@@ -205388,8 +205444,8 @@ var init_errors = __esm(() => {
|
|
|
205388
205444
|
}
|
|
205389
205445
|
};
|
|
205390
205446
|
ServiceUnavailableError = class ServiceUnavailableError extends Error {
|
|
205391
|
-
constructor(message) {
|
|
205392
|
-
super(message);
|
|
205447
|
+
constructor(message, options) {
|
|
205448
|
+
super(message, options);
|
|
205393
205449
|
}
|
|
205394
205450
|
};
|
|
205395
205451
|
PayloadTooLargeError = class PayloadTooLargeError extends Error {
|
|
@@ -206074,13 +206130,69 @@ var init_path_safety = __esm(() => {
|
|
|
206074
206130
|
|
|
206075
206131
|
// src/pg_helpers.ts
|
|
206076
206132
|
function redactPgSecrets(s2) {
|
|
206077
|
-
return
|
|
206133
|
+
return redactUserinfoPasswords(redactUserinfoPasswords(s2, ANY_SCHEME_LAST_AT), PG_SCHEME_FIRST_AT).replace(/password=('(?:\\.|[^'\\])*'|"[^"]*"|\S+)/gi, "password=***");
|
|
206134
|
+
}
|
|
206135
|
+
function runEnd(run, s2, from) {
|
|
206136
|
+
run.lastIndex = from;
|
|
206137
|
+
run.exec(s2);
|
|
206138
|
+
return run.lastIndex;
|
|
206139
|
+
}
|
|
206140
|
+
function redactUserinfoPasswords(s2, pass) {
|
|
206141
|
+
let out = "";
|
|
206142
|
+
let copied = 0;
|
|
206143
|
+
let stop = -1;
|
|
206144
|
+
let sep2 = s2.indexOf("://");
|
|
206145
|
+
while (sep2 !== -1) {
|
|
206146
|
+
const userEnd = runEnd(USERNAME_RUN, s2, sep2 + 3);
|
|
206147
|
+
let at = -1;
|
|
206148
|
+
if (s2[userEnd] === ":" && pass.hasScheme(s2, sep2, copied)) {
|
|
206149
|
+
const from = userEnd + 1;
|
|
206150
|
+
if (stop < from)
|
|
206151
|
+
stop = runEnd(pass.passwordRun, s2, from);
|
|
206152
|
+
at = pass.passwordEnd(s2, from, stop);
|
|
206153
|
+
}
|
|
206154
|
+
if (at === -1) {
|
|
206155
|
+
sep2 = s2.indexOf("://", sep2 + 1);
|
|
206156
|
+
continue;
|
|
206157
|
+
}
|
|
206158
|
+
out += s2.slice(copied, userEnd + 1) + "***@";
|
|
206159
|
+
copied = at + 1;
|
|
206160
|
+
sep2 = s2.indexOf("://", copied);
|
|
206161
|
+
}
|
|
206162
|
+
return out + s2.slice(copied);
|
|
206078
206163
|
}
|
|
206079
206164
|
function redactConnectionSecretShapes(s2) {
|
|
206080
206165
|
return redactPgSecrets(s2.replace(PEM_TERMINATED_PATTERN, "***").replace(PEM_UNTERMINATED_PATTERN, "***").replace(SECRET_QUOTED_PATTERN, "$1***").replace(SECRET_BARE_PATTERN, "$1***"));
|
|
206081
206166
|
}
|
|
206082
|
-
var SECRET_FIELD_NAMES, NAME_ALTERNATION, SECRET_QUOTED_PATTERN, SECRET_BARE_PATTERN, PEM_TERMINATED_PATTERN, PEM_UNTERMINATED_PATTERN;
|
|
206167
|
+
var USERNAME_RUN, SCHEME_CHAR, SCHEME_LETTER, PG_SCHEMES, ANY_SCHEME_LAST_AT, PG_SCHEME_FIRST_AT, SECRET_FIELD_NAMES, NAME_ALTERNATION, SECRET_QUOTED_PATTERN, SECRET_BARE_PATTERN, PEM_TERMINATED_PATTERN, PEM_UNTERMINATED_PATTERN;
|
|
206083
206168
|
var init_pg_helpers = __esm(() => {
|
|
206169
|
+
USERNAME_RUN = /[^:/?#\s]*/y;
|
|
206170
|
+
SCHEME_CHAR = /[a-z0-9+.-]/i;
|
|
206171
|
+
SCHEME_LETTER = /[a-z]/i;
|
|
206172
|
+
PG_SCHEMES = ["postgres", "postgresql"];
|
|
206173
|
+
ANY_SCHEME_LAST_AT = {
|
|
206174
|
+
hasScheme(s2, sep2, floor) {
|
|
206175
|
+
for (let i2 = sep2 - 1;i2 >= floor && SCHEME_CHAR.test(s2[i2]); i2--) {
|
|
206176
|
+
if (SCHEME_LETTER.test(s2[i2]))
|
|
206177
|
+
return true;
|
|
206178
|
+
}
|
|
206179
|
+
return false;
|
|
206180
|
+
},
|
|
206181
|
+
passwordRun: /[^/\s]*/y,
|
|
206182
|
+
passwordEnd(s2, from, stop) {
|
|
206183
|
+
const at = s2.slice(from, stop).lastIndexOf("@");
|
|
206184
|
+
return at > 0 ? from + at : -1;
|
|
206185
|
+
}
|
|
206186
|
+
};
|
|
206187
|
+
PG_SCHEME_FIRST_AT = {
|
|
206188
|
+
hasScheme(s2, sep2, floor) {
|
|
206189
|
+
return PG_SCHEMES.some((scheme) => sep2 - scheme.length >= floor && s2.slice(sep2 - scheme.length, sep2).toLowerCase() === scheme);
|
|
206190
|
+
},
|
|
206191
|
+
passwordRun: /[^@\s]*/y,
|
|
206192
|
+
passwordEnd(s2, from, stop) {
|
|
206193
|
+
return stop > from && s2[stop] === "@" ? stop : -1;
|
|
206194
|
+
}
|
|
206195
|
+
};
|
|
206084
206196
|
SECRET_FIELD_NAMES = [
|
|
206085
206197
|
"password",
|
|
206086
206198
|
"privateKey",
|
|
@@ -293813,6 +293925,26 @@ var init_gcp_impersonation = __esm(() => {
|
|
|
293813
293925
|
impersonatedClients = new Map;
|
|
293814
293926
|
});
|
|
293815
293927
|
|
|
293928
|
+
// src/service/google_credentials.ts
|
|
293929
|
+
import * as fs4 from "fs";
|
|
293930
|
+
function assertGoogleCredentialsIsNotADirectory() {
|
|
293931
|
+
const keyFile = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
293932
|
+
if (!keyFile)
|
|
293933
|
+
return;
|
|
293934
|
+
let isDirectory = false;
|
|
293935
|
+
try {
|
|
293936
|
+
isDirectory = fs4.statSync(keyFile).isDirectory();
|
|
293937
|
+
} catch {
|
|
293938
|
+
return;
|
|
293939
|
+
}
|
|
293940
|
+
if (isDirectory) {
|
|
293941
|
+
throw new ServerConfigurationError(`GOOGLE_APPLICATION_CREDENTIALS names a directory, not a key file: '${keyFile}'. ` + `A bind mount whose source path does not exist creates a directory there; ` + `mount the key file itself.`);
|
|
293942
|
+
}
|
|
293943
|
+
}
|
|
293944
|
+
var init_google_credentials = __esm(() => {
|
|
293945
|
+
init_errors();
|
|
293946
|
+
});
|
|
293947
|
+
|
|
293816
293948
|
// src/service/quoting.ts
|
|
293817
293949
|
function bareTableName(tableName) {
|
|
293818
293950
|
const lastDot = tableName.lastIndexOf(".");
|
|
@@ -293867,7 +293999,7 @@ import {
|
|
|
293867
293999
|
} from "@malloydata/malloy";
|
|
293868
294000
|
import { createHash } from "crypto";
|
|
293869
294001
|
import { mkdtempSync, readFileSync, writeFileSync } from "fs";
|
|
293870
|
-
import
|
|
294002
|
+
import fs5 from "fs/promises";
|
|
293871
294003
|
import os2 from "os";
|
|
293872
294004
|
import path4 from "path";
|
|
293873
294005
|
import tls from "tls";
|
|
@@ -294706,8 +294838,8 @@ async function deleteDuckLakeConnectionFile(connectionName, environmentPath) {
|
|
|
294706
294838
|
assertSafeEnvironmentPath(environmentPath);
|
|
294707
294839
|
const ducklakePath = safeJoinUnderRoot(environmentPath, `${connectionName}_ducklake.duckdb`);
|
|
294708
294840
|
try {
|
|
294709
|
-
await
|
|
294710
|
-
await
|
|
294841
|
+
await fs5.access(ducklakePath);
|
|
294842
|
+
await fs5.rm(ducklakePath);
|
|
294711
294843
|
logger.info(`Removed DuckLake connection file ${connectionName}_ducklake.duckdb from ${environmentPath}`);
|
|
294712
294844
|
} catch (error2) {
|
|
294713
294845
|
if (error2.code === "ENOENT") {
|
|
@@ -295098,7 +295230,10 @@ async function testConnectionConfig(connectionConfig) {
|
|
|
295098
295230
|
if (connectionConfig.type === "duckdb" || connectionConfig.type === "ducklake") {
|
|
295099
295231
|
assertSafePackageName(connectionConfig.name);
|
|
295100
295232
|
}
|
|
295101
|
-
|
|
295233
|
+
if (connectionConfig.type === "bigquery" && !connectionConfig.bigqueryConnection?.serviceAccountKeyJson) {
|
|
295234
|
+
assertGoogleCredentialsIsNotADirectory();
|
|
295235
|
+
}
|
|
295236
|
+
testRoot = await fs5.mkdtemp(path4.join(os2.tmpdir(), "publisher-conn-test-"));
|
|
295102
295237
|
environmentConfig = buildEnvironmentMalloyConfig([connectionConfig], testRoot);
|
|
295103
295238
|
const connection = await environmentConfig.malloyConfig.connections.lookupConnection(connectionConfig.name);
|
|
295104
295239
|
if (connectionConfig.type === "duckdb") {
|
|
@@ -295142,7 +295277,7 @@ async function testConnectionConfig(connectionConfig) {
|
|
|
295142
295277
|
}
|
|
295143
295278
|
if (testRoot) {
|
|
295144
295279
|
try {
|
|
295145
|
-
await
|
|
295280
|
+
await fs5.rm(testRoot, { recursive: true, force: true });
|
|
295146
295281
|
} catch (cleanupError) {
|
|
295147
295282
|
logger.warn("Error cleaning up connection test directory", {
|
|
295148
295283
|
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
|
@@ -295162,6 +295297,7 @@ var init_connection = __esm(() => {
|
|
|
295162
295297
|
init_path_safety();
|
|
295163
295298
|
init_connection_config();
|
|
295164
295299
|
init_gcp_impersonation();
|
|
295300
|
+
init_google_credentials();
|
|
295165
295301
|
init_gcs_s3_utils();
|
|
295166
295302
|
init_proxy();
|
|
295167
295303
|
init_quoting();
|
|
@@ -302669,7 +302805,7 @@ var require_table = __commonJS((exports) => {
|
|
|
302669
302805
|
var Big = require_big();
|
|
302670
302806
|
var extend3 = require_extend();
|
|
302671
302807
|
var events_1 = __require("events");
|
|
302672
|
-
var
|
|
302808
|
+
var fs6 = __require("fs");
|
|
302673
302809
|
var is = require_is();
|
|
302674
302810
|
var path5 = __require("path");
|
|
302675
302811
|
var streamEvents = require_stream_events();
|
|
@@ -302979,7 +303115,7 @@ var require_table = __commonJS((exports) => {
|
|
|
302979
303115
|
if (!metadata.sourceFormat && detectedFormat) {
|
|
302980
303116
|
metadata.sourceFormat = detectedFormat;
|
|
302981
303117
|
}
|
|
302982
|
-
const jobWritable =
|
|
303118
|
+
const jobWritable = fs6.createReadStream(source).pipe(this.createWriteStream_(metadata));
|
|
302983
303119
|
const [jobResponse] = await (0, events_1.once)(jobWritable, "job");
|
|
302984
303120
|
return [jobResponse, jobResponse.metadata];
|
|
302985
303121
|
}
|
|
@@ -308322,72 +308458,21 @@ function onMotlyRoute(text) {
|
|
|
308322
308458
|
function hasEnvReference(annotation) {
|
|
308323
308459
|
return annotation.includes("@env.");
|
|
308324
308460
|
}
|
|
308325
|
-
function
|
|
308326
|
-
const targets = [Object.prototype];
|
|
308327
|
-
for (const key of Object.getOwnPropertyNames(Object.prototype)) {
|
|
308328
|
-
const descriptor = Object.getOwnPropertyDescriptor(Object.prototype, key);
|
|
308329
|
-
if (!descriptor)
|
|
308330
|
-
continue;
|
|
308331
|
-
let value;
|
|
308332
|
-
if ("value" in descriptor) {
|
|
308333
|
-
value = descriptor.value;
|
|
308334
|
-
} else {
|
|
308335
|
-
if (BUILT_IN_PROTO_GETTER === undefined || descriptor.get !== BUILT_IN_PROTO_GETTER) {
|
|
308336
|
-
return;
|
|
308337
|
-
}
|
|
308338
|
-
continue;
|
|
308339
|
-
}
|
|
308340
|
-
if ((typeof value === "object" || typeof value === "function") && value !== null && !targets.includes(value)) {
|
|
308341
|
-
targets.push(value);
|
|
308342
|
-
}
|
|
308343
|
-
}
|
|
308344
|
-
return targets;
|
|
308345
|
-
}
|
|
308346
|
-
function parseGuarded(texts) {
|
|
308347
|
-
const targets = pollutionTargets();
|
|
308348
|
-
if (targets === undefined) {
|
|
308349
|
-
return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
|
|
308350
|
-
}
|
|
308351
|
-
let unreadable = false;
|
|
308352
|
-
const ownNames = (target) => {
|
|
308353
|
-
try {
|
|
308354
|
-
return Object.getOwnPropertyNames(target);
|
|
308355
|
-
} catch {
|
|
308356
|
-
unreadable = true;
|
|
308357
|
-
return [];
|
|
308358
|
-
}
|
|
308359
|
-
};
|
|
308360
|
-
const before = targets.map(ownNames);
|
|
308361
|
-
if (unreadable)
|
|
308362
|
-
return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
|
|
308363
|
-
const undoPollution = () => {
|
|
308364
|
-
let polluted = false;
|
|
308365
|
-
targets.forEach((target, index) => {
|
|
308366
|
-
for (const key of ownNames(target)) {
|
|
308367
|
-
if (before[index].includes(key))
|
|
308368
|
-
continue;
|
|
308369
|
-
polluted = true;
|
|
308370
|
-
try {
|
|
308371
|
-
delete target[key];
|
|
308372
|
-
} catch {}
|
|
308373
|
-
}
|
|
308374
|
-
});
|
|
308375
|
-
return polluted || unreadable;
|
|
308376
|
-
};
|
|
308461
|
+
function parseBounded(texts) {
|
|
308377
308462
|
if (texts.some((text) => text.length > MAX_ANNOTATION_CHARS)) {
|
|
308378
308463
|
return { tag: undefined, messages: [ANNOTATION_TOO_LONG] };
|
|
308379
308464
|
}
|
|
308380
308465
|
try {
|
|
308381
308466
|
const result = parseAnnotation([...texts]);
|
|
308382
|
-
if (undoPollution())
|
|
308383
|
-
return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
|
|
308384
308467
|
return {
|
|
308385
308468
|
tag: result.tag,
|
|
308386
308469
|
messages: result.log.map((error2) => error2.message)
|
|
308387
308470
|
};
|
|
308388
|
-
} catch {
|
|
308389
|
-
|
|
308390
|
-
|
|
308471
|
+
} catch (error2) {
|
|
308472
|
+
return {
|
|
308473
|
+
tag: undefined,
|
|
308474
|
+
messages: [error2 instanceof Error ? error2.message : String(error2)]
|
|
308475
|
+
};
|
|
308391
308476
|
}
|
|
308392
308477
|
}
|
|
308393
308478
|
function docCommentText(texts) {
|
|
@@ -308519,16 +308604,16 @@ function parseMotly(texts) {
|
|
|
308519
308604
|
const envErrors = envDropped > 0 ? [ENV_REFERENCE_DROPPED] : [];
|
|
308520
308605
|
if (motly.length === 0)
|
|
308521
308606
|
return { tag: undefined, errors: envErrors };
|
|
308522
|
-
const direct =
|
|
308607
|
+
const direct = parseBounded(motly);
|
|
308523
308608
|
if (direct.messages.length === 0)
|
|
308524
308609
|
return { tag: direct.tag, errors: envErrors };
|
|
308525
308610
|
const rescued = motly.map((text) => {
|
|
308526
|
-
if (
|
|
308611
|
+
if (parseBounded([text]).messages.length === 0)
|
|
308527
308612
|
return text;
|
|
308528
308613
|
const rewritten = quoteFilterLiterals(text);
|
|
308529
|
-
return
|
|
308614
|
+
return parseBounded([rewritten]).messages.length === 0 ? rewritten : text;
|
|
308530
308615
|
});
|
|
308531
|
-
const after =
|
|
308616
|
+
const after = parseBounded(rescued);
|
|
308532
308617
|
return { tag: after.tag, errors: [...envErrors, ...after.messages] };
|
|
308533
308618
|
}
|
|
308534
308619
|
function tagText(tag, ...path7) {
|
|
@@ -308594,9 +308679,8 @@ function readStartingGivens(tag, declaredType) {
|
|
|
308594
308679
|
}
|
|
308595
308680
|
return Object.keys(collected).length > 0 ? collected : undefined;
|
|
308596
308681
|
}
|
|
308597
|
-
var
|
|
308682
|
+
var MAX_ANNOTATION_CHARS = 8192, ANNOTATION_TOO_LONG, ENV_REFERENCE_DROPPED = "annotation dropped for carrying an @env. reference";
|
|
308598
308683
|
var init_motly = __esm(() => {
|
|
308599
|
-
BUILT_IN_PROTO_GETTER = Object.getOwnPropertyDescriptor(Object.prototype, "__proto__")?.get;
|
|
308600
308684
|
ANNOTATION_TOO_LONG = `annotation exceeds ${MAX_ANNOTATION_CHARS} characters and was not parsed`;
|
|
308601
308685
|
});
|
|
308602
308686
|
|
|
@@ -316329,9 +316413,6 @@ function lintDashboard(facts, manifest) {
|
|
|
316329
316413
|
return findings;
|
|
316330
316414
|
}
|
|
316331
316415
|
function describeParseFailure(message) {
|
|
316332
|
-
if (message === UNSAFE_TO_PARSE) {
|
|
316333
|
-
return `was refused rather than parsed (${message}), which can be caused by something outside this file`;
|
|
316334
|
-
}
|
|
316335
316416
|
if (message === ENV_REFERENCE_DROPPED) {
|
|
316336
316417
|
return `was dropped rather than parsed (${message}), so nothing on that line took effect`;
|
|
316337
316418
|
}
|
|
@@ -316475,9 +316556,9 @@ var init_dashboard = __esm(() => {
|
|
|
316475
316556
|
var require_pump = __commonJS((exports, module) => {
|
|
316476
316557
|
var once = require_once2();
|
|
316477
316558
|
var eos = require_end_of_stream4();
|
|
316478
|
-
var
|
|
316559
|
+
var fs6;
|
|
316479
316560
|
try {
|
|
316480
|
-
|
|
316561
|
+
fs6 = __require("fs");
|
|
316481
316562
|
} catch (e2) {}
|
|
316482
316563
|
var noop3 = function() {};
|
|
316483
316564
|
var ancient = typeof process === "undefined" ? false : /^v?\.0/.test(process.version);
|
|
@@ -316487,9 +316568,9 @@ var require_pump = __commonJS((exports, module) => {
|
|
|
316487
316568
|
var isFS = function(stream4) {
|
|
316488
316569
|
if (!ancient)
|
|
316489
316570
|
return false;
|
|
316490
|
-
if (!
|
|
316571
|
+
if (!fs6)
|
|
316491
316572
|
return false;
|
|
316492
|
-
return (stream4 instanceof (
|
|
316573
|
+
return (stream4 instanceof (fs6.ReadStream || noop3) || stream4 instanceof (fs6.WriteStream || noop3)) && isFn(stream4.close);
|
|
316493
316574
|
};
|
|
316494
316575
|
var isRequest3 = function(stream4) {
|
|
316495
316576
|
return stream4.setHeader && isFn(stream4.abort);
|
|
@@ -316704,7 +316785,7 @@ var require_pend = __commonJS((exports, module) => {
|
|
|
316704
316785
|
|
|
316705
316786
|
// ../../node_modules/fd-slicer/index.js
|
|
316706
316787
|
var require_fd_slicer = __commonJS((exports) => {
|
|
316707
|
-
var
|
|
316788
|
+
var fs6 = __require("fs");
|
|
316708
316789
|
var util6 = __require("util");
|
|
316709
316790
|
var stream4 = __require("stream");
|
|
316710
316791
|
var Readable4 = stream4.Readable;
|
|
@@ -316729,7 +316810,7 @@ var require_fd_slicer = __commonJS((exports) => {
|
|
|
316729
316810
|
FdSlicer.prototype.read = function(buffer, offset, length, position, callback) {
|
|
316730
316811
|
var self2 = this;
|
|
316731
316812
|
self2.pend.go(function(cb) {
|
|
316732
|
-
|
|
316813
|
+
fs6.read(self2.fd, buffer, offset, length, position, function(err, bytesRead, buffer2) {
|
|
316733
316814
|
cb();
|
|
316734
316815
|
callback(err, bytesRead, buffer2);
|
|
316735
316816
|
});
|
|
@@ -316738,7 +316819,7 @@ var require_fd_slicer = __commonJS((exports) => {
|
|
|
316738
316819
|
FdSlicer.prototype.write = function(buffer, offset, length, position, callback) {
|
|
316739
316820
|
var self2 = this;
|
|
316740
316821
|
self2.pend.go(function(cb) {
|
|
316741
|
-
|
|
316822
|
+
fs6.write(self2.fd, buffer, offset, length, position, function(err, written, buffer2) {
|
|
316742
316823
|
cb();
|
|
316743
316824
|
callback(err, written, buffer2);
|
|
316744
316825
|
});
|
|
@@ -316761,7 +316842,7 @@ var require_fd_slicer = __commonJS((exports) => {
|
|
|
316761
316842
|
if (self2.refCount < 0)
|
|
316762
316843
|
throw new Error("invalid unref");
|
|
316763
316844
|
if (self2.autoClose) {
|
|
316764
|
-
|
|
316845
|
+
fs6.close(self2.fd, onCloseDone);
|
|
316765
316846
|
}
|
|
316766
316847
|
function onCloseDone(err) {
|
|
316767
316848
|
if (err) {
|
|
@@ -316800,7 +316881,7 @@ var require_fd_slicer = __commonJS((exports) => {
|
|
|
316800
316881
|
if (self2.destroyed)
|
|
316801
316882
|
return cb();
|
|
316802
316883
|
var buffer = new Buffer(toRead);
|
|
316803
|
-
|
|
316884
|
+
fs6.read(self2.context.fd, buffer, 0, toRead, self2.pos, function(err, bytesRead) {
|
|
316804
316885
|
if (err) {
|
|
316805
316886
|
self2.destroy(err);
|
|
316806
316887
|
} else if (bytesRead === 0) {
|
|
@@ -316850,7 +316931,7 @@ var require_fd_slicer = __commonJS((exports) => {
|
|
|
316850
316931
|
self2.context.pend.go(function(cb) {
|
|
316851
316932
|
if (self2.destroyed)
|
|
316852
316933
|
return cb();
|
|
316853
|
-
|
|
316934
|
+
fs6.write(self2.context.fd, buffer, 0, buffer.length, self2.pos, function(err2, bytes) {
|
|
316854
316935
|
if (err2) {
|
|
316855
316936
|
self2.destroy();
|
|
316856
316937
|
cb();
|
|
@@ -317275,7 +317356,7 @@ var require_buffer_crc32 = __commonJS((exports, module) => {
|
|
|
317275
317356
|
|
|
317276
317357
|
// ../../node_modules/yauzl/index.js
|
|
317277
317358
|
var require_yauzl = __commonJS((exports) => {
|
|
317278
|
-
var
|
|
317359
|
+
var fs6 = __require("fs");
|
|
317279
317360
|
var zlib3 = __require("zlib");
|
|
317280
317361
|
var fd_slicer = require_fd_slicer();
|
|
317281
317362
|
var crc32 = require_buffer_crc32();
|
|
@@ -317312,12 +317393,12 @@ var require_yauzl = __commonJS((exports) => {
|
|
|
317312
317393
|
options.strictFileNames = false;
|
|
317313
317394
|
if (callback == null)
|
|
317314
317395
|
callback = defaultCallback;
|
|
317315
|
-
|
|
317396
|
+
fs6.open(path7, "r", function(err, fd) {
|
|
317316
317397
|
if (err)
|
|
317317
317398
|
return callback(err);
|
|
317318
317399
|
fromFd(fd, options, function(err2, zipfile) {
|
|
317319
317400
|
if (err2)
|
|
317320
|
-
|
|
317401
|
+
fs6.close(fd, defaultCallback);
|
|
317321
317402
|
callback(err2, zipfile);
|
|
317322
317403
|
});
|
|
317323
317404
|
});
|
|
@@ -317341,7 +317422,7 @@ var require_yauzl = __commonJS((exports) => {
|
|
|
317341
317422
|
options.strictFileNames = false;
|
|
317342
317423
|
if (callback == null)
|
|
317343
317424
|
callback = defaultCallback;
|
|
317344
|
-
|
|
317425
|
+
fs6.fstat(fd, function(err, stats) {
|
|
317345
317426
|
if (err)
|
|
317346
317427
|
return callback(err);
|
|
317347
317428
|
var reader = fd_slicer.createFromFd(fd, { autoClose: true });
|
|
@@ -317974,7 +318055,7 @@ var require_yauzl = __commonJS((exports) => {
|
|
|
317974
318055
|
// ../../node_modules/extract-zip/index.js
|
|
317975
318056
|
var require_extract_zip = __commonJS((exports, module) => {
|
|
317976
318057
|
var debug = require_src5()("extract-zip");
|
|
317977
|
-
var { createWriteStream, promises:
|
|
318058
|
+
var { createWriteStream, promises: fs6 } = __require("fs");
|
|
317978
318059
|
var getStream = require_get_stream();
|
|
317979
318060
|
var path7 = __require("path");
|
|
317980
318061
|
var { promisify: promisify2 } = __require("util");
|
|
@@ -318016,8 +318097,8 @@ var require_extract_zip = __commonJS((exports, module) => {
|
|
|
318016
318097
|
}
|
|
318017
318098
|
const destDir = path7.dirname(path7.join(this.opts.dir, entry.fileName));
|
|
318018
318099
|
try {
|
|
318019
|
-
await
|
|
318020
|
-
const canonicalDestDir = await
|
|
318100
|
+
await fs6.mkdir(destDir, { recursive: true });
|
|
318101
|
+
const canonicalDestDir = await fs6.realpath(destDir);
|
|
318021
318102
|
const relativeDestDir = path7.relative(this.opts.dir, canonicalDestDir);
|
|
318022
318103
|
if (relativeDestDir.split(path7.sep).includes("..")) {
|
|
318023
318104
|
throw new Error(`Out of bound path "${canonicalDestDir}" found while processing file ${entry.fileName}`);
|
|
@@ -318062,7 +318143,7 @@ var require_extract_zip = __commonJS((exports, module) => {
|
|
|
318062
318143
|
mkdirOptions.mode = procMode;
|
|
318063
318144
|
}
|
|
318064
318145
|
debug("mkdir", { dir: destDir, ...mkdirOptions });
|
|
318065
|
-
await
|
|
318146
|
+
await fs6.mkdir(destDir, mkdirOptions);
|
|
318066
318147
|
if (isDir)
|
|
318067
318148
|
return;
|
|
318068
318149
|
debug("opening read stream", dest);
|
|
@@ -318070,7 +318151,7 @@ var require_extract_zip = __commonJS((exports, module) => {
|
|
|
318070
318151
|
if (symlink) {
|
|
318071
318152
|
const link = await getStream(readStream2);
|
|
318072
318153
|
debug("creating symlink", link, dest);
|
|
318073
|
-
await
|
|
318154
|
+
await fs6.symlink(link, dest);
|
|
318074
318155
|
} else {
|
|
318075
318156
|
await pipeline2(readStream2, createWriteStream(dest, { mode: procMode }));
|
|
318076
318157
|
}
|
|
@@ -318102,8 +318183,8 @@ var require_extract_zip = __commonJS((exports, module) => {
|
|
|
318102
318183
|
if (!path7.isAbsolute(opts.dir)) {
|
|
318103
318184
|
throw new Error("Target directory is expected to be absolute");
|
|
318104
318185
|
}
|
|
318105
|
-
await
|
|
318106
|
-
opts.dir = await
|
|
318186
|
+
await fs6.mkdir(opts.dir, { recursive: true });
|
|
318187
|
+
opts.dir = await fs6.realpath(opts.dir);
|
|
318107
318188
|
return new Extractor(zipPath, opts).extract();
|
|
318108
318189
|
};
|
|
318109
318190
|
});
|
|
@@ -318199,9 +318280,80 @@ var require_dist17 = __commonJS((exports) => {
|
|
|
318199
318280
|
exports.default = deferred;
|
|
318200
318281
|
});
|
|
318201
318282
|
|
|
318283
|
+
// src/package_load/error_wire.ts
|
|
318284
|
+
import { MalloyError as MalloyError3 } from "@malloydata/malloy";
|
|
318285
|
+
function serializeError(error2) {
|
|
318286
|
+
const serialized = serializeErrorShape(error2);
|
|
318287
|
+
const errno = error2 instanceof Error ? errnoWireFields(error2) : undefined;
|
|
318288
|
+
return errno ? { ...serialized, errno } : serialized;
|
|
318289
|
+
}
|
|
318290
|
+
function serializeErrorShape(error2) {
|
|
318291
|
+
if (error2 instanceof MalloyError3) {
|
|
318292
|
+
return {
|
|
318293
|
+
name: error2.name,
|
|
318294
|
+
message: error2.message,
|
|
318295
|
+
stack: error2.stack,
|
|
318296
|
+
malloyProblems: error2.problems,
|
|
318297
|
+
isCompilationError: true
|
|
318298
|
+
};
|
|
318299
|
+
}
|
|
318300
|
+
if (error2 instanceof ModelCompilationError) {
|
|
318301
|
+
return {
|
|
318302
|
+
name: error2.name,
|
|
318303
|
+
message: error2.message,
|
|
318304
|
+
stack: error2.stack,
|
|
318305
|
+
isCompilationError: true
|
|
318306
|
+
};
|
|
318307
|
+
}
|
|
318308
|
+
if (error2 instanceof PackageManifestError) {
|
|
318309
|
+
return {
|
|
318310
|
+
name: error2.name,
|
|
318311
|
+
message: error2.message,
|
|
318312
|
+
stack: error2.stack,
|
|
318313
|
+
isManifestError: true
|
|
318314
|
+
};
|
|
318315
|
+
}
|
|
318316
|
+
if (error2 instanceof Error) {
|
|
318317
|
+
return {
|
|
318318
|
+
name: error2.name,
|
|
318319
|
+
message: error2.message,
|
|
318320
|
+
stack: error2.stack
|
|
318321
|
+
};
|
|
318322
|
+
}
|
|
318323
|
+
return { name: "Error", message: String(error2) };
|
|
318324
|
+
}
|
|
318325
|
+
function deserializeError(serialized) {
|
|
318326
|
+
const err = new Error(serialized.message);
|
|
318327
|
+
err.name = serialized.name;
|
|
318328
|
+
if (serialized.stack)
|
|
318329
|
+
err.stack = serialized.stack;
|
|
318330
|
+
if (serialized.errno)
|
|
318331
|
+
Object.assign(err, serialized.errno);
|
|
318332
|
+
if (serialized.malloyProblems) {
|
|
318333
|
+
err.problems = serialized.malloyProblems;
|
|
318334
|
+
}
|
|
318335
|
+
if (serialized.isCompilationError) {
|
|
318336
|
+
const wrapped = new ModelCompilationError(err);
|
|
318337
|
+
if (serialized.stack)
|
|
318338
|
+
wrapped.stack = serialized.stack;
|
|
318339
|
+
return wrapped;
|
|
318340
|
+
}
|
|
318341
|
+
if (serialized.isManifestError) {
|
|
318342
|
+
const manifestError = new PackageManifestError(serialized.message);
|
|
318343
|
+
if (serialized.stack)
|
|
318344
|
+
manifestError.stack = serialized.stack;
|
|
318345
|
+
return manifestError;
|
|
318346
|
+
}
|
|
318347
|
+
return err;
|
|
318348
|
+
}
|
|
318349
|
+
var init_error_wire = __esm(() => {
|
|
318350
|
+
init_errors();
|
|
318351
|
+
});
|
|
318352
|
+
|
|
318202
318353
|
// src/package_load/package_load_pool.ts
|
|
318203
318354
|
var exports_package_load_pool = {};
|
|
318204
318355
|
__export(exports_package_load_pool, {
|
|
318356
|
+
serializeError: () => serializeError,
|
|
318205
318357
|
getPackageLoadWorkerCount: () => getPackageLoadWorkerCount,
|
|
318206
318358
|
getPackageLoadPool: () => getPackageLoadPool,
|
|
318207
318359
|
deserializeError: () => deserializeError,
|
|
@@ -318635,38 +318787,6 @@ function adaptResult(result) {
|
|
|
318635
318787
|
timings: result.timings
|
|
318636
318788
|
};
|
|
318637
318789
|
}
|
|
318638
|
-
function serializeError(error2) {
|
|
318639
|
-
if (error2 instanceof Error) {
|
|
318640
|
-
return {
|
|
318641
|
-
name: error2.name,
|
|
318642
|
-
message: error2.message,
|
|
318643
|
-
stack: error2.stack
|
|
318644
|
-
};
|
|
318645
|
-
}
|
|
318646
|
-
return { name: "Error", message: String(error2) };
|
|
318647
|
-
}
|
|
318648
|
-
function deserializeError(serialized) {
|
|
318649
|
-
const err = new Error(serialized.message);
|
|
318650
|
-
err.name = serialized.name;
|
|
318651
|
-
if (serialized.stack)
|
|
318652
|
-
err.stack = serialized.stack;
|
|
318653
|
-
if (serialized.malloyProblems) {
|
|
318654
|
-
err.problems = serialized.malloyProblems;
|
|
318655
|
-
}
|
|
318656
|
-
if (serialized.isCompilationError) {
|
|
318657
|
-
const wrapped = new ModelCompilationError(err);
|
|
318658
|
-
if (serialized.stack)
|
|
318659
|
-
wrapped.stack = serialized.stack;
|
|
318660
|
-
return wrapped;
|
|
318661
|
-
}
|
|
318662
|
-
if (serialized.isManifestError) {
|
|
318663
|
-
const manifestError = new PackageManifestError(serialized.message);
|
|
318664
|
-
if (serialized.stack)
|
|
318665
|
-
manifestError.stack = serialized.stack;
|
|
318666
|
-
return manifestError;
|
|
318667
|
-
}
|
|
318668
|
-
return err;
|
|
318669
|
-
}
|
|
318670
318790
|
function getPackageLoadPool() {
|
|
318671
318791
|
if (singleton === null) {
|
|
318672
318792
|
const n = getPackageLoadWorkerCount();
|
|
@@ -318683,8 +318803,9 @@ async function __setPackageLoadPoolForTests(pool) {
|
|
|
318683
318803
|
}
|
|
318684
318804
|
var PACKAGE_LOAD_JOB_TIMEOUT_MS, WORKER_SPAWN_TIMEOUT_MS = 30000, defaultUrlReader, singleton = null;
|
|
318685
318805
|
var init_package_load_pool = __esm(() => {
|
|
318686
|
-
init_errors();
|
|
318687
318806
|
init_logger();
|
|
318807
|
+
init_error_wire();
|
|
318808
|
+
init_error_wire();
|
|
318688
318809
|
PACKAGE_LOAD_JOB_TIMEOUT_MS = (() => {
|
|
318689
318810
|
const raw = process.env.PACKAGE_LOAD_JOB_TIMEOUT_MS;
|
|
318690
318811
|
if (raw === undefined)
|
|
@@ -318696,10 +318817,10 @@ var init_package_load_pool = __esm(() => {
|
|
|
318696
318817
|
})();
|
|
318697
318818
|
defaultUrlReader = {
|
|
318698
318819
|
readURL: async (url2) => {
|
|
318699
|
-
const { promises:
|
|
318820
|
+
const { promises: fs6 } = await import("fs");
|
|
318700
318821
|
const { fileURLToPath: fileURLToPath4 } = await import("url");
|
|
318701
318822
|
const filePath = url2.protocol === "file:" ? fileURLToPath4(url2) : url2.toString();
|
|
318702
|
-
return
|
|
318823
|
+
return fs6.readFile(filePath, "utf8");
|
|
318703
318824
|
}
|
|
318704
318825
|
};
|
|
318705
318826
|
});
|
|
@@ -318872,7 +318993,7 @@ var init_materialization_metrics = __esm(() => {
|
|
|
318872
318993
|
});
|
|
318873
318994
|
|
|
318874
318995
|
// src/utils.ts
|
|
318875
|
-
import * as
|
|
318996
|
+
import * as fs6 from "fs";
|
|
318876
318997
|
import * as path8 from "path";
|
|
318877
318998
|
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
318878
318999
|
function ignoreDotfiles(file) {
|
|
@@ -318889,7 +319010,7 @@ var init_utils4 = __esm(() => {
|
|
|
318889
319010
|
if (url2.protocol == "file:") {
|
|
318890
319011
|
path9 = fileURLToPath4(url2);
|
|
318891
319012
|
}
|
|
318892
|
-
return
|
|
319013
|
+
return fs6.promises.readFile(path9, "utf8");
|
|
318893
319014
|
}
|
|
318894
319015
|
};
|
|
318895
319016
|
});
|
|
@@ -324018,7 +324139,7 @@ import {
|
|
|
324018
324139
|
InMemoryURLReader as InMemoryURLReader2,
|
|
324019
324140
|
isSourceDef as isSourceDef9,
|
|
324020
324141
|
MalloyConfig as MalloyConfig2,
|
|
324021
|
-
MalloyError as
|
|
324142
|
+
MalloyError as MalloyError5,
|
|
324022
324143
|
Annotations as Annotations6,
|
|
324023
324144
|
modelDefToModelInfo as modelDefToModelInfo2,
|
|
324024
324145
|
Runtime as Runtime2
|
|
@@ -324027,7 +324148,7 @@ import {
|
|
|
324027
324148
|
MalloySQLParser,
|
|
324028
324149
|
MalloySQLStatementType
|
|
324029
324150
|
} from "@malloydata/malloy-sql";
|
|
324030
|
-
import * as
|
|
324151
|
+
import * as fs8 from "fs/promises";
|
|
324031
324152
|
import { readFileSync as readFileSync2 } from "fs";
|
|
324032
324153
|
import { createRequire as createRequire2 } from "module";
|
|
324033
324154
|
import * as path10 from "path";
|
|
@@ -324085,7 +324206,7 @@ async function compileErrorOf(runnable) {
|
|
|
324085
324206
|
await runnable.getPreparedQuery();
|
|
324086
324207
|
return;
|
|
324087
324208
|
} catch (error2) {
|
|
324088
|
-
return error2 instanceof
|
|
324209
|
+
return error2 instanceof MalloyError5 ? error2 : undefined;
|
|
324089
324210
|
}
|
|
324090
324211
|
}
|
|
324091
324212
|
function bindingsAllowDegradeToLive(bindings) {
|
|
@@ -325417,7 +325538,7 @@ var init_model = __esm(() => {
|
|
|
325417
325538
|
if (error2 instanceof Error && error2.stack) {
|
|
325418
325539
|
logger.error("Error stack", error2.stack);
|
|
325419
325540
|
}
|
|
325420
|
-
if (error2 instanceof
|
|
325541
|
+
if (error2 instanceof MalloyError5) {
|
|
325421
325542
|
const problems = error2.problems;
|
|
325422
325543
|
for (const problem of problems) {
|
|
325423
325544
|
logger.error("Problem", problem);
|
|
@@ -325703,7 +325824,7 @@ var init_model = __esm(() => {
|
|
|
325703
325824
|
await runnable.getPreparedQuery();
|
|
325704
325825
|
compiledSource = await this.resolveAuthorizeSourceFromRunnable(runnable);
|
|
325705
325826
|
} catch (error2) {
|
|
325706
|
-
if (error2 instanceof
|
|
325827
|
+
if (error2 instanceof MalloyError5 || error2 instanceof ModelCompilationError) {
|
|
325707
325828
|
return;
|
|
325708
325829
|
}
|
|
325709
325830
|
throw error2;
|
|
@@ -326268,7 +326389,7 @@ var init_model = __esm(() => {
|
|
|
326268
326389
|
async getQueryResults(sourceName, queryName, query, filterParams, bypassFilters, givens, abortSignal, queryMetadataInput, responseShape = "full", bypassAuthorize = false, includeHiddenFilesAndSources = false) {
|
|
326269
326390
|
const startTime = performance.now();
|
|
326270
326391
|
if (this.compilationError) {
|
|
326271
|
-
if (this.compilationError instanceof
|
|
326392
|
+
if (this.compilationError instanceof MalloyError5 || this.compilationError instanceof ModelCompilationError) {
|
|
326272
326393
|
throw this.compilationError;
|
|
326273
326394
|
}
|
|
326274
326395
|
throw new BadRequestError(`Model compilation failed: ${this.compilationError.message}`);
|
|
@@ -326414,7 +326535,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
326414
326535
|
if (error2 instanceof FilterValidationError) {
|
|
326415
326536
|
throw new BadRequestError(error2.message);
|
|
326416
326537
|
}
|
|
326417
|
-
if (error2 instanceof
|
|
326538
|
+
if (error2 instanceof MalloyError5) {
|
|
326418
326539
|
throw error2;
|
|
326419
326540
|
}
|
|
326420
326541
|
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
@@ -326522,7 +326643,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
326522
326643
|
});
|
|
326523
326644
|
throw new BadRequestError(err instanceof Error ? err.message : String(err));
|
|
326524
326645
|
}
|
|
326525
|
-
if (err instanceof
|
|
326646
|
+
if (err instanceof MalloyError5) {
|
|
326526
326647
|
throw err;
|
|
326527
326648
|
}
|
|
326528
326649
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
@@ -326850,7 +326971,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
326850
326971
|
recordNotebookCellExecution(this.notebookFormat(), kind, "ok", performance.now() - started);
|
|
326851
326972
|
return result;
|
|
326852
326973
|
} catch (error2) {
|
|
326853
|
-
recordNotebookCellExecution(this.notebookFormat(), kind, error2 instanceof AccessDeniedError ? "denied" : error2 instanceof NotQueryableError ? "not_queryable" : error2 instanceof BadRequestError || error2 instanceof
|
|
326974
|
+
recordNotebookCellExecution(this.notebookFormat(), kind, error2 instanceof AccessDeniedError ? "denied" : error2 instanceof NotQueryableError ? "not_queryable" : error2 instanceof BadRequestError || error2 instanceof MalloyError5 ? "bad_request" : "error", performance.now() - started);
|
|
326854
326975
|
throw error2;
|
|
326855
326976
|
}
|
|
326856
326977
|
}
|
|
@@ -326962,7 +327083,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
326962
327083
|
});
|
|
326963
327084
|
throw new BadRequestError(error2 instanceof Error ? error2.message : String(error2));
|
|
326964
327085
|
}
|
|
326965
|
-
if (error2 instanceof
|
|
327086
|
+
if (error2 instanceof MalloyError5) {
|
|
326966
327087
|
throw error2;
|
|
326967
327088
|
}
|
|
326968
327089
|
if (error2 instanceof PayloadTooLargeError) {
|
|
@@ -327006,7 +327127,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
327006
327127
|
throw new ModelNotFoundError(`${modelPath} does not exist.`);
|
|
327007
327128
|
}
|
|
327008
327129
|
try {
|
|
327009
|
-
if (!(await
|
|
327130
|
+
if (!(await fs8.stat(fullModelPath)).isFile()) {
|
|
327010
327131
|
throw new ModelNotFoundError(`${modelPath} is not a file.`);
|
|
327011
327132
|
}
|
|
327012
327133
|
} catch {
|
|
@@ -327110,7 +327231,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
327110
327231
|
let fileContents = undefined;
|
|
327111
327232
|
let parse5 = undefined;
|
|
327112
327233
|
try {
|
|
327113
|
-
fileContents = await
|
|
327234
|
+
fileContents = await fs8.readFile(modelURL, "utf8");
|
|
327114
327235
|
} catch {
|
|
327115
327236
|
throw new ModelNotFoundError("Model not found: " + modelPath);
|
|
327116
327237
|
}
|
|
@@ -327204,7 +327325,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
327204
327325
|
async getFileText(packagePath) {
|
|
327205
327326
|
const fullPath = path10.join(packagePath, this.modelPath);
|
|
327206
327327
|
try {
|
|
327207
|
-
return await
|
|
327328
|
+
return await fs8.readFile(fullPath, "utf8");
|
|
327208
327329
|
} catch {
|
|
327209
327330
|
throw new ModelNotFoundError(`Model file not found: ${this.modelPath}`);
|
|
327210
327331
|
}
|
|
@@ -328191,7 +328312,7 @@ var require_minimatch = __commonJS((exports, module) => {
|
|
|
328191
328312
|
|
|
328192
328313
|
// ../../node_modules/recursive-readdir/index.js
|
|
328193
328314
|
var require_recursive_readdir = __commonJS((exports, module) => {
|
|
328194
|
-
var
|
|
328315
|
+
var fs9 = __require("fs");
|
|
328195
328316
|
var p2 = __require("path");
|
|
328196
328317
|
var minimatch = require_minimatch();
|
|
328197
328318
|
function patternMatcher(pattern) {
|
|
@@ -328225,7 +328346,7 @@ var require_recursive_readdir = __commonJS((exports, module) => {
|
|
|
328225
328346
|
}
|
|
328226
328347
|
ignores = ignores.map(toMatcherFunction);
|
|
328227
328348
|
var list = [];
|
|
328228
|
-
|
|
328349
|
+
fs9.readdir(path11, function(err, files) {
|
|
328229
328350
|
if (err) {
|
|
328230
328351
|
return callback(err);
|
|
328231
328352
|
}
|
|
@@ -328235,7 +328356,7 @@ var require_recursive_readdir = __commonJS((exports, module) => {
|
|
|
328235
328356
|
}
|
|
328236
328357
|
files.forEach(function(file) {
|
|
328237
328358
|
var filePath = p2.join(path11, file);
|
|
328238
|
-
|
|
328359
|
+
fs9.stat(filePath, function(_err, stats) {
|
|
328239
328360
|
if (_err) {
|
|
328240
328361
|
return callback(_err);
|
|
328241
328362
|
}
|
|
@@ -346984,12 +347105,12 @@ var require_dist19 = __commonJS((exports, module) => {
|
|
|
346984
347105
|
throw new Error(`Unknown format "${name}"`);
|
|
346985
347106
|
return f3;
|
|
346986
347107
|
};
|
|
346987
|
-
function addFormats(ajv, list,
|
|
347108
|
+
function addFormats(ajv, list, fs12, exportName) {
|
|
346988
347109
|
var _a2;
|
|
346989
347110
|
var _b;
|
|
346990
347111
|
(_a2 = (_b = ajv.opts.code).formats) !== null && _a2 !== undefined || (_b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`);
|
|
346991
347112
|
for (const f3 of list)
|
|
346992
|
-
ajv.addFormat(f3,
|
|
347113
|
+
ajv.addFormat(f3, fs12[f3]);
|
|
346993
347114
|
}
|
|
346994
347115
|
module.exports = exports = formatsPlugin;
|
|
346995
347116
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -356703,6 +356824,7 @@ init_errors();
|
|
|
356703
356824
|
init_logger();
|
|
356704
356825
|
init_gcs_s3_utils();
|
|
356705
356826
|
init_gcp_impersonation();
|
|
356827
|
+
init_google_credentials();
|
|
356706
356828
|
init_introspection_sql();
|
|
356707
356829
|
var import_bigquery = __toESM(require_src144(), 1);
|
|
356708
356830
|
import { ClientSecretCredential } from "@azure/identity";
|
|
@@ -356766,6 +356888,7 @@ async function createBigQueryClient(connection) {
|
|
|
356766
356888
|
throw new Error("BigQuery project ID is required. Either set the defaultProjectId in the connection configuration or the project_id in the service account key JSON.");
|
|
356767
356889
|
}
|
|
356768
356890
|
} else if (Object.keys(connection.bigqueryConnection).length === 0 && process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
356891
|
+
assertGoogleCredentialsIsNotADirectory();
|
|
356769
356892
|
config2.keyFilename = process.env.GOOGLE_APPLICATION_CREDENTIALS || "";
|
|
356770
356893
|
} else {
|
|
356771
356894
|
throw new Error("BigQuery connection is required, either set the bigqueryConnection in the connection configuration or set the GOOGLE_APPLICATION_CREDENTIALS environment variable.");
|
|
@@ -364841,11 +364964,21 @@ class PackageController {
|
|
|
364841
364964
|
const packageName = body.name;
|
|
364842
364965
|
const environment = await this.environmentStore.getEnvironment(environmentName, false);
|
|
364843
364966
|
let result;
|
|
364844
|
-
|
|
364845
|
-
|
|
364846
|
-
|
|
364847
|
-
|
|
364848
|
-
|
|
364967
|
+
try {
|
|
364968
|
+
if (body.location) {
|
|
364969
|
+
const bodyLocation = body.location;
|
|
364970
|
+
result = await environment.installPackage(packageName, (stagingPath) => this.downloadInto(environmentName, packageName, bodyLocation, stagingPath), (pkg) => formatPublishRejections(pkg));
|
|
364971
|
+
} else {
|
|
364972
|
+
result = await environment.addPackage(packageName);
|
|
364973
|
+
}
|
|
364974
|
+
} catch (error2) {
|
|
364975
|
+
const answered = internalErrorToHttpError(error2, {
|
|
364976
|
+
log: false
|
|
364977
|
+
});
|
|
364978
|
+
if (answered.status >= 500) {
|
|
364979
|
+
environment.recordPackageAddFailure(packageName, answered.json.message);
|
|
364980
|
+
}
|
|
364981
|
+
throw error2;
|
|
364849
364982
|
}
|
|
364850
364983
|
if (!result) {
|
|
364851
364984
|
throw new Error(`Failed to create package ${packageName}`);
|
|
@@ -366555,7 +366688,7 @@ var import_client_s33 = __toESM(require_dist_cjs75(), 1);
|
|
|
366555
366688
|
import { Storage as Storage2 } from "@google-cloud/storage";
|
|
366556
366689
|
var import_extract_zip = __toESM(require_extract_zip(), 1);
|
|
366557
366690
|
import crypto5 from "crypto";
|
|
366558
|
-
import * as
|
|
366691
|
+
import * as fs11 from "fs";
|
|
366559
366692
|
import * as os3 from "os";
|
|
366560
366693
|
import * as path13 from "path";
|
|
366561
366694
|
|
|
@@ -372407,7 +372540,7 @@ class StorageManager {
|
|
|
372407
372540
|
// src/service/environment.ts
|
|
372408
372541
|
init_notebook();
|
|
372409
372542
|
init_dashboard();
|
|
372410
|
-
import { MalloyError as
|
|
372543
|
+
import { MalloyError as MalloyError7, Runtime as Runtime3 } from "@malloydata/malloy";
|
|
372411
372544
|
|
|
372412
372545
|
// src/service/notebook_lint.ts
|
|
372413
372546
|
init_dashboard();
|
|
@@ -372852,13 +372985,13 @@ init_errors();
|
|
|
372852
372985
|
init_authorize();
|
|
372853
372986
|
init_given();
|
|
372854
372987
|
import crypto4 from "crypto";
|
|
372855
|
-
import * as
|
|
372988
|
+
import * as fs10 from "fs";
|
|
372856
372989
|
import * as path12 from "path";
|
|
372857
372990
|
import { fileURLToPath as fileURLToPath7, pathToFileURL as pathToFileURL3 } from "url";
|
|
372858
372991
|
|
|
372859
372992
|
// src/service/compile_restriction.ts
|
|
372860
372993
|
init_errors();
|
|
372861
|
-
import { Malloy, MalloyError as
|
|
372994
|
+
import { Malloy, MalloyError as MalloyError4 } from "@malloydata/malloy";
|
|
372862
372995
|
var RESTRICTED_CONSTRUCT_CODE = "restricted-construct-forbidden";
|
|
372863
372996
|
var SYNTAX_ERROR_CODE = "syntax-error";
|
|
372864
372997
|
function isParseFailure(problem) {
|
|
@@ -372881,7 +373014,7 @@ async function assertNoRestrictedConstructs(runtime, model, source) {
|
|
|
372881
373014
|
});
|
|
372882
373015
|
problems = compiled.problems ?? [];
|
|
372883
373016
|
} catch (error2) {
|
|
372884
|
-
if (!(error2 instanceof
|
|
373017
|
+
if (!(error2 instanceof MalloyError4))
|
|
372885
373018
|
throw error2;
|
|
372886
373019
|
problems = error2.problems;
|
|
372887
373020
|
}
|
|
@@ -372911,7 +373044,7 @@ init_connection_config();
|
|
|
372911
373044
|
init_logger();
|
|
372912
373045
|
var import_client_s32 = __toESM(require_dist_cjs75(), 1);
|
|
372913
373046
|
import { Storage } from "@google-cloud/storage";
|
|
372914
|
-
import * as
|
|
373047
|
+
import * as fs7 from "fs/promises";
|
|
372915
373048
|
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
372916
373049
|
|
|
372917
373050
|
// src/storage/DatabaseInterface.ts
|
|
@@ -372947,9 +373080,9 @@ async function readManifestBytes(uri) {
|
|
|
372947
373080
|
return res.Body.transformToString();
|
|
372948
373081
|
}
|
|
372949
373082
|
if (uri.startsWith("file://")) {
|
|
372950
|
-
return
|
|
373083
|
+
return fs7.readFile(fileURLToPath5(uri), "utf8");
|
|
372951
373084
|
}
|
|
372952
|
-
return
|
|
373085
|
+
return fs7.readFile(uri, "utf8");
|
|
372953
373086
|
}
|
|
372954
373087
|
async function fetchManifestEntries(uri) {
|
|
372955
373088
|
const raw = await readManifestBytes(uri);
|
|
@@ -373008,7 +373141,7 @@ init_logger();
|
|
|
373008
373141
|
init_notebook_metrics();
|
|
373009
373142
|
init_materialization_metrics();
|
|
373010
373143
|
var import_recursive_readdir = __toESM(require_recursive_readdir(), 1);
|
|
373011
|
-
import * as
|
|
373144
|
+
import * as fs9 from "fs/promises";
|
|
373012
373145
|
import * as path11 from "path";
|
|
373013
373146
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
373014
373147
|
import"@malloydata/db-duckdb/native";
|
|
@@ -373019,7 +373152,7 @@ import {
|
|
|
373019
373152
|
EmptyURLReader,
|
|
373020
373153
|
FixedConnectionMap as FixedConnectionMap2,
|
|
373021
373154
|
MalloyConfig as MalloyConfig3,
|
|
373022
|
-
MalloyError as
|
|
373155
|
+
MalloyError as MalloyError6
|
|
373023
373156
|
} from "@malloydata/malloy";
|
|
373024
373157
|
|
|
373025
373158
|
// src/package_load_metrics.ts
|
|
@@ -373909,7 +374042,7 @@ function toTableNameManifest(entries) {
|
|
|
373909
374042
|
return out;
|
|
373910
374043
|
}
|
|
373911
374044
|
function packageLoadFailureStatus(error2) {
|
|
373912
|
-
if (error2 instanceof ModelCompilationError || error2 instanceof
|
|
374045
|
+
if (error2 instanceof ModelCompilationError || error2 instanceof MalloyError6) {
|
|
373913
374046
|
return "compilation_error";
|
|
373914
374047
|
}
|
|
373915
374048
|
if (error2 instanceof BadRequestError || error2 instanceof PackageManifestError) {
|
|
@@ -374099,11 +374232,11 @@ class Package {
|
|
|
374099
374232
|
if (!cleanupDirectoryOnFailure) {
|
|
374100
374233
|
logger.info(`Preserving existing package directory after failed load: ${packagePath}`);
|
|
374101
374234
|
} else {
|
|
374102
|
-
const stat7 = await
|
|
374235
|
+
const stat7 = await fs9.lstat(packagePath).catch(() => null);
|
|
374103
374236
|
if (stat7?.isSymbolicLink()) {
|
|
374104
374237
|
logger.info(`Skipping cleanup of symlinked package path on failure: ${packagePath}`);
|
|
374105
374238
|
} else {
|
|
374106
|
-
await
|
|
374239
|
+
await fs9.rm(packagePath, { recursive: true, force: true });
|
|
374107
374240
|
logger.info(`Cleaned up failed package directory: ${packagePath}`);
|
|
374108
374241
|
}
|
|
374109
374242
|
}
|
|
@@ -374125,10 +374258,10 @@ class Package {
|
|
|
374125
374258
|
defaultConnectionName: "duckdb"
|
|
374126
374259
|
}).catch((err) => {
|
|
374127
374260
|
const realError = err instanceof Error ? err : new Error(`Package-load worker pool failure: ${String(err)}`);
|
|
374128
|
-
if (realError instanceof
|
|
374261
|
+
if (realError instanceof MalloyError6 || realError instanceof ModelCompilationError || realError instanceof PackageManifestError) {
|
|
374129
374262
|
throw realError;
|
|
374130
374263
|
}
|
|
374131
|
-
throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}
|
|
374264
|
+
throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`, { cause: realError });
|
|
374132
374265
|
});
|
|
374133
374266
|
const [outcome, databases] = await Promise.all([
|
|
374134
374267
|
workerOutcome,
|
|
@@ -374183,7 +374316,7 @@ class Package {
|
|
|
374183
374316
|
});
|
|
374184
374317
|
}
|
|
374185
374318
|
if (sm.modelPath.endsWith(MODEL_FILE_SUFFIX)) {
|
|
374186
|
-
const modelSource = await
|
|
374319
|
+
const modelSource = await fs9.readFile(path11.join(packagePath, sm.modelPath), "utf-8");
|
|
374187
374320
|
assertPersistNamesQuoted(modelSource, sm.modelPath);
|
|
374188
374321
|
}
|
|
374189
374322
|
models.set(sm.modelPath, model);
|
|
@@ -374803,10 +374936,10 @@ class Package {
|
|
|
374803
374936
|
});
|
|
374804
374937
|
} catch (err) {
|
|
374805
374938
|
const realError = err instanceof Error ? err : new Error(`Package-load worker pool failure: ${String(err)}`);
|
|
374806
|
-
if (realError instanceof
|
|
374939
|
+
if (realError instanceof MalloyError6 || realError instanceof ModelCompilationError || realError instanceof PackageManifestError) {
|
|
374807
374940
|
throw realError;
|
|
374808
374941
|
}
|
|
374809
|
-
throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}
|
|
374942
|
+
throw new ServiceUnavailableError(`Package-load worker pool unavailable: ${realError.message}`, { cause: realError });
|
|
374810
374943
|
}
|
|
374811
374944
|
const nextModels = new Map;
|
|
374812
374945
|
const renderTagWarnings = [];
|
|
@@ -374912,7 +375045,7 @@ class Package {
|
|
|
374912
375045
|
}
|
|
374913
375046
|
async claimsToBeADashboard(modelPath) {
|
|
374914
375047
|
try {
|
|
374915
|
-
const source = await
|
|
375048
|
+
const source = await fs9.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
|
|
374916
375049
|
return hasArtifactLineOutsideBlocks(source, /^##?[ \t]*artifact\b/);
|
|
374917
375050
|
} catch {
|
|
374918
375051
|
return false;
|
|
@@ -374924,7 +375057,7 @@ class Package {
|
|
|
374924
375057
|
return model.carriesNotebookArtifactNote();
|
|
374925
375058
|
if (!model.getCompilationError())
|
|
374926
375059
|
return false;
|
|
374927
|
-
const source = await
|
|
375060
|
+
const source = await fs9.readFile(safeJoinUnderRoot(this.packagePath, modelPath), "utf8");
|
|
374928
375061
|
return claimsToBeANotebook(source);
|
|
374929
375062
|
} catch {
|
|
374930
375063
|
return false;
|
|
@@ -375237,7 +375370,7 @@ class Package {
|
|
|
375237
375370
|
const dir = safeJoinUnderRoot(this.packagePath, DASHBOARDS_DIR);
|
|
375238
375371
|
let entries;
|
|
375239
375372
|
try {
|
|
375240
|
-
entries = await
|
|
375373
|
+
entries = await fs9.readdir(dir);
|
|
375241
375374
|
} catch {
|
|
375242
375375
|
return [];
|
|
375243
375376
|
}
|
|
@@ -375343,8 +375476,11 @@ class Package {
|
|
|
375343
375476
|
static async validatePackageManifestExistsOrThrowError(packagePath) {
|
|
375344
375477
|
const packageConfigPath = safeJoinUnderRoot(packagePath, PACKAGE_MANIFEST_NAME);
|
|
375345
375478
|
try {
|
|
375346
|
-
await
|
|
375347
|
-
} catch {
|
|
375479
|
+
await fs9.stat(packageConfigPath);
|
|
375480
|
+
} catch (error2) {
|
|
375481
|
+
const code = error2.code;
|
|
375482
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
375483
|
+
throw error2;
|
|
375348
375484
|
logger.error(`Can't find ${packageConfigPath}`);
|
|
375349
375485
|
throw new PackageNotFoundError(`Package manifest for ${packagePath} does not exist.`);
|
|
375350
375486
|
}
|
|
@@ -375476,6 +375612,7 @@ async function denyHiddenAsNotQueryable(convert, gate) {
|
|
|
375476
375612
|
throw error2;
|
|
375477
375613
|
}
|
|
375478
375614
|
}
|
|
375615
|
+
var MAX_RECORDED_ADD_FAILURES = 100;
|
|
375479
375616
|
|
|
375480
375617
|
class Environment {
|
|
375481
375618
|
packages = new Map;
|
|
@@ -375483,6 +375620,7 @@ class Environment {
|
|
|
375483
375620
|
packageStatuses = new Map;
|
|
375484
375621
|
failedPackages = new Map;
|
|
375485
375622
|
mountErrors = new Map;
|
|
375623
|
+
recordedAddFailures = [];
|
|
375486
375624
|
staleCompileErrors = new Map;
|
|
375487
375625
|
malloyConfig;
|
|
375488
375626
|
connectionMutex = new Mutex;
|
|
@@ -375522,11 +375660,11 @@ class Environment {
|
|
|
375522
375660
|
return;
|
|
375523
375661
|
const readmePath = path12.join(this.environmentPath, "README.md");
|
|
375524
375662
|
try {
|
|
375525
|
-
await
|
|
375663
|
+
await fs10.promises.writeFile(readmePath, readme, "utf-8");
|
|
375526
375664
|
logger.info(`Updated README.md for environment ${this.environmentName}`);
|
|
375527
375665
|
} catch (err) {
|
|
375528
375666
|
logger.error(`Failed to write README.md`, { error: err });
|
|
375529
|
-
throw new Error(`Failed to update environment README
|
|
375667
|
+
throw new Error(`Failed to update environment README`, { cause: err });
|
|
375530
375668
|
}
|
|
375531
375669
|
}
|
|
375532
375670
|
async update(payload) {
|
|
@@ -375560,7 +375698,7 @@ class Environment {
|
|
|
375560
375698
|
}
|
|
375561
375699
|
static async create(environmentName, environmentPath, connections, storageDestinations = []) {
|
|
375562
375700
|
assertSafeEnvironmentPath(environmentPath);
|
|
375563
|
-
if (!(await
|
|
375701
|
+
if (!(await fs10.promises.stat(environmentPath))?.isDirectory()) {
|
|
375564
375702
|
throw new EnvironmentNotFoundError(`Environment path ${environmentPath} not found`);
|
|
375565
375703
|
}
|
|
375566
375704
|
logger.info(`Creating environment with connection configuration`);
|
|
@@ -375578,7 +375716,7 @@ class Environment {
|
|
|
375578
375716
|
async reloadEnvironmentMetadata() {
|
|
375579
375717
|
let readme = "";
|
|
375580
375718
|
try {
|
|
375581
|
-
readme = (await
|
|
375719
|
+
readme = (await fs10.promises.readFile(safeJoinUnderRoot(this.environmentPath, README_NAME))).toString();
|
|
375582
375720
|
} catch {}
|
|
375583
375721
|
this.metadata = {
|
|
375584
375722
|
...this.metadata,
|
|
@@ -375622,7 +375760,7 @@ class Environment {
|
|
|
375622
375760
|
if (scope === "append") {
|
|
375623
375761
|
let modelContent = "";
|
|
375624
375762
|
try {
|
|
375625
|
-
modelContent = await
|
|
375763
|
+
modelContent = await fs10.promises.readFile(modelPath, "utf8");
|
|
375626
375764
|
} catch {}
|
|
375627
375765
|
fullSource = modelContent ? `${modelContent}
|
|
375628
375766
|
${source}` : source ?? "";
|
|
@@ -375646,7 +375784,7 @@ ${source}` : source ?? "";
|
|
|
375646
375784
|
const pkg = await this._loadOrGetPackageLocked(packageName);
|
|
375647
375785
|
let { model: gateModel, exact: hasExactGateModel } = pkg.getCompileAuthorizationModel(modelName);
|
|
375648
375786
|
if (!hasExactGateModel && source !== undefined) {
|
|
375649
|
-
const diskTarget = await
|
|
375787
|
+
const diskTarget = await fs10.promises.stat(modelPath).catch(() => {
|
|
375650
375788
|
return;
|
|
375651
375789
|
});
|
|
375652
375790
|
if (diskTarget?.isFile()) {
|
|
@@ -375700,7 +375838,7 @@ ${source}` : source ?? "";
|
|
|
375700
375838
|
}, () => hasExactGateModel ? gateModel.assertAuthorizedForRunnable(finalQuery, givens ?? {}) : gateModel.assertAuthorizedFromCompiledRunnable(finalQuery, givens ?? {}));
|
|
375701
375839
|
}
|
|
375702
375840
|
} catch (error2) {
|
|
375703
|
-
if (!(error2 instanceof
|
|
375841
|
+
if (!(error2 instanceof MalloyError7))
|
|
375704
375842
|
throw error2;
|
|
375705
375843
|
}
|
|
375706
375844
|
}
|
|
@@ -375716,7 +375854,7 @@ ${source}` : source ?? "";
|
|
|
375716
375854
|
replacement: source === undefined ? undefined : { modelPath: modelName, source }
|
|
375717
375855
|
});
|
|
375718
375856
|
} catch (error2) {
|
|
375719
|
-
if (error2 instanceof
|
|
375857
|
+
if (error2 instanceof MalloyError7 || error2 instanceof ModelCompilationError || error2 instanceof PackageManifestError) {
|
|
375720
375858
|
throw error2;
|
|
375721
375859
|
}
|
|
375722
375860
|
throw new ServiceUnavailableError(`Package compile worker unavailable: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
@@ -375755,7 +375893,7 @@ ${source}` : source ?? "";
|
|
|
375755
375893
|
], compiled.modelPath);
|
|
375756
375894
|
}
|
|
375757
375895
|
}
|
|
375758
|
-
const lintText = !(isNotebookModelPath(compiled.modelPath) || isDashboardModelPath(compiled.modelPath)) ? undefined : compiled.modelSourceText ?? (compiled.modelPath === modelName && source !== undefined ? source : await
|
|
375896
|
+
const lintText = !(isNotebookModelPath(compiled.modelPath) || isDashboardModelPath(compiled.modelPath)) ? undefined : compiled.modelSourceText ?? (compiled.modelPath === modelName && source !== undefined ? source : await fs10.promises.readFile(path12.join(packagePath, compiled.modelPath), "utf8").catch(() => {
|
|
375759
375897
|
return;
|
|
375760
375898
|
}));
|
|
375761
375899
|
if (lintText !== undefined) {
|
|
@@ -375785,7 +375923,7 @@ ${source}` : source ?? "";
|
|
|
375785
375923
|
environment: this.environmentName,
|
|
375786
375924
|
reason: "base_model_load_failed"
|
|
375787
375925
|
});
|
|
375788
|
-
if (error2 instanceof
|
|
375926
|
+
if (error2 instanceof MalloyError7) {
|
|
375789
375927
|
throw new CompileRefusedError(`Cannot validate the submitted source: the model ` + `"${modelName}" does not compile, so there is nothing ` + `to check the submitted source against. Problems: ` + error2.problems.map((p2) => p2.message).join("; "));
|
|
375790
375928
|
}
|
|
375791
375929
|
throw new CompileRefusedError(`Cannot validate the submitted source: the model ` + `"${modelName}" could not be loaded to check it against.`);
|
|
@@ -375828,7 +375966,7 @@ ${source}` : source ?? "";
|
|
|
375828
375966
|
if (typeof givenCode === "string" && givenCode.startsWith("runtime-given-")) {
|
|
375829
375967
|
throw new BadRequestError(error2 instanceof Error ? error2.message : String(error2));
|
|
375830
375968
|
}
|
|
375831
|
-
if (error2 instanceof
|
|
375969
|
+
if (error2 instanceof MalloyError7) {
|
|
375832
375970
|
throw error2;
|
|
375833
375971
|
}
|
|
375834
375972
|
}
|
|
@@ -375844,7 +375982,7 @@ ${source}` : source ?? "";
|
|
|
375844
375982
|
sql
|
|
375845
375983
|
};
|
|
375846
375984
|
} catch (error2) {
|
|
375847
|
-
if (error2 instanceof
|
|
375985
|
+
if (error2 instanceof MalloyError7) {
|
|
375848
375986
|
return {
|
|
375849
375987
|
problems: tagProblems([
|
|
375850
375988
|
...error2.problems,
|
|
@@ -375897,7 +376035,7 @@ ${source}` : source ?? "";
|
|
|
375897
376035
|
try {
|
|
375898
376036
|
const destinationRoot = storageDestinationRoot(this.environmentPath);
|
|
375899
376037
|
if (this.destinations.length > 0) {
|
|
375900
|
-
|
|
376038
|
+
fs10.mkdirSync(destinationRoot, { recursive: true });
|
|
375901
376039
|
}
|
|
375902
376040
|
this.destinationMalloyConfig = buildEnvironmentMalloyConfig(this.destinations, destinationRoot);
|
|
375903
376041
|
} catch (error2) {
|
|
@@ -376018,7 +376156,7 @@ ${source}` : source ?? "";
|
|
|
376018
376156
|
}
|
|
376019
376157
|
async _readModelFileLocked(target) {
|
|
376020
376158
|
try {
|
|
376021
|
-
return await
|
|
376159
|
+
return await fs10.promises.readFile(target, "utf8");
|
|
376022
376160
|
} catch (error2) {
|
|
376023
376161
|
if (error2.code !== "ENOENT")
|
|
376024
376162
|
throw error2;
|
|
@@ -376026,10 +376164,10 @@ ${source}` : source ?? "";
|
|
|
376026
376164
|
}
|
|
376027
376165
|
}
|
|
376028
376166
|
async _writeModelFileLocked(target, source) {
|
|
376029
|
-
await
|
|
376167
|
+
await fs10.promises.mkdir(path12.dirname(target), { recursive: true });
|
|
376030
376168
|
const temporary = `${target}.${crypto4.randomUUID()}.tmp`;
|
|
376031
|
-
await
|
|
376032
|
-
await
|
|
376169
|
+
await fs10.promises.writeFile(temporary, source, "utf8");
|
|
376170
|
+
await fs10.promises.rename(temporary, target);
|
|
376033
376171
|
}
|
|
376034
376172
|
async writeModelFileTransactional(packageName, modelPath, source, check2, verify) {
|
|
376035
376173
|
assertSafePackageName(packageName);
|
|
@@ -376046,7 +376184,7 @@ ${source}` : source ?? "";
|
|
|
376046
376184
|
if (previous !== undefined)
|
|
376047
376185
|
await this._writeModelFileLocked(target, previous);
|
|
376048
376186
|
else
|
|
376049
|
-
await
|
|
376187
|
+
await fs10.promises.rm(target, { force: true });
|
|
376050
376188
|
await this._loadOrGetPackageLocked(packageName, true).catch(() => {
|
|
376051
376189
|
return;
|
|
376052
376190
|
});
|
|
@@ -376074,7 +376212,7 @@ ${source}` : source ?? "";
|
|
|
376074
376212
|
if (path12.basename(dir) !== dirName)
|
|
376075
376213
|
continue;
|
|
376076
376214
|
try {
|
|
376077
|
-
await
|
|
376215
|
+
await fs10.promises.rm(dir, { recursive: true, force: true });
|
|
376078
376216
|
} catch (err) {
|
|
376079
376217
|
logger.warn(`Failed to sweep stale ${dirName} dir at ${dir}`, {
|
|
376080
376218
|
error: err
|
|
@@ -376180,7 +376318,7 @@ ${source}` : source ?? "";
|
|
|
376180
376318
|
async addPackage(packageName, options = {}) {
|
|
376181
376319
|
assertSafePackageName(packageName);
|
|
376182
376320
|
const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
376183
|
-
if (!await
|
|
376321
|
+
if (!await fs10.promises.access(packagePath).then(() => true).catch(() => false) || !(await fs10.promises.stat(packagePath))?.isDirectory()) {
|
|
376184
376322
|
throw new PackageNotFoundError(`Package ${packageName} not found`);
|
|
376185
376323
|
}
|
|
376186
376324
|
this.assertCanAdmitNewPackage(packageName, "add a new package", options.allowAdmission === true);
|
|
@@ -376212,7 +376350,7 @@ ${source}` : source ?? "";
|
|
|
376212
376350
|
async installPackage(packageName, downloader, validate) {
|
|
376213
376351
|
assertSafePackageName(packageName);
|
|
376214
376352
|
const stagingPath = this.allocateStagingPath(packageName);
|
|
376215
|
-
await
|
|
376353
|
+
await fs10.promises.mkdir(path12.dirname(stagingPath), { recursive: true });
|
|
376216
376354
|
logger.debug("install.phase1.download.started", {
|
|
376217
376355
|
environmentName: this.environmentName,
|
|
376218
376356
|
packageName,
|
|
@@ -376222,7 +376360,7 @@ ${source}` : source ?? "";
|
|
|
376222
376360
|
try {
|
|
376223
376361
|
await downloader(stagingPath);
|
|
376224
376362
|
} catch (err) {
|
|
376225
|
-
await
|
|
376363
|
+
await fs10.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
|
|
376226
376364
|
throw err;
|
|
376227
376365
|
}
|
|
376228
376366
|
logger.debug("install.phase1.download.completed", {
|
|
@@ -376238,13 +376376,13 @@ ${source}` : source ?? "";
|
|
|
376238
376376
|
const canonicalPath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
376239
376377
|
let retiredPath;
|
|
376240
376378
|
const oldPackage = this.packages.get(packageName);
|
|
376241
|
-
const oldExistsOnDisk = await
|
|
376379
|
+
const oldExistsOnDisk = await fs10.promises.access(canonicalPath).then(() => true).catch(() => false);
|
|
376242
376380
|
if (oldExistsOnDisk) {
|
|
376243
376381
|
retiredPath = this.allocateRetiredPath(packageName);
|
|
376244
|
-
await
|
|
376382
|
+
await fs10.promises.mkdir(path12.dirname(retiredPath), {
|
|
376245
376383
|
recursive: true
|
|
376246
376384
|
});
|
|
376247
|
-
await
|
|
376385
|
+
await fs10.promises.rename(canonicalPath, retiredPath);
|
|
376248
376386
|
logger.debug("install.phase2.retired_old", {
|
|
376249
376387
|
environmentName: this.environmentName,
|
|
376250
376388
|
packageName,
|
|
@@ -376253,7 +376391,7 @@ ${source}` : source ?? "";
|
|
|
376253
376391
|
}
|
|
376254
376392
|
let newPackage;
|
|
376255
376393
|
try {
|
|
376256
|
-
await
|
|
376394
|
+
await fs10.promises.rename(stagingPath, canonicalPath);
|
|
376257
376395
|
this.setPackageStatus(packageName, "loading" /* LOADING */);
|
|
376258
376396
|
newPackage = await Package.create(this.environmentName, packageName, canonicalPath, () => this.malloyConfig.malloyConfig, true);
|
|
376259
376397
|
this.attachDestinationServeConfig(newPackage);
|
|
@@ -376267,11 +376405,11 @@ ${source}` : source ?? "";
|
|
|
376267
376405
|
canonicalPath
|
|
376268
376406
|
});
|
|
376269
376407
|
} catch (err) {
|
|
376270
|
-
await
|
|
376408
|
+
await fs10.promises.rm(canonicalPath, { recursive: true, force: true }).catch(() => {});
|
|
376271
376409
|
let restored = false;
|
|
376272
376410
|
if (retiredPath) {
|
|
376273
376411
|
try {
|
|
376274
|
-
await
|
|
376412
|
+
await fs10.promises.rename(retiredPath, canonicalPath);
|
|
376275
376413
|
restored = true;
|
|
376276
376414
|
} catch (restoreErr) {
|
|
376277
376415
|
logger.error("Failed to restore retired package after install rollback", {
|
|
@@ -376281,7 +376419,7 @@ ${source}` : source ?? "";
|
|
|
376281
376419
|
});
|
|
376282
376420
|
}
|
|
376283
376421
|
}
|
|
376284
|
-
await
|
|
376422
|
+
await fs10.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
|
|
376285
376423
|
if (oldPackage && restored) {
|
|
376286
376424
|
this.setPackageStatus(packageName, "serving" /* SERVING */);
|
|
376287
376425
|
} else {
|
|
@@ -376315,7 +376453,7 @@ ${source}` : source ?? "";
|
|
|
376315
376453
|
packageName,
|
|
376316
376454
|
retiredPath: pathToClean
|
|
376317
376455
|
});
|
|
376318
|
-
|
|
376456
|
+
fs10.promises.rm(pathToClean, { recursive: true, force: true }).catch((err) => {
|
|
376319
376457
|
logger.warn(`Failed to clean up retired package directory ${pathToClean}`, { error: err });
|
|
376320
376458
|
});
|
|
376321
376459
|
});
|
|
@@ -376448,7 +376586,7 @@ ${source}` : source ?? "";
|
|
|
376448
376586
|
try {
|
|
376449
376587
|
let existingManifest = {};
|
|
376450
376588
|
try {
|
|
376451
|
-
const content = await
|
|
376589
|
+
const content = await fs10.promises.readFile(manifestPath, "utf-8");
|
|
376452
376590
|
existingManifest = JSON.parse(content);
|
|
376453
376591
|
} catch (_err) {
|
|
376454
376592
|
logger.warn(`Could not read manifest for ${packageName}`);
|
|
@@ -376476,11 +376614,11 @@ ${source}` : source ?? "";
|
|
|
376476
376614
|
}
|
|
376477
376615
|
} : {}
|
|
376478
376616
|
};
|
|
376479
|
-
await
|
|
376617
|
+
await fs10.promises.writeFile(manifestPath, JSON.stringify(updatedManifest, null, 2), "utf-8");
|
|
376480
376618
|
logger.info(`Updated publisher.json for ${packageName}`);
|
|
376481
376619
|
} catch (error2) {
|
|
376482
376620
|
logger.error(`Failed to update publisher.json`, { error: error2 });
|
|
376483
|
-
throw new Error(`Failed to update package manifest
|
|
376621
|
+
throw new Error(`Failed to update package manifest`, { cause: error2 });
|
|
376484
376622
|
}
|
|
376485
376623
|
}
|
|
376486
376624
|
async updatePackage(packageName, body) {
|
|
@@ -376562,9 +376700,25 @@ ${source}` : source ?? "";
|
|
|
376562
376700
|
setPackageMountError(packageName, message) {
|
|
376563
376701
|
this.mountErrors.set(packageName, message);
|
|
376564
376702
|
}
|
|
376703
|
+
recordPackageAddFailure(packageName, message) {
|
|
376704
|
+
if (this.packageStatuses.has(packageName))
|
|
376705
|
+
return;
|
|
376706
|
+
if (!this.mountErrors.has(packageName)) {
|
|
376707
|
+
this.recordedAddFailures.push(packageName);
|
|
376708
|
+
while (this.recordedAddFailures.length > MAX_RECORDED_ADD_FAILURES) {
|
|
376709
|
+
const evicted = this.recordedAddFailures.shift();
|
|
376710
|
+
if (evicted !== undefined)
|
|
376711
|
+
this.mountErrors.delete(evicted);
|
|
376712
|
+
}
|
|
376713
|
+
}
|
|
376714
|
+
this.mountErrors.set(packageName, message);
|
|
376715
|
+
}
|
|
376565
376716
|
clearPackageLoadFailure(packageName) {
|
|
376566
376717
|
this.failedPackages.delete(packageName);
|
|
376567
376718
|
this.mountErrors.delete(packageName);
|
|
376719
|
+
const recorded = this.recordedAddFailures.indexOf(packageName);
|
|
376720
|
+
if (recorded !== -1)
|
|
376721
|
+
this.recordedAddFailures.splice(recorded, 1);
|
|
376568
376722
|
this.staleCompileErrors.delete(packageName);
|
|
376569
376723
|
}
|
|
376570
376724
|
getFailedPackages() {
|
|
@@ -376609,10 +376763,10 @@ ${source}` : source ?? "";
|
|
|
376609
376763
|
const retiredPath = this.allocateRetiredPath(packageName);
|
|
376610
376764
|
let renamed = false;
|
|
376611
376765
|
try {
|
|
376612
|
-
await
|
|
376766
|
+
await fs10.promises.mkdir(path12.dirname(retiredPath), {
|
|
376613
376767
|
recursive: true
|
|
376614
376768
|
});
|
|
376615
|
-
await
|
|
376769
|
+
await fs10.promises.rename(canonicalPath, retiredPath);
|
|
376616
376770
|
renamed = true;
|
|
376617
376771
|
} catch (err) {
|
|
376618
376772
|
logger.error("Error renaming package directory to retired during unload", {
|
|
@@ -376625,7 +376779,7 @@ ${source}` : source ?? "";
|
|
|
376625
376779
|
this.packageStatuses.delete(packageName);
|
|
376626
376780
|
if (renamed) {
|
|
376627
376781
|
setImmediate(() => {
|
|
376628
|
-
|
|
376782
|
+
fs10.promises.rm(retiredPath, { recursive: true, force: true }).catch((err) => {
|
|
376629
376783
|
logger.warn(`Failed to clean up retired package directory ${retiredPath}`, { error: err });
|
|
376630
376784
|
});
|
|
376631
376785
|
});
|
|
@@ -376711,7 +376865,7 @@ ${source}` : source ?? "";
|
|
|
376711
376865
|
async deleteDuckDBConnection(connectionName) {
|
|
376712
376866
|
const duckdbPath = path12.join(this.environmentPath, `${connectionName}.duckdb`);
|
|
376713
376867
|
try {
|
|
376714
|
-
await
|
|
376868
|
+
await fs10.promises.rm(duckdbPath, { force: true });
|
|
376715
376869
|
logger.info(`Removed DuckDB connection file ${connectionName} from environment ${this.environmentName}`);
|
|
376716
376870
|
} catch (error2) {
|
|
376717
376871
|
logger.error(`Failed to remove DuckDB connection file ${connectionName} from environment ${this.environmentName}`, { error: error2 });
|
|
@@ -376722,8 +376876,11 @@ ${source}` : source ?? "";
|
|
|
376722
376876
|
logger.info(`Removed DuckLake connection ${connectionName} from environment ${this.environmentName}`);
|
|
376723
376877
|
}
|
|
376724
376878
|
}
|
|
376879
|
+
|
|
376880
|
+
// src/service/environment_store.ts
|
|
376881
|
+
init_google_credentials();
|
|
376725
376882
|
// package.json
|
|
376726
|
-
var version2 = "0.
|
|
376883
|
+
var version2 = "0.9.1";
|
|
376727
376884
|
|
|
376728
376885
|
// src/version.ts
|
|
376729
376886
|
var SERVER_VERSION = version2;
|
|
@@ -376777,11 +376934,11 @@ function validateEnvironmentAzureUrls(environment) {
|
|
|
376777
376934
|
}
|
|
376778
376935
|
async function clearMountTarget(targetPath) {
|
|
376779
376936
|
try {
|
|
376780
|
-
const stats = await
|
|
376937
|
+
const stats = await fs11.promises.lstat(targetPath);
|
|
376781
376938
|
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
376782
|
-
await
|
|
376939
|
+
await fs11.promises.rm(targetPath, { recursive: true, force: true });
|
|
376783
376940
|
} else {
|
|
376784
|
-
await
|
|
376941
|
+
await fs11.promises.unlink(targetPath);
|
|
376785
376942
|
}
|
|
376786
376943
|
} catch {}
|
|
376787
376944
|
}
|
|
@@ -376953,6 +377110,7 @@ class EnvironmentStore {
|
|
|
376953
377110
|
environments = new Map;
|
|
376954
377111
|
failedEnvironments = new Map;
|
|
376955
377112
|
unconfiguredNotice = null;
|
|
377113
|
+
initError = null;
|
|
376956
377114
|
environmentMutexes = new Map;
|
|
376957
377115
|
publisherConfigIsFrozen;
|
|
376958
377116
|
finishedInitialization;
|
|
@@ -377036,7 +377194,7 @@ class EnvironmentStore {
|
|
|
377036
377194
|
if (existingEnvironments.length > 0) {
|
|
377037
377195
|
await Promise.all(existingEnvironments.map(async (dbEnvironment) => {
|
|
377038
377196
|
try {
|
|
377039
|
-
const environmentExists = await
|
|
377197
|
+
const environmentExists = await fs11.promises.access(dbEnvironment.path).then(() => true).catch(() => false);
|
|
377040
377198
|
const environmentConfig = environmentManifest.environments.find((p2) => p2.name === dbEnvironment.name);
|
|
377041
377199
|
if (!environmentExists) {
|
|
377042
377200
|
if (environmentConfig) {
|
|
@@ -377112,6 +377270,7 @@ class EnvironmentStore {
|
|
|
377112
377270
|
this.emitReadinessLine();
|
|
377113
377271
|
} catch (error2) {
|
|
377114
377272
|
markNotReady();
|
|
377273
|
+
this.initError = redactPgSecrets(messageWithFilesystemCause(error2));
|
|
377115
377274
|
const errorData = this.extractErrorDataFromError(error2);
|
|
377116
377275
|
logger.error("Error initializing environment store", errorData);
|
|
377117
377276
|
try {
|
|
@@ -377404,12 +377563,12 @@ class EnvironmentStore {
|
|
|
377404
377563
|
}
|
|
377405
377564
|
async cleanupAndCreatePublisherPath() {
|
|
377406
377565
|
const reInit = process.env.INITIALIZE_STORAGE === "true";
|
|
377407
|
-
await
|
|
377566
|
+
await fs11.promises.mkdir(this.serverRootPath, { recursive: true });
|
|
377408
377567
|
if (reInit) {
|
|
377409
377568
|
const uploadDocsPath2 = path13.join(this.serverRootPath, PUBLISHER_DATA_DIR);
|
|
377410
377569
|
logger.info(`Reinitialization mode: Cleaning up upload documents path ${uploadDocsPath2}`);
|
|
377411
377570
|
try {
|
|
377412
|
-
await
|
|
377571
|
+
await fs11.promises.rm(uploadDocsPath2, {
|
|
377413
377572
|
recursive: true,
|
|
377414
377573
|
force: true
|
|
377415
377574
|
});
|
|
@@ -377424,7 +377583,7 @@ class EnvironmentStore {
|
|
|
377424
377583
|
logger.info(`Using existing publisher path`);
|
|
377425
377584
|
}
|
|
377426
377585
|
const uploadDocsPath = path13.join(this.serverRootPath, PUBLISHER_DATA_DIR);
|
|
377427
|
-
await
|
|
377586
|
+
await fs11.promises.mkdir(uploadDocsPath, { recursive: true });
|
|
377428
377587
|
}
|
|
377429
377588
|
async listEnvironments(skipInitializationCheck = false) {
|
|
377430
377589
|
if (!skipInitializationCheck) {
|
|
@@ -377502,6 +377661,9 @@ class EnvironmentStore {
|
|
|
377502
377661
|
if (this.unconfiguredNotice && status.environments.length === 0) {
|
|
377503
377662
|
status.emptyReason = this.unconfiguredNotice;
|
|
377504
377663
|
}
|
|
377664
|
+
if (this.initError) {
|
|
377665
|
+
status.initError = this.initError;
|
|
377666
|
+
}
|
|
377505
377667
|
return status;
|
|
377506
377668
|
}
|
|
377507
377669
|
async getEnvironment(environmentName, reload = false) {
|
|
@@ -377574,9 +377736,6 @@ class EnvironmentStore {
|
|
|
377574
377736
|
const loaded = await this.loadEnvironmentIntoDisk(environmentName, packagesToProcess);
|
|
377575
377737
|
absoluteEnvironmentPath = loaded.path;
|
|
377576
377738
|
mountErrors = loaded.mountErrors;
|
|
377577
|
-
if (absoluteEnvironmentPath.endsWith(".zip")) {
|
|
377578
|
-
absoluteEnvironmentPath = await this.unzipEnvironment(absoluteEnvironmentPath);
|
|
377579
|
-
}
|
|
377580
377739
|
} else {
|
|
377581
377740
|
absoluteEnvironmentPath = await this.scaffoldEnvironment(environment);
|
|
377582
377741
|
}
|
|
@@ -377603,41 +377762,44 @@ class EnvironmentStore {
|
|
|
377603
377762
|
}
|
|
377604
377763
|
async unzipEnvironment(absoluteEnvironmentPath) {
|
|
377605
377764
|
assertSafeEnvironmentPath(absoluteEnvironmentPath);
|
|
377606
|
-
const startedAt = Date.now();
|
|
377607
|
-
logger.info(`Detected zip file at "${absoluteEnvironmentPath}". Unzipping...`);
|
|
377608
377765
|
const archiveDir = path13.resolve(path13.dirname(absoluteEnvironmentPath));
|
|
377609
377766
|
const unzippedEnvironmentPath = path13.resolve(archiveDir, path13.basename(absoluteEnvironmentPath, ".zip"));
|
|
377610
377767
|
if (!unzippedEnvironmentPath.startsWith(archiveDir + path13.sep)) {
|
|
377611
377768
|
throw new BadRequestError(`Refusing to unzip "${absoluteEnvironmentPath}": target escapes its directory`);
|
|
377612
377769
|
}
|
|
377613
|
-
await
|
|
377770
|
+
await this.extractZipInto(absoluteEnvironmentPath, unzippedEnvironmentPath);
|
|
377771
|
+
return unzippedEnvironmentPath;
|
|
377772
|
+
}
|
|
377773
|
+
async extractZipInto(archivePath, targetDir) {
|
|
377774
|
+
const startedAt = Date.now();
|
|
377775
|
+
logger.info(`Detected zip file at "${archivePath}". Unzipping...`);
|
|
377776
|
+
await fs11.promises.rm(targetDir, {
|
|
377614
377777
|
recursive: true,
|
|
377615
377778
|
force: true
|
|
377616
377779
|
});
|
|
377617
|
-
await
|
|
377780
|
+
await fs11.promises.mkdir(targetDir, { recursive: true });
|
|
377618
377781
|
let entryCount = 0;
|
|
377619
377782
|
let totalUncompressedBytes = 0;
|
|
377620
377783
|
try {
|
|
377621
|
-
await import_extract_zip.default(
|
|
377622
|
-
dir: path13.resolve(
|
|
377784
|
+
await import_extract_zip.default(archivePath, {
|
|
377785
|
+
dir: path13.resolve(targetDir),
|
|
377623
377786
|
onEntry: (entry) => {
|
|
377624
377787
|
if ((entry.externalFileAttributes >>> 16 & 61440) === 40960) {
|
|
377625
|
-
throw new BadRequestError(`Refusing to unzip "${
|
|
377788
|
+
throw new BadRequestError(`Refusing to unzip "${archivePath}": entry "${entry.fileName}" is a symbolic link`);
|
|
377626
377789
|
}
|
|
377627
377790
|
entryCount += 1;
|
|
377628
377791
|
totalUncompressedBytes += entry.uncompressedSize ?? 0;
|
|
377629
377792
|
}
|
|
377630
377793
|
});
|
|
377631
377794
|
} catch (error2) {
|
|
377632
|
-
await
|
|
377795
|
+
await fs11.promises.rm(targetDir, {
|
|
377633
377796
|
recursive: true,
|
|
377634
377797
|
force: true
|
|
377635
377798
|
});
|
|
377636
377799
|
throw error2;
|
|
377637
377800
|
}
|
|
377638
377801
|
const mib = (totalUncompressedBytes / (1024 * 1024)).toFixed(1);
|
|
377639
|
-
logger.info(`Unzipped "${
|
|
377640
|
-
return unzippedEnvironmentPath;
|
|
377802
|
+
logger.info(`Unzipped "${archivePath}" -> "${targetDir}" ` + `(${entryCount} entries, ${mib} MiB uncompressed) in ` + `${formatDuration(Date.now() - startedAt)}`);
|
|
377641
377803
|
}
|
|
377642
377804
|
async updateEnvironment(environment) {
|
|
377643
377805
|
await this.finishedInitialization;
|
|
@@ -377675,7 +377837,7 @@ class EnvironmentStore {
|
|
|
377675
377837
|
await this.deleteEnvironmentFromDatabase(environmentName);
|
|
377676
377838
|
if (environmentPath) {
|
|
377677
377839
|
try {
|
|
377678
|
-
await
|
|
377840
|
+
await fs11.promises.rm(environmentPath, {
|
|
377679
377841
|
recursive: true,
|
|
377680
377842
|
force: true
|
|
377681
377843
|
});
|
|
@@ -377696,7 +377858,7 @@ class EnvironmentStore {
|
|
|
377696
377858
|
throw new PublisherConfigError(PUBLISHER_CONFIG_NAME, error2);
|
|
377697
377859
|
} else {
|
|
377698
377860
|
try {
|
|
377699
|
-
const entries = await
|
|
377861
|
+
const entries = await fs11.promises.readdir(serverRootPath, {
|
|
377700
377862
|
withFileTypes: true
|
|
377701
377863
|
});
|
|
377702
377864
|
const environments = [];
|
|
@@ -377729,9 +377891,9 @@ class EnvironmentStore {
|
|
|
377729
377891
|
assertSafePackageName(environment.name);
|
|
377730
377892
|
const environmentName = environment.name;
|
|
377731
377893
|
const absoluteEnvironmentPath = safeJoinUnderRoot(this.serverRootPath, PUBLISHER_DATA_DIR, environmentName);
|
|
377732
|
-
await
|
|
377894
|
+
await fs11.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
|
|
377733
377895
|
if (environment.readme) {
|
|
377734
|
-
await
|
|
377896
|
+
await fs11.promises.writeFile(safeJoinUnderRoot(absoluteEnvironmentPath, "README.md"), environment.readme);
|
|
377735
377897
|
}
|
|
377736
377898
|
return absoluteEnvironmentPath;
|
|
377737
377899
|
}
|
|
@@ -377752,7 +377914,7 @@ class EnvironmentStore {
|
|
|
377752
377914
|
}
|
|
377753
377915
|
recordMountFailure(error2, location, packageNames, mountErrors) {
|
|
377754
377916
|
logger.error(`Failed to download or mount location "${location}"`, this.extractErrorDataFromError(error2));
|
|
377755
|
-
const message = redactPgSecrets(
|
|
377917
|
+
const message = redactPgSecrets(messageWithFilesystemCause(error2));
|
|
377756
377918
|
for (const packageName of packageNames) {
|
|
377757
377919
|
mountErrors.set(packageName, message);
|
|
377758
377920
|
}
|
|
@@ -377761,7 +377923,7 @@ class EnvironmentStore {
|
|
|
377761
377923
|
assertSafePackageName(environmentName);
|
|
377762
377924
|
const absoluteTargetPath = safeJoinUnderRoot(this.serverRootPath, PUBLISHER_DATA_DIR, environmentName);
|
|
377763
377925
|
const mountErrors = new Map;
|
|
377764
|
-
await
|
|
377926
|
+
await fs11.promises.mkdir(absoluteTargetPath, { recursive: true });
|
|
377765
377927
|
if (!packages || packages.length === 0) {
|
|
377766
377928
|
throw new PackageNotFoundError(`No packages found for environment ${environmentName}`);
|
|
377767
377929
|
}
|
|
@@ -377793,7 +377955,7 @@ class EnvironmentStore {
|
|
|
377793
377955
|
for (const [groupedLocation, packagesForLocation] of locationGroups) {
|
|
377794
377956
|
const locationHash = crypto5.createHash("sha256").update(groupedLocation).digest("hex").substring(0, 16);
|
|
377795
377957
|
const tempDownloadPath = safeJoinUnderRoot(absoluteTargetPath, `.temp_${locationHash}`);
|
|
377796
|
-
await
|
|
377958
|
+
await fs11.promises.mkdir(tempDownloadPath, { recursive: true });
|
|
377797
377959
|
logger.info(`Created temporary directory: ${tempDownloadPath}`);
|
|
377798
377960
|
let downloaded = true;
|
|
377799
377961
|
try {
|
|
@@ -377808,6 +377970,7 @@ class EnvironmentStore {
|
|
|
377808
377970
|
const packageDir = _package.name;
|
|
377809
377971
|
assertSafePackageName(packageDir);
|
|
377810
377972
|
absolutePackagePath = safeJoinUnderRoot(absoluteTargetPath, packageDir);
|
|
377973
|
+
const isLocalArchive = this.isLocalPath(_package.location) && _package.location.endsWith(".zip");
|
|
377811
377974
|
let sourcePath;
|
|
377812
377975
|
if (this.isGitHubURL(_package.location)) {
|
|
377813
377976
|
const githubInfo = parseGitHubUrl(_package.location);
|
|
@@ -377822,30 +377985,32 @@ class EnvironmentStore {
|
|
|
377822
377985
|
sourcePath = tempDownloadPath;
|
|
377823
377986
|
}
|
|
377824
377987
|
} else {
|
|
377825
|
-
if (
|
|
377988
|
+
if (isLocalArchive) {
|
|
377989
|
+
sourcePath = tempDownloadPath;
|
|
377990
|
+
} else if (this.isLocalPath(_package.location)) {
|
|
377826
377991
|
sourcePath = this.resolveLocalPath(_package.location);
|
|
377827
377992
|
} else {
|
|
377828
377993
|
sourcePath = safeJoinUnderRoot(tempDownloadPath, groupedLocation);
|
|
377829
377994
|
}
|
|
377830
377995
|
}
|
|
377831
|
-
const sourceExists = await
|
|
377996
|
+
const sourceExists = await fs11.promises.access(sourcePath).then(() => true).catch(() => false);
|
|
377832
377997
|
if (sourceExists) {
|
|
377833
|
-
const isInPlace = this.inPlaceEnvs.has(environmentName) && this.isLocalPath(_package.location);
|
|
377998
|
+
const isInPlace = this.inPlaceEnvs.has(environmentName) && this.isLocalPath(_package.location) && !isLocalArchive;
|
|
377834
377999
|
if (isInPlace) {
|
|
377835
378000
|
await clearMountTarget(absolutePackagePath);
|
|
377836
378001
|
const absoluteSourcePath = path13.resolve(sourcePath);
|
|
377837
378002
|
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
377838
378003
|
try {
|
|
377839
|
-
await
|
|
378004
|
+
await fs11.promises.symlink(absoluteSourcePath, absolutePackagePath, linkType);
|
|
377840
378005
|
logger.info(`In-place mount (watch mode): linked package "${packageDir}" -> "${absoluteSourcePath}" (${++mountedCount}/${totalPackages})`);
|
|
377841
378006
|
} catch (linkError) {
|
|
377842
378007
|
const code = linkError?.code ?? String(linkError);
|
|
377843
378008
|
logger.warn(`In-place mount failed for package "${packageDir}" (${code}); falling back to a copy. Source-edit live reload is disabled for this package.`);
|
|
377844
378009
|
await clearMountTarget(absolutePackagePath);
|
|
377845
|
-
await
|
|
378010
|
+
await fs11.promises.mkdir(absolutePackagePath, {
|
|
377846
378011
|
recursive: true
|
|
377847
378012
|
});
|
|
377848
|
-
await
|
|
378013
|
+
await fs11.promises.cp(sourcePath, absolutePackagePath, {
|
|
377849
378014
|
recursive: true
|
|
377850
378015
|
});
|
|
377851
378016
|
logger.info(`Copied package "${packageDir}" (${++mountedCount}/${totalPackages})`);
|
|
@@ -377853,22 +378018,24 @@ class EnvironmentStore {
|
|
|
377853
378018
|
} else {
|
|
377854
378019
|
if (this.inPlaceEnvs.has(environmentName) && !this.isLocalPath(_package.location)) {
|
|
377855
378020
|
logger.warn(`Watch mode: package "${packageDir}" has remote location "${_package.location}" — falling back to copy. Source-edit live reload won't work for this package; clone the source locally and use a local-dir location to enable it.`);
|
|
378021
|
+
} else if (this.inPlaceEnvs.has(environmentName) && isLocalArchive) {
|
|
378022
|
+
logger.warn(`Watch mode: package "${packageDir}" is an archive "${_package.location}" — extracted and copied. Source-edit live reload won't work for this package; use an unpacked local-dir location to enable it.`);
|
|
377856
378023
|
}
|
|
377857
378024
|
await clearMountTarget(absolutePackagePath);
|
|
377858
|
-
await
|
|
378025
|
+
await fs11.promises.mkdir(absolutePackagePath, {
|
|
377859
378026
|
recursive: true
|
|
377860
378027
|
});
|
|
377861
|
-
await
|
|
378028
|
+
await fs11.promises.cp(sourcePath, absolutePackagePath, {
|
|
377862
378029
|
recursive: true
|
|
377863
378030
|
});
|
|
377864
378031
|
logger.info(`Extracted package "${packageDir}" from ${groupedLocation.startsWith("https://github.com/") && _package.location.includes("/tree/") ? "GitHub subdirectory" : "shared download"} (${++mountedCount}/${totalPackages})`);
|
|
377865
378032
|
}
|
|
377866
378033
|
} else {
|
|
377867
378034
|
await clearMountTarget(absolutePackagePath);
|
|
377868
|
-
await
|
|
378035
|
+
await fs11.promises.mkdir(absolutePackagePath, {
|
|
377869
378036
|
recursive: true
|
|
377870
378037
|
});
|
|
377871
|
-
await
|
|
378038
|
+
await fs11.promises.cp(tempDownloadPath, absolutePackagePath, {
|
|
377872
378039
|
recursive: true
|
|
377873
378040
|
});
|
|
377874
378041
|
logger.info(`Copied entire download as package "${packageDir}" (${++mountedCount}/${totalPackages})`);
|
|
@@ -377881,7 +378048,7 @@ class EnvironmentStore {
|
|
|
377881
378048
|
}
|
|
377882
378049
|
}
|
|
377883
378050
|
try {
|
|
377884
|
-
await
|
|
378051
|
+
await fs11.promises.rm(tempDownloadPath, {
|
|
377885
378052
|
recursive: true,
|
|
377886
378053
|
force: true
|
|
377887
378054
|
});
|
|
@@ -377903,7 +378070,7 @@ class EnvironmentStore {
|
|
|
377903
378070
|
} catch (error2) {
|
|
377904
378071
|
const errorData = this.extractErrorDataFromError(error2);
|
|
377905
378072
|
logger.error(`Failed to download GCS directory "${location}"`, errorData);
|
|
377906
|
-
throw new PackageNotFoundError(`Failed to download GCS directory: ${location}
|
|
378073
|
+
throw new PackageNotFoundError(`Failed to download GCS directory: ${location}`, { cause: error2 });
|
|
377907
378074
|
}
|
|
377908
378075
|
}
|
|
377909
378076
|
if (this.isGitHubURL(location)) {
|
|
@@ -377917,7 +378084,7 @@ class EnvironmentStore {
|
|
|
377917
378084
|
} catch (error2) {
|
|
377918
378085
|
const errorData = this.extractErrorDataFromError(error2);
|
|
377919
378086
|
logger.error(`Failed to clone GitHub repository "${location}"`, errorData);
|
|
377920
|
-
throw new PackageNotFoundError(`Failed to clone GitHub repository: ${location}
|
|
378087
|
+
throw new PackageNotFoundError(`Failed to clone GitHub repository: ${location}`, { cause: error2 });
|
|
377921
378088
|
}
|
|
377922
378089
|
}
|
|
377923
378090
|
if (this.isS3URL(location)) {
|
|
@@ -377928,7 +378095,7 @@ class EnvironmentStore {
|
|
|
377928
378095
|
} catch (error2) {
|
|
377929
378096
|
const errorData = this.extractErrorDataFromError(error2);
|
|
377930
378097
|
logger.error(`Failed to download S3 directory "${location}"`, errorData);
|
|
377931
|
-
throw new PackageNotFoundError(`Failed to download S3 directory: ${location}
|
|
378098
|
+
throw new PackageNotFoundError(`Failed to download S3 directory: ${location}`, { cause: error2 });
|
|
377932
378099
|
}
|
|
377933
378100
|
}
|
|
377934
378101
|
if (this.isLocalPath(location)) {
|
|
@@ -377940,7 +378107,7 @@ class EnvironmentStore {
|
|
|
377940
378107
|
} catch (error2) {
|
|
377941
378108
|
const errorData = this.extractErrorDataFromError(error2);
|
|
377942
378109
|
logger.error(`Failed to mount local directory "${packagePath}"`, errorData);
|
|
377943
|
-
throw new PackageNotFoundError(`Failed to mount local directory: ${packagePath}
|
|
378110
|
+
throw new PackageNotFoundError(`Failed to mount local directory: ${packagePath}`, { cause: error2 });
|
|
377944
378111
|
}
|
|
377945
378112
|
}
|
|
377946
378113
|
const errorMsg = `Invalid package path: "${location}". Must be an absolute mounted path or a GCS/S3/GitHub URI.`;
|
|
@@ -377949,25 +378116,31 @@ class EnvironmentStore {
|
|
|
377949
378116
|
}
|
|
377950
378117
|
async mountLocalDirectory(environmentPath, absoluteTargetPath, environmentName, packageName) {
|
|
377951
378118
|
assertSafeEnvironmentPath(absoluteTargetPath);
|
|
377952
|
-
|
|
377953
|
-
|
|
377954
|
-
|
|
377955
|
-
|
|
377956
|
-
|
|
377957
|
-
|
|
378119
|
+
const notFound = () => new PackageNotFoundError(`Package ${packageName} for environment ${environmentName} not found in "${environmentPath}"`);
|
|
378120
|
+
const source = await fs11.promises.stat(environmentPath).catch((error2) => {
|
|
378121
|
+
if (error2.code === "ENOENT") {
|
|
378122
|
+
throw notFound();
|
|
378123
|
+
}
|
|
378124
|
+
throw error2;
|
|
378125
|
+
});
|
|
378126
|
+
if (environmentPath.endsWith(".zip") && source.isFile()) {
|
|
378127
|
+
await this.extractZipInto(environmentPath, absoluteTargetPath);
|
|
378128
|
+
} else if (source.isDirectory()) {
|
|
378129
|
+
await fs11.promises.rm(absoluteTargetPath, {
|
|
377958
378130
|
recursive: true,
|
|
377959
378131
|
force: true
|
|
377960
378132
|
});
|
|
377961
|
-
await
|
|
377962
|
-
await
|
|
378133
|
+
await fs11.promises.mkdir(absoluteTargetPath, { recursive: true });
|
|
378134
|
+
await fs11.promises.cp(environmentPath, absoluteTargetPath, {
|
|
377963
378135
|
recursive: true
|
|
377964
378136
|
});
|
|
377965
378137
|
} else {
|
|
377966
|
-
throw
|
|
378138
|
+
throw notFound();
|
|
377967
378139
|
}
|
|
377968
378140
|
}
|
|
377969
378141
|
async downloadGcsDirectory(gcsPath, environmentName, absoluteDirPath, isCompressedFile) {
|
|
377970
378142
|
assertSafeEnvironmentPath(absoluteDirPath);
|
|
378143
|
+
assertGoogleCredentialsIsNotADirectory();
|
|
377971
378144
|
const trimmedPath = gcsPath.slice(5);
|
|
377972
378145
|
const [bucketName, ...prefixParts] = trimmedPath.split("/");
|
|
377973
378146
|
const prefix = prefixParts.join("/");
|
|
@@ -377978,11 +378151,11 @@ class EnvironmentStore {
|
|
|
377978
378151
|
throw new EnvironmentNotFoundError(`Environment ${environmentName} not found in ${gcsPath}`);
|
|
377979
378152
|
}
|
|
377980
378153
|
if (!isCompressedFile) {
|
|
377981
|
-
await
|
|
378154
|
+
await fs11.promises.rm(absoluteDirPath, {
|
|
377982
378155
|
recursive: true,
|
|
377983
378156
|
force: true
|
|
377984
378157
|
});
|
|
377985
|
-
await
|
|
378158
|
+
await fs11.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
377986
378159
|
} else {
|
|
377987
378160
|
absoluteDirPath = `${absoluteDirPath}.zip`;
|
|
377988
378161
|
}
|
|
@@ -377992,10 +378165,10 @@ class EnvironmentStore {
|
|
|
377992
378165
|
if (file.name.endsWith("/")) {
|
|
377993
378166
|
return;
|
|
377994
378167
|
}
|
|
377995
|
-
await
|
|
378168
|
+
await fs11.promises.mkdir(path13.dirname(absoluteFilePath), {
|
|
377996
378169
|
recursive: true
|
|
377997
378170
|
});
|
|
377998
|
-
return
|
|
378171
|
+
return fs11.promises.writeFile(absoluteFilePath, await file.download());
|
|
377999
378172
|
}));
|
|
378000
378173
|
if (isCompressedFile) {
|
|
378001
378174
|
await this.unzipEnvironment(absoluteDirPath);
|
|
@@ -378009,7 +378182,7 @@ class EnvironmentStore {
|
|
|
378009
378182
|
const prefix = prefixParts.join("/");
|
|
378010
378183
|
if (isCompressedFile) {
|
|
378011
378184
|
const zipFilePath = `${absoluteDirPath}.zip`;
|
|
378012
|
-
await
|
|
378185
|
+
await fs11.promises.mkdir(path13.dirname(zipFilePath), {
|
|
378013
378186
|
recursive: true
|
|
378014
378187
|
});
|
|
378015
378188
|
const command = new import_client_s33.GetObjectCommand({
|
|
@@ -378020,7 +378193,7 @@ class EnvironmentStore {
|
|
|
378020
378193
|
if (!item.Body) {
|
|
378021
378194
|
throw new EnvironmentNotFoundError(`Environment ${environmentName} not found in ${s3Path}`);
|
|
378022
378195
|
}
|
|
378023
|
-
const file =
|
|
378196
|
+
const file = fs11.createWriteStream(zipFilePath);
|
|
378024
378197
|
item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
|
|
378025
378198
|
await new Promise((resolve6, reject2) => {
|
|
378026
378199
|
file.on("error", reject2);
|
|
@@ -378034,8 +378207,8 @@ class EnvironmentStore {
|
|
|
378034
378207
|
Bucket: bucketName,
|
|
378035
378208
|
Prefix: prefix
|
|
378036
378209
|
});
|
|
378037
|
-
await
|
|
378038
|
-
await
|
|
378210
|
+
await fs11.promises.rm(absoluteDirPath, { recursive: true, force: true });
|
|
378211
|
+
await fs11.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
378039
378212
|
if (!objects.Contents || objects.Contents.length === 0) {
|
|
378040
378213
|
throw new EnvironmentNotFoundError(`Environment ${environmentName} not found in ${s3Path}`);
|
|
378041
378214
|
}
|
|
@@ -378049,7 +378222,7 @@ class EnvironmentStore {
|
|
|
378049
378222
|
return;
|
|
378050
378223
|
}
|
|
378051
378224
|
const absoluteFilePath = safeJoinUnderRoot(absoluteDirPath, relativeFilePath);
|
|
378052
|
-
await
|
|
378225
|
+
await fs11.promises.mkdir(path13.dirname(absoluteFilePath), {
|
|
378053
378226
|
recursive: true
|
|
378054
378227
|
});
|
|
378055
378228
|
const command = new import_client_s33.GetObjectCommand({
|
|
@@ -378060,7 +378233,7 @@ class EnvironmentStore {
|
|
|
378060
378233
|
if (!item.Body) {
|
|
378061
378234
|
return;
|
|
378062
378235
|
}
|
|
378063
|
-
const file =
|
|
378236
|
+
const file = fs11.createWriteStream(absoluteFilePath);
|
|
378064
378237
|
item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
|
|
378065
378238
|
await new Promise((resolve6, reject2) => {
|
|
378066
378239
|
file.on("error", reject2);
|
|
@@ -378077,11 +378250,11 @@ class EnvironmentStore {
|
|
|
378077
378250
|
}
|
|
378078
378251
|
const { owner, repoName, packagePath } = githubInfo;
|
|
378079
378252
|
const cleanPackagePath = (packagePath?.replace("/tree/main", "") || "").replace(/^\/+/, "");
|
|
378080
|
-
await
|
|
378253
|
+
await fs11.promises.rm(absoluteDirPath, {
|
|
378081
378254
|
recursive: true,
|
|
378082
378255
|
force: true
|
|
378083
378256
|
});
|
|
378084
|
-
await
|
|
378257
|
+
await fs11.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
378085
378258
|
const repoUrl = `https://github.com/${owner}/${repoName}`;
|
|
378086
378259
|
const reporter = new CloneProgressReporter(cloneProgressLabel(`${owner}/${repoName}`, progressContext));
|
|
378087
378260
|
await new Promise((resolve6, reject2) => {
|
|
@@ -378107,24 +378280,24 @@ class EnvironmentStore {
|
|
|
378107
378280
|
return;
|
|
378108
378281
|
}
|
|
378109
378282
|
const packageFullPath = safeJoinUnderRoot(absoluteDirPath, cleanPackagePath);
|
|
378110
|
-
const packageExists = await
|
|
378283
|
+
const packageExists = await fs11.promises.access(packageFullPath).then(() => true).catch(() => false);
|
|
378111
378284
|
if (!packageExists) {
|
|
378112
378285
|
throw new Error(`Package path "${cleanPackagePath}" does not exist in the cloned repository.`);
|
|
378113
378286
|
}
|
|
378114
|
-
const dirContents = await
|
|
378287
|
+
const dirContents = await fs11.promises.readdir(absoluteDirPath);
|
|
378115
378288
|
for (const entry of dirContents) {
|
|
378116
378289
|
if (entry !== cleanPackagePath.replace(/^\/+/, "").split("/")[0]) {
|
|
378117
|
-
await
|
|
378290
|
+
await fs11.promises.rm(safeJoinUnderRoot(absoluteDirPath, entry), {
|
|
378118
378291
|
recursive: true,
|
|
378119
378292
|
force: true
|
|
378120
378293
|
});
|
|
378121
378294
|
}
|
|
378122
378295
|
}
|
|
378123
|
-
const packageContents = await
|
|
378296
|
+
const packageContents = await fs11.promises.readdir(packageFullPath);
|
|
378124
378297
|
for (const entry of packageContents) {
|
|
378125
|
-
await
|
|
378298
|
+
await fs11.promises.rename(safeJoinUnderRoot(packageFullPath, entry), safeJoinUnderRoot(absoluteDirPath, entry));
|
|
378126
378299
|
}
|
|
378127
|
-
await
|
|
378300
|
+
await fs11.promises.rm(packageFullPath, { recursive: true, force: true });
|
|
378128
378301
|
}
|
|
378129
378302
|
extractErrorDataFromError(error2) {
|
|
378130
378303
|
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
@@ -383336,7 +383509,8 @@ A JSON object with:
|
|
|
383336
383509
|
- version: this server's release version.
|
|
383337
383510
|
- environments: each environment's name with its loaded package names.
|
|
383338
383511
|
- emptyReason (only present when the server found no config at startup, or the --config path was missing): why environments is empty, and the path it checked. The server still reports serving in that state.
|
|
383339
|
-
-
|
|
383512
|
+
- initError (only present when startup failed): why. The server stays at "initializing" and never serves; the cause is usually a config file it cannot read or parse, or a server root it cannot write (publisher.db). A package or environment that failed to load is under loadErrors instead.
|
|
383513
|
+
- loadErrors (only present when something failed): entries of {environment, package?, message, stale?, failedAt?}. An entry WITHOUT stale means the package (or whole environment, when package is absent) did not load and is missing from environments; that includes a package add that failed on the server's side. An entry WITH stale: true means the package IS serving, but its most recent reload failed to compile, so the model answering queries is OLDER than the files on disk; the message says why. Fix the file and reload (reload_package) to clear it.
|
|
383340
383514
|
|
|
383341
383515
|
No loadErrors key means everything configured loaded and nothing is stale.`;
|
|
383342
383516
|
function registerGetStatusTool(mcpServer, environmentStore) {
|
|
@@ -383355,6 +383529,9 @@ function registerGetStatusTool(mcpServer, environmentStore) {
|
|
|
383355
383529
|
...status.emptyReason !== undefined && {
|
|
383356
383530
|
emptyReason: status.emptyReason
|
|
383357
383531
|
},
|
|
383532
|
+
...status.initError !== undefined && {
|
|
383533
|
+
initError: status.initError
|
|
383534
|
+
},
|
|
383358
383535
|
...status.loadErrors !== undefined && {
|
|
383359
383536
|
loadErrors: status.loadErrors
|
|
383360
383537
|
}
|
|
@@ -383628,7 +383805,8 @@ otherwise a modelling agent is dispatched to fix a model that is already right.
|
|
|
383628
383805
|
|
|
383629
383806
|
## Step 6: Append events, then stop
|
|
383630
383807
|
|
|
383631
|
-
Append to
|
|
383808
|
+
Append to \`<workdir>/runs/<runId>/events.jsonl\` with \`caseId\` set (the run
|
|
383809
|
+
directory \`eval.py run\` printed; the workdir is the set's, from \`eval.toml\`). Shapes
|
|
383632
383810
|
live in \`reference/ledger-schema.md\`.
|
|
383633
383811
|
|
|
383634
383812
|
1. \`attempt\`: qid, sample, phase, question_sha, submitted, final_query,
|
|
@@ -384203,7 +384381,7 @@ a permanent parallel mechanism.`
|
|
|
384203
384381
|
{
|
|
384204
384382
|
name: "eval-answer/ledger-schema",
|
|
384205
384383
|
description: "The eval ledger: files and events. Reference detail for the eval-answer skill.",
|
|
384206
|
-
body: "# The eval ledger: files and events\n\nThe ledger is plain files in the model package's git repository. There is no\neval API and no eval database. The conductor (`skill:eval-loop`) reads and\nwrites these files directly; the stages share them as their contract.\n`eval-answer` writes `attempt`, `tool_call`, and `score`. An engine-side\n`eval-retrieval` skill, which does not ship here, writes `retrieval_score` and\n`probe`; both stay in this contract so one validator covers every run\ndirectory.\n`eval-diagnose` writes `issue` and `issue_status`. `eval-improve` writes\n`candidate`. `eval-loop` writes `acceptance_check`, further `issue_status`, and\n`checkpoint`.\n\n**The contract is code: `eval-answer/scripts/ledger.py`.** Every script that\nwrites `run.json` or `events.jsonl` builds its lines through that module, so a\nfield rename that reaches only one writer fails at write time. This document\nis the human-readable rendering; where the two disagree, the module is right\nand this file is the bug. Check any run directory with:\n\n```\npython3 skills/eval-answer/scripts/ledger.py validate <runDir>\n```\n\nErrors are broken identity (missing `run.json`, a missing required field, two\nscores for one attempt); warnings are missing comparison pins (a run without\n`skillsVersion` still measures, but cannot anchor an A/B on that pin) and\ngrandfathered unknown fields on old runs.\n\n## Layout\n\n```\nevals/<set>/\n set.json # set metadata (below)\n cases.jsonl # one case per line\n judge-regressions.jsonl # judge verdicts a human overruled\n runs/<runId>/\n run.json # run config, the attribution pins\n events.jsonl # append-only event lines\n artifacts/ # prediction CSVs, judge outputs, transcripts\n```\n\nThe set directory lives in the SAME git repository as the model it evaluates,\nso a checkpoint (a git commit) pins the model and the ledger together. Never\nplace `evals/` inside the directory tree the answerer's package serves: gold\nin the served tree is a contamination path.\n\nRules that make the ledger trustworthy:\n\n- `events.jsonl` is append-only. Never edit a line. To void one, append a new\n event whose payload marks the old one `voided`. One sanctioned exception: a\n re-runnable stage (diagnose, improve) replaces ITS OWN prior events, keyed by\n issue id, through `ledger.replace_events()` -- so a crash-and-rerun does not\n duplicate issues. A stage never touches another stage's lines.\n- A stage never rewrites another stage's fields.\n- One artifact directory per attempt (`qid` plus `sample`). Never overwrite a\n previous attempt's prediction CSV.\n- End-of-run numbers come from counting event lines (`jq` over\n `events.jsonl`), never from arithmetic recalled in prose.\n\n## `set.json`\n\n| Field | Notes |\n|---|---|\n| `name` | Set name; also the directory name. |\n| `description` | |\n| `datasetVersion` | Integer. Bump on any golden repair or case change. Runs record the version they scored against. |\n| `targetModelPath` | Model path within the package. |\n| `truthPackage` | Name of the package holding the semantics-free sources every golden is re-derived from -- a package NAME, not an object. Without it `verify_goldens.py` re-derives nothing: it reports \"nothing to re-derive against\" and **exits 3**, which a caller must read as unverified rather than as a pass. The checks that need no truth server -- rubric figures, verification axis, stale rubric claims, set names -- still run and still report. `init_truth_package.py` scaffolds the package. |\n| `truthModel` | Model file inside that package. Default `truth.malloy`. |\n| `targetPackage` | Name of the package UNDER TEST. `verify_goldens.py` refuses a \"truth\" server that also serves it, because an answerer on that server can retrieve the raw truth sources beside the model and quietly change what is under test. `run_baseline.py` and `improve.py` pass the package they were given, so this field is the fallback for a direct call; without either the guard cannot fire. |\n| `truthTableRewrite` | Boolean, default false. Rewrites `duckdb.table('data/x.parquet')` refs to bare `x` in canonical queries, for a truth package whose tables are registered rather than read from files. |\n\n## `cases.jsonl`\n\nOne JSON object per line:\n\n| Field | Notes |\n|---|---|\n| `qid` | Stable case id. |\n| `question` | Exact text the answerer will see. |\n| `split` | `dev` or `holdout`. Frozen at import. Diagnose and improve read `dev` only; the acceptance check runs both. |\n| `tags` | list |\n| `state` | `candidate` / `selected` / `excluded`. |\n| `source` | Where the case came from. |\n| `golden` | `status` (`verified` / `provisional` / `invalid` / `ambiguous` / `verified_wrong`, the last being a key a person has established is wrong, which `run_baseline.py` drops from every aggregate), `kind`, `value` or `path` (artifact under the set directory), `canonicalQuery` (runs against the **truth** package, never the model under test), `verifiedBy`, and `verification` -- `{primaryAxis, variesAxis, note}` naming what the second derivation varied. **A golden is written only after two differently shaped derivations agree** (the truth query and a second one that varies a different axis -- group by something else, sum a different way, take a different route through the raw tables); `gold/<qid>.json` holds both (`truthRows`, `verifyRows`, `agreement`). Two derivations that vary the same axis share every other blind spot: that is how a golden that summed three overlapping time slices, 3x too high, passed its check. `verify_goldens.py` re-derives the value, reads the accepting clause of the rubric against the rows, and flags a second derivation that names no axis. **The question asks for data, never for an interpretation of it**, and either fixes the grain or the rubric accepts a correct figure at any stated grain (`skill:eval-judge` rules 10 and 11). |\n| `golden.kind` | What the key IS. A value (`scalar`, rows), `unanswerable`, or `criteria`: prose with no value, where the author's criteria are the whole key. `criteria` is the one kind that is legitimately `verified` on arrival, because there is no derivation to perform and nothing that can be numerically wrong; an imported key holding a VALUE is `provisional` until re-derived (`skill:eval-import`). **`verify_goldens.py --promote` is the only thing that writes `golden.status`**: it marks a `provisional` golden `verified` when its value re-derived cleanly from the truth package AND a second derivation exists. `--refresh` rewrites a drifted VALUE and never changes a status; `invalid` and `ambiguous` are settled by a person through the golden side door. |\n| `golden.rubric` | **On the golden, not on the case.** The prose the judge is shown as `CASE RUBRIC`, naming what counts as correct and what does not. `run_baseline.py` reads `golden.rubric`; a rubric written at the case's top level is silently not passed, the judge is told `CASE RUBRIC: none`, and it then scores from the golden value and the answer alone -- which reads as a judge that ignores its instructions. |\n| `golden.mustState` | **On the golden, not on the case.** What the answer has to say out loud. Read from `golden.mustState` by the run-package builder. |\n| `goldenRevision` | Integer. Bump on every golden change; `score` events stamp the revision they compared against. |\n| `expectedEntities` | `required`: entity ids (`kind:source:name`) the answer cannot be produced without. `requiredAnyOf`: a list of **groups**, each a list of ids of which any one suffices -- for a case the model can answer through more than one route. Naming only one route scores the other as a retrieval miss and steers the fix to the docs for an entity whose docs were never the problem. `acceptable`: ids that are not noise if returned. An entity counts as delivered when returned under its exact id, under the same type and name on a sibling source, or by name in a returned source's documentation; `score_retrieval.py` records the route per entity as `delivery`. |\n\n## Term files (`eval-retrieval` only)\n\nA customer set has no term file. The engine-side `eval-retrieval` skill keeps\n`intents.jsonl` fixtures -- search term, entity type, a description of what the\nsearcher meant, a validity flag -- beside its own scripts, for evaluating the\nengine against fixed inputs. Their shape and the `retrieval_score` events they\nproduce are documented there and validated here.\n\n## `run.json`\n\nThe attribution pins. Two runs are comparable only when these match where it\nmatters. Two runs with different `target` values are comparable on answer\nverdicts only if the same model version reached both, which is rarely worth\nassuming:\n\nRequired fields are the run's **identity** -- every run ever written carries\nthem. The rest are optional in the schema, but the **comparison pins** are\nwarned about when absent, because a run missing one cannot take part in an A/B\non that axis.\n\n| Field | Notes |\n|---|---|\n| `runId` *(required)* | Directory name. |\n| `target` *(required)* | Which server answered, and how it reached the data: `local`, `local-proxied`, or `platform`. Decides what an improve step is even allowed to do (see `skill:eval-loop`). |\n| `answererModel` *(required)* | |\n| `phase` / `started` *(required)* | |\n| `judgeModel` / `judgeVersion` / `rubricSha` *(pins)* | The judge's model, and the version + content sha of `skill:eval-judge`. |\n| `judgePromptSha` *(pin)* | Content sha of the prompt the judge is SHOWN, which is the harness's own template and not the skill file `rubricSha` covers. The template carries the golden rendering, the evidence blocks and the tie-breaking rules, so two runs whose prompts differed compared as the same judge -- the blind spot that let a golden-rendering defect run through three arms with every test passing, because the tests covered the code paths and not the text the model sees. Absent on runs written before it existed. |\n| `datasetVersion` *(pin)* | From `set.json` at run time. |\n| `modelSha` *(pin)* | Content sha of the `model.malloy` snapshot in the run directory -- the bytes the answerer actually queried. A git sha is not enough: a snapshot host serves a copy, often of a dirty tree no commit names. |\n| `skillsVersion` *(pin)* | HEAD of the checkout the agents' skills were loaded from, dirty-marked (`ledger.skills_git_sha(root)`). The skills are the doctrine the agents load; a run that cannot name their version cannot anchor a skills A/B. |\n| `skillsRoot` / `harnessVersion` | Which checkout supplied the doctrine (`--skills-root`, e.g. a Publisher checkout for the open-source skills; default this one), and this checkout's own HEAD. The eval-* skills always come from the harness checkout, whatever `skillsRoot` says. Two runs whose `skillsRoot` differ are a skills A/B only if their manifests name the same skills. |\n| `diagnoserModel` / `improverModel` | Written by `diagnose.py` / `improve.py` when those stages run, so the run names every LLM that touched it. Absent on a run that was only answered and judged. |\n| `modelGitSha` | Commit of the model repo, from `--model-repo`; `-dirty` suffix when `--model-dir` (or the whole repo, without it) had uncommitted changes -- fine for a band measurement, not for an A/B pin. Null when git cannot answer for the scope (a path outside the repo): absent beats wrong. **Null unless `--model-repo` names the repo.** It cannot be inferred: Publisher serves a COPY under `publisher_data/`, so the tree around the served file is the server's storage, not the model's history, and the field named that until 2026-09-03. `modelSha` above is the pin that always works; this is the pointer to where those bytes are versioned, and absent beats wrong. |\n| `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |\n| `modelDir` | The package directory `--model-dir` named, when it was: the path the `-dirty` marker on `modelGitSha` was decided over. Absent means the whole repo, where any unrelated untracked file marks the model dirty. |\n| `environment` / `package` / `modelPath` | What was served, and from where. On a platform target `environment` is the organization and `package` is the workspace the MCP URL is scoped to. |\n| `scope` | Platform target only: the `environment/package[@version]` the answerer was told to pass as an explicit `scopes` entry on every `get_context` / `execute_query` call. A workspace can serve many packages (a personal workspace serves every package the user can read), so without this the run also measures whether retrieval picks the right package -- a different measurement. Absent means unscoped. |\n| `targetVersion` *(pin)* | Platform target only: the published version the answers came from. Both hosted tools default an omitted version to the PINNED one, meaning whatever the workspace serves at the moment of the call, so this only describes the run when `--scope` carries it to every call. `--target-version` supplies it when the scope omits it; a scope naming a different version is refused rather than treated as an override, because a run cannot say which build answered if two pins disagree. |\n| `mcpUrl` / `publisher` | The endpoints the answerer and the re-execution used. |\n| `predictionsReExecuted` | Whether re-execution was POSSIBLE: the server was serving the bytes this run is pinned to. It is one bool for the whole run and says nothing about any given query, so it reads stronger than it is; `reExecution` below is what actually happened. |\n| `reExecution` | `{attempted, ok, failed, noQuery, notReExecuted, notJudged, missing}` over the run's predictions. `failed` means the query ran and the server returned an error, which is a different thing from an answer with no query at all. `notJudged` is a case the judge was never handed: a golden refused before judging (`provisional`, `invalid`, `ambiguous`, `verified_wrong`, missing), an attempt that submitted nothing, or every case under `--no-judge`. Those used to land in `missing` and read as corrupt artifacts on a freshly imported set. `ok`, `failed`, `noQuery`, `notReExecuted`, `notJudged` and `missing` partition the cases, so they sum to the case count; `attempted` is `ok + failed` and sits outside that sum. |\n| `retrievalMode` | Which retriever answered: `semantic`, `lexical`, `mixed` (it changed mid-run, so nothing can be averaged across it) or `unreported` (nothing ranked, which on a configured server means no embedding provider). Only a RANKING call names a retriever, so a call that enumerates a type or looks a name up is `unreported` on a fully semantic server and must not make the run read as `mixed`. Local retrieval degrades to lexical SILENTLY without an embedding key, and a pair compared across that reads as a model change, so `flip_table.py` refuses the pair unless both sides match. |\n| `retrievalCalls` | `{semantic, lexical, unreported}` call counts behind `retrievalMode`. `unreported` counts the calls that did not rank, which is a different thing from calls that fell back. |\n| `retrievalGate` | What the pre-arm retrieval gate found, or why it did not run. A run is held until `get_context` answers semantic twice in a row, because the first calls after a restart are lexical by design and an arm started immediately measures two retrievers. `not run (no answering phase)` under `--rebuild`/`--rejudge`, and `skipped by --no-retrieval-gate` when the operator opted out -- which is a different fact from a gate that passed, and the field is what tells them apart. |\n| `coverageReport` | The `check_coverage.py --out` report this run read, when `--coverage` named one: its path, the model version it was stamped with, its `agentModel`, and `decided` of `cases`. Coverage is a read of the MODEL, so it is measured separately and costs a judge call per case; the run does not run it, it consumes it. Its per-case verdict then beats the authored `coverage` label in retrieval attribution, and each `score_retrieval` row records `coverage_source` (`measured`, `authored`, `none`) so nobody has to guess which one charged the failure. Absent when no report was given, which is a different fact from a report that found everything covered. |\n| `label` | `<set>-<phase>-<nn>`, assigned by `run_baseline.py` from the set name, the run's phase and the next free number beside it (`ecommerce-baseline-01`, `ecommerce-baseline-02`, `ecommerce-blind_gate-01`). The A/A pair is two runs of the same phase; the post-edit arms are two runs of `blind_gate`. Hand-typed names do not survive one afternoon of runs -- `base`, `rejudged2`, `r3`, `post1` sort wrongly, group not at all, and cannot be matched to an arm. `--label` overrides for a run that genuinely needs a human name. |\n| `effort` | |\n| `maxTurns` / `answererTimeout` *(pins)* | The answerer's turn cap and per-attempt timeout, as the arm ran them. `skill:eval-loop` step 6 lists the call budget among the pins to freeze for a whole run, and nothing wrote one, so no two runs could be compared on the setting that decides whether a case got to finish at all. Distinct from `callBudget`, which no harness has ever written. Size the cap from a pilot rather than taking the default: on a 29-case arm at the default 30, completed attempts had a median of 17 turns and a 90th percentile of 26, and the four cases that ran past it were recorded as wrong answers. |\n| `truncated` / `contaminated` | The qids excluded for a HARNESS-owned reason: the cap cut the answer off, or the attempt breached isolation. Both leave the denominator, both are named here rather than only in the console, and either one makes `status` `incomplete`. |\n| `answererCostUsd` / `judgeCostUsd` | What the arm cost, split by role. The judge's half was discarded until 2026-09-02, so every \"cost per arm\" quoted before then was the answerer alone. Per-case judge cost is on each `score` event. |\n| `answererCostCopiedFrom` | The run whose attempts `answererCostUsd` was COPIED from, when this run spawned no answerer -- a `--rejudge`, a `--rebuild`, a `--from`. Those copy the source's attempt events verbatim, `cost_usd` included, so the figure is right about the attempts and wrong as this run's spend: one re-judge reported $17.56 of answering that never happened. Null when this run paid, so a total over several run files can skip the copies. |\n| `goldenCheck` | What `verify_goldens.py` said before the run started: `N ok, M drifted, K other finding(s)`, or why it did not run (no truth server on a platform target; `--skip-golden-check`; rebuild). A run that started on a drifted set says so here rather than pretending its verdicts mean something. `run_baseline` calls `verify()` without a model, so the set-name audit (check 5) does not run in-arm; the trailing `N stale entity name(s)` is the in-arm lint's own count, and `entity-name lint not run` says there was no model text to lint against. Without it the field read `0 other finding(s)` for a check that never happened. |\n| `staleEntityNames` | The `expectedEntities` names that appear nowhere in the served model, from the in-arm lint. Each one depresses the run's own recall on every case listing it, so a stale set reads as a retrieval failure until someone checks by hand. `null` when there was no model text to lint against (a platform target), which is a different fact from `[]`. |\n| `status` | `complete`; `aborted` when four consecutive attempts errored or found the server dead and the harness stopped rather than spend the rest of the budget on attempts nobody will trust; or `incomplete` when the arm ran to the end and still cannot report a rate, because `truncated` or `contaminated` is non-empty. `incomplete` is a third state, not a soft `complete`: the arm did everything asked of it and the number is still unearned. It does NOT stop the run being compared: `flip_table.py` scores the cases both arms did, and names the ones this arm left unscored -- an excluded case carries no verdict, so it can never be counted as a flip. |\n| `packageSha` / `servedRevision` *(pins)* | Taken from the server, not recomputed: `sourceContentSha` is a content hash over EVERY model path in the package, so an edit to an imported file moves it where a sha of the one `--model-path` does not; `servedRevision` is minted per load, so it identifies a load rather than content and is a poor pin alone. Measured: `publisher.json`'s `version` moves neither, and nothing in Publisher reads it -- it is not in the Package API schema and never returned, so it pins nothing. |\n| `datasetSha` *(pin)* | Content hash of `set.json` + `cases.jsonl`. Deliberately SEPARATE from the model's sha: a golden repair is not a model change, and one pin covering both would make every answer-key fix read as an edit to the model, which is the distinction an A/B rests on. Automatic, so nobody has to remember it; `datasetVersion` stays beside it as the human-readable sequence. **Local targets only.** A hosted target that publishes IMMUTABLE versions needs none of this: the set rides inside the version, and immutability -- not hashing -- is what makes a pin trustworthy. There, `targetVersion` alone identifies model and set together. |\n| `doubtedGoldens` | The cases whose golden is not believed: `qid`, `gold_status` (`suspect` or `verified_wrong`), `gold_note`, and `declaredBy` (`judge` or `set`). A status the SET declared is a key somebody already settled, not an opinion the judge formed this run, and attributing both to the judge claimed a judgement that never happened. **Read this before diagnose.** These are dataset issues, not model failures, and they go through the golden side door in `skill:eval-loop`. Empty list when no key is in doubt from either source. Written from the same scan that prints the end-of-run warning, because a warning that lives only in console text is one scrollback away from sending a modelling agent at a model that is already right. |\n| `mode` / `setName` / `serverVersion` / `traceMode` / `callBudget` | Defined and accepted, **not yet written by any harness** -- kept in the schema for the platform target and the conductor, which need them. (`targetVersion` and `status` were in this list and are both written now.) |\n\n## Events\n\nEvery line in `events.jsonl` is **flat**: `{ \"kind\": ..., <fields> }`, one\nJSON object per line, with an optional `at` ISO timestamp. Case-scoped kinds\n(`attempt`, `tool_call`, `score`) carry `qid`, `sample`, `phase` on the line;\nrun-level kinds do not. (An earlier draft of this document nested fields under\na `payload` with a `caseId`; no writer ever did that, and 23 run directories\nexist in the flat shape, so the flat shape is the contract.) `kind` is one of:\n`attempt`, `tool_call`, `score`, `retrieval_score`, `issue`, `issue_status`,\n`candidate`, `acceptance_check`, `checkpoint`.\n\n### `attempt`\n\n| Field | Type | Notes |\n|---|---|---|\n| `qid` | string | |\n| `sample` | int or null | Which repeat. Required even when null. |\n| `phase` | string | `baseline` / `loop` / `blind_gate` / `canary` / `final`. `phase` lives here, on the attempt, not in run config. |\n| `question_sha` | string | Hash of the exact text the answerer saw, so two runs can be compared on whether they asked the same thing. A case may carry `questionSha`, the seal stamped at conversion, and that wins when present; the ecommerce set writes a 16-char prefix of it, and a set without one left this null. Neither form proves the question was not edited, since both follow the case as it now reads: that comparison is check 6 of `verify_goldens.py`. |\n| `submitted` | bool | False when there was no final query. Not a wrong answer. |\n| `final_query` | string or null | Required to replay. A named view is recorded as the Malloy it stands for, `run: <source> -> <view>`, so every consumer sees one shape and the query re-executes. |\n| `final_query_source` | string or null | How `final_query` was chosen: `declared` (the answer printed it), `last_ok` (the last call the server answered) or `last`. `last` is a warning: a trailing sanity probe may be standing in for the answer's own query. |\n| `servedRevision` | string or null | From the package actually queried. |\n| `n_get_context` / `n_execute` / `n_execute_errors` | int | |\n| `host_tool_uses` | int | EVERY tool use the host logged, MCP calls included. It counted only the non-MCP ones until 2026-09-03, which made the under-report check below true of almost every clean attempt. |\n| `mcp_tool_uses` | int or null | The MCP subset: `get_context` plus `execute_query`. Null on a run written before the split. |\n| `reported_calls` | int | MCP calls the answerer claimed. `reported_calls > host_tool_uses` is the under-report floor from `skill:eval-answer`: the answerer cannot have made more calls than the host logged. `validate_run` warns on it, and only for runs that carry `mcp_tool_uses`, because the field meant something narrower before. |\n| `contaminated` | bool or `\"unknown\"` | `\"unknown\"` when no host log exists. Read it with `ledger.is_contaminated`, never for truthiness: runs written before 2026-09-03 carry the strings `\"true\"`/`\"false\"`, and `bool(\"false\")` is True. `ledger.event` now rejects those strings on write; `validate_run` grandfathers them on read as a warning. |\n| `contamination_reasons` | list | Empty when clean. |\n| `input_tokens` / `output_tokens` / `cache_read_tokens` / `cache_write_tokens` | int or null | Answerer token usage, the four counts that reprice a run from its own ledger. Null when the host does not report it. `cost_usd` beside them comes from the CLI's `total_cost_usd`, which prices cache reads and writes at their rates, so it was always cache-inclusive; what was missing was the breakdown that reproduces it. `cache_write_tokens` (`cache_creation_input_tokens` upstream) was never captured before 2026-09-15, and on one analysed run cache writes were 44% of the agent's cost, the single largest line. **Read `input_tokens` with care:** it counts only the tokens after the last cache breakpoint. One run recorded 224 for 24 questions beside 2.4M cache reads; the 224 is not the context volume, and a token total that omits the cache columns is not a size. |\n| `cost_usd` | float or null | Answerer cost for this attempt. |\n| `num_turns` / `wall_seconds` | int, float or null | |\n| `answer_text` | string or null | The answer the judge scored. Kept so a verdict can be re-read without the transcript. |\n| `skills_invoked` | list of string | The skills the answerer invoked through the `Skill` tool, deduplicated and sorted, counted from its own transcript. Always a list on a run that recorded it, so an EMPTY list is the finding: the arm granted a manifest and the agent reached for none of it. Absent on runs written before 2026-09-15, which is why every reader takes `or []`. Only explicit `Skill` calls are counted, and only skills the arm granted -- one outside `--answerer-skills` is a contamination breach and lands in `contamination_reasons` instead. What it is for: a manifest names what an arm GRANTED, and comparing two arms' doctrine is meaningless if the doctrine never loaded. |\n| `transcriptPath` | string | The answerer's transcript under `artifacts/`. |\n\nToken counts sit here rather than being derived later because the claim a\nsemantic model makes is about cost as well as correctness -- that a documented\nmodel reaches a good answer in fewer turns and tokens than working from raw\nschema. A ledger that counts calls but not tokens can state half of that.\n\n### `tool_call`\n\nOne event per MCP `get_context` or `execute_query` the attempt made.\n\n| Field | Type | Notes |\n|---|---|---|\n| `tool` | string | `get_context` or `execute_query`. |\n| `traceId` | string or null | `get_context` only; look up in your host's trace store. |\n| `targets` | string, list or null | **What the answerer asked for**: the search terms it sent to `get_context`. Null for `execute_query`. |\n| `rankedSummary` | object | Copied at capture so evidence survives trace eviction: `entityIds` (identity, in response order), `hits` (one row per entity: `entity_id`, `source`, `relevance`, `matched_targets`, `position`, `position_in_source`), `resultCount`, `docTokens`. **There is no rank.** This field previously documented `ranks` and \"per-target `targets` with within-target ranks\"; no writer ever wrote either, and what `ranks` did hold was `range(1, n+1)` over a list already flattened source-card by source-card -- a position, not a rank, and not even a relevance order, since the response sorts globally and then buckets into cards. Ask relevance questions of `hits[].relevance`. |\n| `rankedSummary.hits[].matched_targets` | list or null | Which of the caller's search targets matched this entity, each with the server's own relevance. **`null` means no attribution was published**, which is not the same as `[]`: the server attaches it to entities only (a source card's attribution is discarded before an entity object exists) and only on the semantic path, because a lexical score is relative to its own query and comparing two of them means nothing. A lexical run therefore cannot say which target found what, and a reader must report that as unavailable rather than as \"never asked\". |\n| `retrieval_mode` | string or null | `get_context` only: the `retrieval` field of the response that answered, `semantic` or `lexical`. Null when the server named none, which means no embedding provider. Recorded per call because the semantic path can fall over partway through a run. |\n| `query` | string or null | `execute_query` only: the Malloy the call sent, so the final query can be chosen by which call the server actually answered. |\n| `modelPath` | string or null | `execute_query` only: the model file the call named. A source does not resolve outside the file that declares it, so re-execution needs this. |\n| `error` | string or null | |\n\nNever persist `execute_query` result rows, givens, or credentials.\n\n`targets` records the request; `rankedSummary` records the response. Without\nboth, a low per-attempt recall has two readings that cannot be told apart: the\nanswerer searched for the wrong thing, or it searched well and retrieval ranked\nthe right entity too low. Those have opposite owners -- `agent-skill` and\n`retrieval` -- so a ledger holding only the response cannot attribute the\nfailure, and any recall computed from it is a blend of answerer behaviour and\nretrieval quality.\n\nThat blend is also why per-attempt recall is **not** comparable across arms: a\nstronger answerer searches better and scores higher retrieval recall without\nretrieval having changed. Use it for attribution within an arm. Comparing\nretrieval itself between engine versions is `eval-retrieval`'s job, with fixed\nterms, and is not something a customer run reports.\n\n### `score`\n\nThe answer judge's verdict for one attempt (protocol in\n`skill:eval-judge`). Every attempt in a scored run gets exactly one.\n\n| Field | Type | Notes |\n|---|---|---|\n| `verdict` | string or null | `match` / `near_match` / `no_match` / `needs_human`; null when the attempt is not scorable. Only `match` and `no_match` are decisions; see below. |\n| `reason` | string | Why, from the judge; for a null verdict, why not scorable, and it is ALWAYS one of these: `not_submitted`, `answerer_truncated`, `contaminated`, `judge_unparseable`, `no_saved_verdict`, `judge_error: <text>`, or `golden_<status>` -- `golden_missing`, `golden_provisional`, `golden_invalid`, `golden_ambiguous`, `golden_verified_wrong`. A null verdict never arrives without one: a nulling that does not say which of these it was is indistinguishable from the field being dropped on the write path, and was read as exactly that from a real run. The `golden_*` reasons and `answerer_truncated` are decided before the judge is called, so neither an unestablished key nor a cut-off attempt costs a judge call. |\n| `confidence` | int or null | 1 to 10. Confidence of 5 or lower forces `needs_human`. |\n| `column_pairing` | object or null | The judge's named gold-to-prediction column correspondence. |\n| `judge_version` / `rubric_sha` | string | Pins which rubric produced this verdict. |\n| `golden_revision` | int | From the case at score time. |\n| `contaminated` | bool or `\"unknown\"` | Copied from the attempt; true or unknown means `verdict: null`. |\n| `must_not_use_hits` | list or null | Entries of `golden.mustNotUse` a script found in the final query. A hit forces `verdict: no_match`. Decided by `check_must_not_use.py`, never by the judge: it is a question about query text. Only a BARE name vetoes; an entry with a connective (`X as ...`, `X through ...`) objects to a use of X rather than to X, and goes to the judge with the prose entries and a path's bare leaf. |\n| `judge_verdict` | string or null | What the judge said when a MECHANICAL override replaced it: a `must_not_use_hits` veto, or contamination voiding the verdict. Null otherwise, so the judge's own agreement rate stays measurable across both. When a case is vetoed AND contaminated this still holds the judge's read, not the veto's `no_match`. |\n| `artifactPath` | string | The full judge output under `artifacts/`. |\n| `gold_status` | string | `verified` / `verified_benign` / `suspect` / `verified_wrong`, and never anything else: the case file's `golden.status` is a DIFFERENT vocabulary that shares only `verified` and `verified_wrong`, and only those two fall back into this field. A `provisional` / `invalid` / `ambiguous` key is reported through the verdict's `reason` (`golden_<status>`) instead, so nothing downstream has to match on two vocabularies in one field. **From the judge**, which scored against the golden as written and reports separately whether it believes it; falls back to the case's standing `golden.status` when the judge does not say, written onto the verdict itself so the run summary and the aggregates see it too. `verified_wrong` excludes the case from run aggregates -- from the judge OR from the case file; assigning the fallback into a copy of the verdict meant a key the set had marked wrong stayed in the aggregates unless the judge independently agreed. `suspect` and `verified_wrong` route to the golden side door as `dataset` issues, never to improve. |\n| `gold_note` | string or null | The judge's evidence for a non-`verified` status: the two values, or the model line against the rubric sentence. Null when `verified`. |\n\nA `golden_*` reason means the ANSWER KEY is not something a verdict can be\nissued against. A golden the set stamps `provisional`, `invalid` or `ambiguous`\nis refused, as is a case with no golden at all and one the set marks\n`verified_wrong`; a golden with NO `status` is unguarded rather than\nunscorable, because sets that predate the field would otherwise score zero of\nzero. A golden holding no value is not refused either: that is how a\n`kind: unanswerable` case and a `kind: criteria` golden are written. This\nmatters most for an imported set, because `skill:eval-import` stamps\n`provisional` on every golden it writes and enforces it -- so a set is\nunscorable by construction until its keys are re-derived, and `run_baseline`\nrefuses to spend at all when every case in a set is in that state.\n\n`not_submitted` means the attempt produced NOTHING to judge: no answer text and\nno query. An attempt that wrote prose without querying **is judged**, and\nagainst a golden that holds a value an answer containing none of it is\n`no_match` (`skill:eval-judge`, `reference/refusal.md` rule 10). Excusing those\nas unscorable dropped a confident refusal on an answerable case out of the pass\nrate, which is the one thing the answerable-sounds-unanswerable cases exist to\nmeasure. A refusal is exempt only where `golden.kind` is `unanswerable`, and\nthere naming the gap is the pass and a confident number is the fail.\n\n`answerer_truncated` means the answerer hit the run's `maxTurns` cap and the\nCLI returned whatever it had, which is usually a sentence stopping mid-thought.\nIt is refused BEFORE the golden gate and before a saved verdict is reused, so\n`--rebuild` over a run made without this re-derives the right ledger from its\ntranscripts. A cap is a harness setting, so a case cut off by one says nothing\nabout whether the model could answer it: measured on one 29-case arm, four\nattempts ended at exactly 31 turns and each was judged as a wrong answer, one\nof them on the text \"Let me build the correct monthly trend query.\"\n\n`contaminated` means the attempt breached isolation (`contamination_reasons`\nnames how) and the verdict is withheld rather than lost. The judge's own read\nmoves to `judge_verdict`. Before this reason was written, a voided verdict kept\nthe judge's prose in `reason` beside `verdict: null`, and a run report\nreasonably concluded the ledger was dropping the field -- it recovered nine\n\"lost\" verdicts by re-parsing the stored judge replies, every one of which had\nbeen voided on purpose.\n\nBoth are HARNESS-owned, not model-owned, and a run containing either reports\n`status: incomplete` in `run.json` and **prints no pass rate**. `unscorable`\n(no established golden) and `judge_unparseable` do not suppress the rate: the\nfirst is a dataset state no arm can fix, and the second already has a retry.\n\nAggregates count decided verdicts only. `match` and `no_match` are the pass and\nthe fail; **`near_match`, `needs_human` and null are none of the above** and stay\nout of acceptance arithmetic. `near_match` is excluded because it means \"defensibly\ndifferent\", and an arguable verdict that moves a pass rate is a measurement\nartefact rather than a result (`skill:eval-judge` rule 7). Report its count:\nit rising is how a set tells you its rubrics are going vague.\n\nThis applies to `score`. On `retrieval_score` below, `near_match` **is** counted\ntowards recall and precision, because an overlapping entity is a genuine\nretrieval success. The two are different questions that share a word.\n\n### `retrieval_score` (written by `eval-retrieval`, not by a customer run)\n\nOne per term judged in a fixed-term replay (run-level; no `caseId`).\n\n| Field | Type | Notes |\n|---|---|---|\n| `intentId` / `term` / `entityType` | string | From the term file. |\n| `in_scope` | bool | Does THIS model version represent the concept? False is a coverage gap, not a retrieval failure. |\n| `judgments` | list | Per returned entity: `entityId`, `rank`, `level` (`match` / `near_match` / `no_match`), `confidence`, `why`. Empty when nothing returned. |\n| `judge_version` / `rubric_sha` | string | |\n| `traceId` | string or null | The `get_context` call judged. |\n\nRun-level metrics fall out by counting:\n\n- `coverage` = in-scope terms / valid terms -- a property of the model and its\n data, reported on its own, never as a retrieval number.\n- `recall` (on in-scope terms) = fraction whose judgments contain a `match`.\n- `precision@N` = `match` judgments / judged, with N stated.\n\n### `issue`\n\n| Field | Type | Notes |\n|---|---|---|\n| `issue_id` | string | Stable across status events. |\n| `qids` | list | Affected cases. |\n| `primary_code` / `contributing_codes` | string / list | From `skill:eval-diagnose`, verbatim. An event written before the codes were renamed may carry a retired one: `COVERAGE` is now `MISSING`, `NO-DISAMBIG` is `AMBIGUOUS`, and `CONVENTION` is `RULE_UNWRITTEN`. Read those as the new name; nothing rewrites old events. |\n| `component` | string | `dataset` / `agent-call` / `get_context/model` / `get_context/retrieval` / `construction` / `model-definition`. |\n| `owner` | string | `model` / `retrieval` / `agent-skill` / `dataset`. Environment failures stop the run; they are never diagnosed, so there is no environment owner. |\n| `severity` / `confidence` | string | |\n| `sufficiency` | string | `sufficient` / `insufficient` / `unknown`. |\n| `traceIds` | list | |\n| `diagnosis` | string | Written before any edit exists. |\n\n### `issue_status`\n\n`issue_id` plus `status`: `open` / `batched` / `fixed` / `rejected` /\n`deferred`. Readers take the latest event for that `issue_id`. Do not invent\na status column.\n\n### `candidate`\n\nWritten by `eval-improve`, for every proposed edit, accepted or not. A\nrejected direction keeps its record.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `files` | Paths the edit touched. |\n| `diffSummary` | One line per file. |\n| `probes` | Query and result for each factual claim. |\n| `meaningChanged` | Entities whose *meaning* the edit changed; `[]` for a docs-only edit. |\n| `goldenSuspect` | Each `{qid, entity, stored, rederived}`: a golden this edit may have invalidated. Reported by the improver, never repaired by it. **Non-empty halts the acceptance check** until adjudicated through the golden side door. |\n| `goldenAudit` | The set's `verify_goldens.py` run against the edited model: `{ran, clean, model, tail}`. Catches drift and rubric-vs-model contradictions only; a golden whose value silently moved is invisible to it, which is why `goldenSuspect` exists alongside. |\n\n### `acceptance_check`\n\nCalled `gate` before 2026-09-03. `ledger.py` reads the old kind as this one, so\nrun directories written earlier still validate and nothing rewrites them.\n\nWritten by `eval-loop`, one per acceptance check decision, BEFORE any checkpoint commit.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `decision` | `accepted` / `rejected`. |\n| `class` | `docs` / `definition` / `retrieval` / `justified`. |\n| `baselineRunId` / `finalRunIds` | Plural: acceptance needs two independent runs. |\n| `regressions` | Case ids whose verdict got worse vs baseline. Must be empty to accept. |\n| `holdoutDelta` | Confident-verdict delta on the holdout slice. |\n| `reason` | Including independent deterministic justification when that is the basis. |\n\n### `checkpoint`\n\nWritten by `eval-loop` after an accepted acceptance check, or when a restore runs. The\nmodel bytes live in git, not in this payload.\n\n| Field | Notes |\n|---|---|\n| `action` | `created` / `restored`. |\n| `label` | |\n| `modelGitSha` | The commit this checkpoint names (create), or the commit restored to. |\n| `issueIds` | Issues the accepted edit closed. Empty on restore. |\n\n## `judge-regressions.jsonl`\n\nAppend a line whenever a human overrules a judge verdict: the case or intent,\nthe judge's verdict, the human's, and why. Re-run this file against the judge\nwhenever `skill:eval-judge` or the judge model changes; a rubric change that\nflips old human-settled verdicts is a judge regression, not new truth."
|
|
384384
|
+
body: "# The eval ledger: files and events\n\nThe ledger is plain files in the model package's git repository. There is no\neval API and no eval database. The conductor (`skill:eval-loop`) reads and\nwrites these files directly; the stages share them as their contract.\n`eval-answer` writes `attempt`, `tool_call`, and `score`. An engine-side\n`eval-retrieval` skill, which does not ship here, writes `retrieval_score` and\n`probe`; both stay in this contract so one validator covers every run\ndirectory.\n`eval-diagnose` writes `issue` and `issue_status`. `eval-improve` writes\n`candidate`. `eval-loop` writes `acceptance_check`, further `issue_status`, and\n`checkpoint`.\n\n**The contract is code: `eval-answer/scripts/ledger.py`.** Every script that\nwrites `run.json` or `events.jsonl` builds its lines through that module, so a\nfield rename that reaches only one writer fails at write time. This document\nis the human-readable rendering; where the two disagree, the module is right\nand this file is the bug. Check any run directory with:\n\n```\npython3 skills/eval-answer/scripts/ledger.py validate <runDir>\n```\n\nErrors are broken identity (missing `run.json`, a missing required field, two\nscores for one attempt); warnings are missing comparison pins (a run without\n`skillsVersion` still measures, but cannot anchor an A/B on that pin) and\ngrandfathered unknown fields on old runs.\n\n## Layout\n\n```\nevals/<set>/ # the set, in the model's repository\n set.json # set metadata (below)\n eval.toml # servers and paths for eval.py (config.py)\n cases.jsonl # one case per line\n judge-regressions.jsonl # judge verdicts a human overruled\n\n<workdir>/ # [paths] workdir, default ~/.malloy-eval/<set>-<hash>/\n runs/<runId>/\n run.json # run config, the attribution pins\n events.jsonl # append-only event lines\n artifacts/ # prediction CSVs, judge outputs, transcripts\n packages/ # built report packages\n servers/<role>/ # server roots for eval.py serve\n```\n\nThe set directory lives in the SAME git repository as the model it evaluates,\nso a checkpoint (a git commit) pins the model and the answer key together.\nThe workdir is never inside the model package: a run holds a `model.malloy`\nsnapshot and a report is a Malloy package, and either one nested there puts\nthe package into `loadErrors`. For a checkpoint to pin the ledger as well,\npoint the workdir at a directory in the repository outside the package, and\ngitignore its `servers/` and `packages/`.\n\nRules that make the ledger trustworthy:\n\n- `events.jsonl` is append-only. Never edit a line. To void one, append a new\n event whose payload marks the old one `voided`. One sanctioned exception: a\n re-runnable stage (diagnose, improve) replaces ITS OWN prior events, keyed by\n issue id, through `ledger.replace_events()` -- so a crash-and-rerun does not\n duplicate issues. A stage never touches another stage's lines.\n- A stage never rewrites another stage's fields.\n- One artifact directory per attempt (`qid` plus `sample`). Never overwrite a\n previous attempt's prediction CSV.\n- End-of-run numbers come from counting event lines (`jq` over\n `events.jsonl`), never from arithmetic recalled in prose.\n\n## `set.json`\n\n| Field | Notes |\n|---|---|\n| `name` | Set name; also the directory name. |\n| `description` | |\n| `datasetVersion` | Integer. Bump on any golden repair or case change. Runs record the version they scored against. |\n| `targetModelPath` | Model path within the package. |\n| `truthPackage` | Name of the package holding the semantics-free sources every golden is re-derived from -- a package NAME, not an object. Without it `verify_goldens.py` re-derives nothing: it reports \"nothing to re-derive against\" and **exits 3**, which a caller must read as unverified rather than as a pass. The checks that need no truth server -- rubric figures, verification axis, stale rubric claims, set names -- still run and still report. `init_truth_package.py` scaffolds the package. |\n| `truthModel` | Model file inside that package. Default `truth.malloy`. |\n| `targetPackage` | Name of the package UNDER TEST. `verify_goldens.py` refuses a \"truth\" server that also serves it, because an answerer on that server can retrieve the raw truth sources beside the model and quietly change what is under test. `run_baseline.py` and `improve.py` pass the package they were given, so this field is the fallback for a direct call; without either the guard cannot fire. |\n| `truthTableRewrite` | Boolean, default false. Rewrites `duckdb.table('data/x.parquet')` refs to bare `x` in canonical queries, for a truth package whose tables are registered rather than read from files. |\n\n## `cases.jsonl`\n\nOne JSON object per line:\n\n| Field | Notes |\n|---|---|\n| `qid` | Stable case id. |\n| `question` | Exact text the answerer will see. |\n| `split` | `dev` or `holdout`. Frozen at import. Diagnose and improve read `dev` only; the acceptance check runs both. |\n| `tags` | list |\n| `state` | `candidate` / `selected` / `excluded`. |\n| `source` | Where the case came from. |\n| `golden` | `status` (`verified` / `provisional` / `invalid` / `ambiguous` / `verified_wrong`, the last being a key a person has established is wrong, which `run_baseline.py` drops from every aggregate), `kind`, `value` or `path` (artifact under the set directory; a `scalar` value is an object naming the canonical query's column, `{\"net_sales\": 834216}`, never a bare number), `canonicalQuery` (runs against the **truth** package, never the model under test), `verifiedBy`, and `verification` -- `{primaryAxis, variesAxis, note}` naming what the second derivation varied. **A golden is written only after two differently shaped derivations agree** (the truth query and a second one that varies a different axis -- group by something else, sum a different way, take a different route through the raw tables); `gold/<qid>.json` holds both (`truthRows`, `verifyRows`, `agreement`). Two derivations that vary the same axis share every other blind spot: that is how a golden that summed three overlapping time slices, 3x too high, passed its check. `verify_goldens.py` re-derives the value, reads the accepting clause of the rubric against the rows, and flags a second derivation that names no axis. **The question asks for data, never for an interpretation of it**, and either fixes the grain or the rubric accepts a correct figure at any stated grain (`skill:eval-judge` rules 10 and 11). |\n| `golden.kind` | What the key IS. A value (`scalar`, rows), `unanswerable`, or `criteria`: prose with no value, where the author's criteria are the whole key. `criteria` is the one kind that is legitimately `verified` on arrival, because there is no derivation to perform and nothing that can be numerically wrong; an imported key holding a VALUE is `provisional` until re-derived (`skill:eval-import`). **`verify_goldens.py --promote` is the only thing that writes `golden.status`**: it marks a `provisional` golden `verified` when its value re-derived cleanly from the truth package AND a second derivation exists. `--refresh` rewrites a drifted VALUE and never changes a status; `invalid` and `ambiguous` are settled by a person through the golden side door. |\n| `golden.rubric` | **On the golden, not on the case.** The prose the judge is shown as `CASE RUBRIC`, naming what counts as correct and what does not. `run_baseline.py` reads `golden.rubric`; a rubric written at the case's top level is silently not passed, the judge is told `CASE RUBRIC: none`, and it then scores from the golden value and the answer alone -- which reads as a judge that ignores its instructions. |\n| `golden.mustState` | **On the golden, not on the case.** What the answer has to say out loud. Read from `golden.mustState` by the run-package builder. |\n| `goldenRevision` | Integer. Bump on every golden change; `score` events stamp the revision they compared against. |\n| `expectedEntities` | `required`: entity ids (`kind:source:name`) the answer cannot be produced without. `requiredAnyOf`: a list of **groups**, each a list of ids of which any one suffices -- for a case the model can answer through more than one route. Naming only one route scores the other as a retrieval miss and steers the fix to the docs for an entity whose docs were never the problem. `acceptable`: ids that are not noise if returned. An entity counts as delivered when returned under its exact id, under the same type and name on a sibling source, or by name in a returned source's documentation; `score_retrieval.py` records the route per entity as `delivery`. |\n\n## Term files (`eval-retrieval` only)\n\nA customer set has no term file. The engine-side `eval-retrieval` skill keeps\n`intents.jsonl` fixtures -- search term, entity type, a description of what the\nsearcher meant, a validity flag -- beside its own scripts, for evaluating the\nengine against fixed inputs. Their shape and the `retrieval_score` events they\nproduce are documented there and validated here.\n\n## `run.json`\n\nThe attribution pins. Two runs are comparable only when these match where it\nmatters. Two runs with different `target` values are comparable on answer\nverdicts only if the same model version reached both, which is rarely worth\nassuming:\n\nRequired fields are the run's **identity** -- every run ever written carries\nthem. The rest are optional in the schema, but the **comparison pins** are\nwarned about when absent, because a run missing one cannot take part in an A/B\non that axis.\n\n| Field | Notes |\n|---|---|\n| `runId` *(required)* | Directory name. |\n| `target` *(required)* | Which server answered, and how it reached the data: `local`, `local-proxied`, or `platform`. Decides what an improve step is even allowed to do (see `skill:eval-loop`). |\n| `answererModel` *(required)* | |\n| `phase` / `started` *(required)* | |\n| `judgeModel` / `judgeVersion` / `rubricSha` *(pins)* | The judge's model, and the version + content sha of `skill:eval-judge`. |\n| `judgePromptSha` *(pin)* | Content sha of the prompt the judge is SHOWN, which is the harness's own template and not the skill file `rubricSha` covers. The template carries the golden rendering, the evidence blocks and the tie-breaking rules, so two runs whose prompts differed compared as the same judge -- the blind spot that let a golden-rendering defect run through three arms with every test passing, because the tests covered the code paths and not the text the model sees. Absent on runs written before it existed. |\n| `datasetVersion` *(pin)* | From `set.json` at run time. |\n| `modelSha` *(pin)* | Content sha of the `model.malloy` snapshot in the run directory -- the bytes the answerer actually queried. A git sha is not enough: a snapshot host serves a copy, often of a dirty tree no commit names. |\n| `skillsVersion` *(pin)* | HEAD of the checkout the agents' skills were loaded from, dirty-marked (`ledger.skills_git_sha(root)`). The skills are the doctrine the agents load; a run that cannot name their version cannot anchor a skills A/B. |\n| `skillsRoot` / `harnessVersion` | Which checkout supplied the doctrine (`--skills-root`, e.g. a Publisher checkout for the open-source skills; default this one), and this checkout's own HEAD. The eval-* skills always come from the harness checkout, whatever `skillsRoot` says. Two runs whose `skillsRoot` differ are a skills A/B only if their manifests name the same skills. |\n| `diagnoserModel` / `improverModel` | Written by `diagnose.py` / `improve.py` when those stages run, so the run names every LLM that touched it. Absent on a run that was only answered and judged. |\n| `modelGitSha` | Commit of the model repo, from `--model-repo`; `-dirty` suffix when `--model-dir` (or the whole repo, without it) had uncommitted changes -- fine for a band measurement, not for an A/B pin. Null when git cannot answer for the scope (a path outside the repo): absent beats wrong. **Null unless `--model-repo` names the repo.** It cannot be inferred: Publisher serves a COPY under `publisher_data/`, so the tree around the served file is the server's storage, not the model's history, and the field named that until 2026-09-03. `modelSha` above is the pin that always works; this is the pointer to where those bytes are versioned, and absent beats wrong. |\n| `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |\n| `modelDir` | The package directory `--model-dir` named, when it was: the path the `-dirty` marker on `modelGitSha` was decided over. Absent means the whole repo, where any unrelated untracked file marks the model dirty. |\n| `environment` / `package` / `modelPath` | What was served, and from where. On a platform target `environment` is the organization and `package` is the workspace the MCP URL is scoped to. |\n| `scope` | Platform target only: the `environment/package[@version]` the answerer was told to pass as an explicit `scopes` entry on every `get_context` / `execute_query` call. A workspace can serve many packages (a personal workspace serves every package the user can read), so without this the run also measures whether retrieval picks the right package -- a different measurement. Absent means unscoped. |\n| `targetVersion` *(pin)* | Platform target only: the published version the answers came from. Both hosted tools default an omitted version to the PINNED one, meaning whatever the workspace serves at the moment of the call, so this only describes the run when `--scope` carries it to every call. `--target-version` supplies it when the scope omits it; a scope naming a different version is refused rather than treated as an override, because a run cannot say which build answered if two pins disagree. |\n| `mcpUrl` / `publisher` | The endpoints the answerer and the re-execution used. |\n| `evalConfig` | The set's `eval.toml` (or `eval.json`) the run read, as `{path, values}`; `path` is null when the set has none. |\n| `predictionsReExecuted` | Whether re-execution was POSSIBLE: the server was serving the bytes this run is pinned to. It is one bool for the whole run and says nothing about any given query, so it reads stronger than it is; `reExecution` below is what actually happened. |\n| `reExecution` | `{attempted, ok, failed, noQuery, notReExecuted, notJudged, missing}` over the run's predictions. `failed` means the query ran and the server returned an error, which is a different thing from an answer with no query at all. `notJudged` is a case the judge was never handed: a golden refused before judging (`provisional`, `invalid`, `ambiguous`, `verified_wrong`, missing), an attempt that submitted nothing, or every case under `--no-judge`. Those used to land in `missing` and read as corrupt artifacts on a freshly imported set. `ok`, `failed`, `noQuery`, `notReExecuted`, `notJudged` and `missing` partition the cases, so they sum to the case count; `attempted` is `ok + failed` and sits outside that sum. |\n| `retrievalMode` | Which retriever answered: `semantic`, `lexical`, `mixed` (it changed mid-run, so nothing can be averaged across it) or `unreported` (nothing ranked, which on a configured server means no embedding provider). Only a RANKING call names a retriever, so a call that enumerates a type or looks a name up is `unreported` on a fully semantic server and must not make the run read as `mixed`. Local retrieval degrades to lexical SILENTLY without an embedding key, and a pair compared across that reads as a model change, so `flip_table.py` refuses the pair unless both sides match. |\n| `retrievalCalls` | `{semantic, lexical, unreported}` call counts behind `retrievalMode`. `unreported` counts the calls that did not rank, which is a different thing from calls that fell back. |\n| `retrievalGate` | What the pre-arm retrieval gate found, or why it did not run. A run is held until `get_context` answers semantic twice in a row, because the first calls after a restart are lexical by design and an arm started immediately measures two retrievers. `not run (no answering phase)` under `--rebuild`/`--rejudge`, and `skipped by --no-retrieval-gate` when the operator opted out -- which is a different fact from a gate that passed, and the field is what tells them apart. |\n| `coverageReport` | The `check_coverage.py --out` report this run read, when `--coverage` named one: its path, the model version it was stamped with, its `agentModel`, and `decided` of `cases`. Coverage is a read of the MODEL, so it is measured separately and costs a judge call per case; the run does not run it, it consumes it. Its per-case verdict then beats the authored `coverage` label in retrieval attribution, and each `score_retrieval` row records `coverage_source` (`measured`, `authored`, `none`) so nobody has to guess which one charged the failure. Absent when no report was given, which is a different fact from a report that found everything covered. |\n| `label` | `<set>-<phase>-<nn>`, assigned by `run_baseline.py` from the set name, the run's phase and the next free number beside it (`ecommerce-baseline-01`, `ecommerce-baseline-02`, `ecommerce-blind_gate-01`). The A/A pair is two runs of the same phase; the post-edit arms are two runs of `blind_gate`. Hand-typed names do not survive one afternoon of runs -- `base`, `rejudged2`, `r3`, `post1` sort wrongly, group not at all, and cannot be matched to an arm. `--label` overrides for a run that genuinely needs a human name. |\n| `effort` | |\n| `maxTurns` / `answererTimeout` *(pins)* | The answerer's turn cap and per-attempt timeout, as the arm ran them. `skill:eval-loop` step 6 lists the call budget among the pins to freeze for a whole run, and nothing wrote one, so no two runs could be compared on the setting that decides whether a case got to finish at all. Distinct from `callBudget`, which no harness has ever written. Size the cap from a pilot rather than taking the default: on a 29-case arm at the default 30, completed attempts had a median of 17 turns and a 90th percentile of 26, and the four cases that ran past it were recorded as wrong answers. |\n| `truncated` / `contaminated` | The qids excluded for a HARNESS-owned reason: the cap cut the answer off, or the attempt breached isolation. Both leave the denominator, both are named here rather than only in the console, and either one makes `status` `incomplete`. |\n| `answererCostUsd` / `judgeCostUsd` | What the arm cost, split by role. The judge's half was discarded until 2026-09-02, so every \"cost per arm\" quoted before then was the answerer alone. Per-case judge cost is on each `score` event. |\n| `answererCostCopiedFrom` | The run whose attempts `answererCostUsd` was COPIED from, when this run spawned no answerer -- a `--rejudge`, a `--rebuild`, a `--from`. Those copy the source's attempt events verbatim, `cost_usd` included, so the figure is right about the attempts and wrong as this run's spend: one re-judge reported $17.56 of answering that never happened. Null when this run paid, so a total over several run files can skip the copies. |\n| `goldenCheck` | What `verify_goldens.py` said before the run started: `N ok, M drifted, K other finding(s)`, or why it did not run (no truth server on a platform target; `--skip-golden-check`; rebuild). A run that started on a drifted set says so here rather than pretending its verdicts mean something. `run_baseline` calls `verify()` without a model, so the set-name audit (check 5) does not run in-arm; the trailing `N stale entity name(s)` is the in-arm lint's own count, and `entity-name lint not run` says there was no model text to lint against. Without it the field read `0 other finding(s)` for a check that never happened. |\n| `staleEntityNames` | The `expectedEntities` ids the compiled served model does not declare, from the in-arm lint (bare names from a search of the model text when the compiled model could not be read). Each one depresses the run's own recall on every case listing it, so a stale set reads as a retrieval failure until someone checks by hand. `null` when there was no model text to lint against (a platform target), which is a different fact from `[]`. |\n| `status` | `complete`; `aborted` when four consecutive attempts errored or found the server dead and the harness stopped rather than spend the rest of the budget on attempts nobody will trust; or `incomplete` when the arm ran to the end and still cannot report a rate, because `truncated` or `contaminated` is non-empty. `incomplete` is a third state, not a soft `complete`: the arm did everything asked of it and the number is still unearned. It does NOT stop the run being compared: `flip_table.py` scores the cases both arms did, and names the ones this arm left unscored -- an excluded case carries no verdict, so it can never be counted as a flip. |\n| `packageSha` / `servedRevision` *(pins)* | Taken from the server, not recomputed: `sourceContentSha` is a content hash over EVERY model path in the package, so an edit to an imported file moves it where a sha of the one `--model-path` does not; `servedRevision` is minted per load, so it identifies a load rather than content and is a poor pin alone. Measured: `publisher.json`'s `version` moves neither, and nothing in Publisher reads it -- it is not in the Package API schema and never returned, so it pins nothing. |\n| `datasetSha` *(pin)* | Content hash of `set.json` + `cases.jsonl`. Deliberately SEPARATE from the model's sha: a golden repair is not a model change, and one pin covering both would make every answer-key fix read as an edit to the model, which is the distinction an A/B rests on. Automatic, so nobody has to remember it; `datasetVersion` stays beside it as the human-readable sequence. **Local targets only.** A hosted target that publishes IMMUTABLE versions needs none of this: the set rides inside the version, and immutability -- not hashing -- is what makes a pin trustworthy. There, `targetVersion` alone identifies model and set together. |\n| `doubtedGoldens` | The cases whose golden is not believed: `qid`, `gold_status` (`suspect` or `verified_wrong`), `gold_note`, and `declaredBy` (`judge` or `set`). A status the SET declared is a key somebody already settled, not an opinion the judge formed this run, and attributing both to the judge claimed a judgement that never happened. **Read this before diagnose.** These are dataset issues, not model failures, and they go through the golden side door in `skill:eval-loop`. Empty list when no key is in doubt from either source. Written from the same scan that prints the end-of-run warning, because a warning that lives only in console text is one scrollback away from sending a modelling agent at a model that is already right. |\n| `mode` / `setName` / `serverVersion` / `traceMode` / `callBudget` | Defined and accepted, **not yet written by any harness** -- kept in the schema for the platform target and the conductor, which need them. (`targetVersion` and `status` were in this list and are both written now.) |\n\n## Events\n\nEvery line in `events.jsonl` is **flat**: `{ \"kind\": ..., <fields> }`, one\nJSON object per line, with an optional `at` ISO timestamp. Case-scoped kinds\n(`attempt`, `tool_call`, `score`) carry `qid`, `sample`, `phase` on the line;\nrun-level kinds do not. (An earlier draft of this document nested fields under\na `payload` with a `caseId`; no writer ever did that, and 23 run directories\nexist in the flat shape, so the flat shape is the contract.) `kind` is one of:\n`attempt`, `tool_call`, `score`, `retrieval_score`, `issue`, `issue_status`,\n`candidate`, `acceptance_check`, `checkpoint`.\n\n### `attempt`\n\n| Field | Type | Notes |\n|---|---|---|\n| `qid` | string | |\n| `sample` | int or null | Which repeat. Required even when null. |\n| `phase` | string | `baseline` / `loop` / `blind_gate` / `canary` / `final`. `phase` lives here, on the attempt, not in run config. |\n| `question_sha` | string | Hash of the exact text the answerer saw, so two runs can be compared on whether they asked the same thing. A case may carry `questionSha`, the seal stamped at conversion, and that wins when present; the ecommerce set writes a 16-char prefix of it, and a set without one left this null. Neither form proves the question was not edited, since both follow the case as it now reads: that comparison is check 6 of `verify_goldens.py`. |\n| `submitted` | bool | False when there was no final query. Not a wrong answer. |\n| `final_query` | string or null | Required to replay. A named view is recorded as the Malloy it stands for, `run: <source> -> <view>`, so every consumer sees one shape and the query re-executes. |\n| `final_query_source` | string or null | How `final_query` was chosen: `declared` (the answer printed it), `last_ok` (the last call the server answered) or `last`. `last` is a warning: a trailing sanity probe may be standing in for the answer's own query. |\n| `servedRevision` | string or null | From the package actually queried. |\n| `n_get_context` / `n_execute` / `n_execute_errors` | int | |\n| `host_tool_uses` | int | EVERY tool use the host logged, MCP calls included. It counted only the non-MCP ones until 2026-09-03, which made the under-report check below true of almost every clean attempt. |\n| `mcp_tool_uses` | int or null | The MCP subset: `get_context` plus `execute_query`. Null on a run written before the split. |\n| `reported_calls` | int | MCP calls the answerer claimed. `reported_calls > host_tool_uses` is the under-report floor from `skill:eval-answer`: the answerer cannot have made more calls than the host logged. `validate_run` warns on it, and only for runs that carry `mcp_tool_uses`, because the field meant something narrower before. |\n| `contaminated` | bool or `\"unknown\"` | `\"unknown\"` when no host log exists. Read it with `ledger.is_contaminated`, never for truthiness: runs written before 2026-09-03 carry the strings `\"true\"`/`\"false\"`, and `bool(\"false\")` is True. `ledger.event` now rejects those strings on write; `validate_run` grandfathers them on read as a warning. |\n| `contamination_reasons` | list | Empty when clean. |\n| `input_tokens` / `output_tokens` / `cache_read_tokens` / `cache_write_tokens` | int or null | Answerer token usage, the four counts that reprice a run from its own ledger. Null when the host does not report it. `cost_usd` beside them comes from the CLI's `total_cost_usd`, which prices cache reads and writes at their rates, so it was always cache-inclusive; what was missing was the breakdown that reproduces it. `cache_write_tokens` (`cache_creation_input_tokens` upstream) was never captured before 2026-09-15, and on one analysed run cache writes were 44% of the agent's cost, the single largest line. **Read `input_tokens` with care:** it counts only the tokens after the last cache breakpoint. One run recorded 224 for 24 questions beside 2.4M cache reads; the 224 is not the context volume, and a token total that omits the cache columns is not a size. |\n| `cost_usd` | float or null | Answerer cost for this attempt. |\n| `num_turns` / `wall_seconds` | int, float or null | |\n| `answer_text` | string or null | The answer the judge scored. Kept so a verdict can be re-read without the transcript. |\n| `skills_invoked` | list of string | The skills the answerer invoked through the `Skill` tool, deduplicated and sorted, counted from its own transcript. Always a list on a run that recorded it, so an EMPTY list is the finding: the arm granted a manifest and the agent reached for none of it. Absent on runs written before 2026-09-15, which is why every reader takes `or []`. Only explicit `Skill` calls are counted, and only skills the arm granted -- one outside `--answerer-skills` is a contamination breach and lands in `contamination_reasons` instead. What it is for: a manifest names what an arm GRANTED, and comparing two arms' doctrine is meaningless if the doctrine never loaded. |\n| `transcriptPath` | string | The answerer's transcript under `artifacts/`. |\n\nToken counts sit here rather than being derived later because the claim a\nsemantic model makes is about cost as well as correctness -- that a documented\nmodel reaches a good answer in fewer turns and tokens than working from raw\nschema. A ledger that counts calls but not tokens can state half of that.\n\n### `tool_call`\n\nOne event per MCP `get_context` or `execute_query` the attempt made.\n\n| Field | Type | Notes |\n|---|---|---|\n| `tool` | string | `get_context` or `execute_query`. |\n| `traceId` | string or null | `get_context` only; look up in your host's trace store. |\n| `targets` | string, list or null | **What the answerer asked for**: the search terms it sent to `get_context`. Null for `execute_query`. |\n| `rankedSummary` | object | Copied at capture so evidence survives trace eviction: `entityIds` (identity, in response order), `hits` (one row per entity: `entity_id`, `source`, `relevance`, `matched_targets`, `position`, `position_in_source`), `resultCount`, `docTokens`. **There is no rank.** This field previously documented `ranks` and \"per-target `targets` with within-target ranks\"; no writer ever wrote either, and what `ranks` did hold was `range(1, n+1)` over a list already flattened source-card by source-card -- a position, not a rank, and not even a relevance order, since the response sorts globally and then buckets into cards. Ask relevance questions of `hits[].relevance`. |\n| `rankedSummary.hits[].matched_targets` | list or null | Which of the caller's search targets matched this entity, each with the server's own relevance. **`null` means no attribution was published**, which is not the same as `[]`: the server attaches it to entities only (a source card's attribution is discarded before an entity object exists) and only on the semantic path, because a lexical score is relative to its own query and comparing two of them means nothing. A lexical run therefore cannot say which target found what, and a reader must report that as unavailable rather than as \"never asked\". |\n| `retrieval_mode` | string or null | `get_context` only: the `retrieval` field of the response that answered, `semantic` or `lexical`. Null when the server named none, which means no embedding provider. Recorded per call because the semantic path can fall over partway through a run. |\n| `query` | string or null | `execute_query` only: the Malloy the call sent, so the final query can be chosen by which call the server actually answered. |\n| `modelPath` | string or null | `execute_query` only: the model file the call named. A source does not resolve outside the file that declares it, so re-execution needs this. |\n| `error` | string or null | |\n\nNever persist `execute_query` result rows, givens, or credentials.\n\n`targets` records the request; `rankedSummary` records the response. Without\nboth, a low per-attempt recall has two readings that cannot be told apart: the\nanswerer searched for the wrong thing, or it searched well and retrieval ranked\nthe right entity too low. Those have opposite owners -- `agent-skill` and\n`retrieval` -- so a ledger holding only the response cannot attribute the\nfailure, and any recall computed from it is a blend of answerer behaviour and\nretrieval quality.\n\nThat blend is also why per-attempt recall is **not** comparable across arms: a\nstronger answerer searches better and scores higher retrieval recall without\nretrieval having changed. Use it for attribution within an arm. Comparing\nretrieval itself between engine versions is `eval-retrieval`'s job, with fixed\nterms, and is not something a customer run reports.\n\n### `score`\n\nThe answer judge's verdict for one attempt (protocol in\n`skill:eval-judge`). Every attempt in a scored run gets exactly one.\n\n| Field | Type | Notes |\n|---|---|---|\n| `verdict` | string or null | `match` / `near_match` / `no_match` / `needs_human`; null when the attempt is not scorable. Only `match` and `no_match` are decisions; see below. |\n| `reason` | string | Why, from the judge; for a null verdict, why not scorable, and it is ALWAYS one of these: `not_submitted`, `answerer_truncated`, `contaminated`, `judge_unparseable`, `no_saved_verdict`, `judge_error: <text>`, or `golden_<status>` -- `golden_missing`, `golden_provisional`, `golden_invalid`, `golden_ambiguous`, `golden_verified_wrong`. A null verdict never arrives without one: a nulling that does not say which of these it was is indistinguishable from the field being dropped on the write path, and was read as exactly that from a real run. The `golden_*` reasons and `answerer_truncated` are decided before the judge is called, so neither an unestablished key nor a cut-off attempt costs a judge call. |\n| `confidence` | int or null | 1 to 10. Confidence of 5 or lower forces `needs_human`. |\n| `column_pairing` | object or null | The judge's named gold-to-prediction column correspondence. |\n| `judge_version` / `rubric_sha` | string | Pins which rubric produced this verdict. |\n| `golden_revision` | int | From the case at score time. |\n| `contaminated` | bool or `\"unknown\"` | Copied from the attempt; true or unknown means `verdict: null`. |\n| `must_not_use_hits` | list or null | Entries of `golden.mustNotUse` a script found in the final query. A hit forces `verdict: no_match`. Decided by `check_must_not_use.py`, never by the judge: it is a question about query text. Only a BARE name vetoes; an entry with a connective (`X as ...`, `X through ...`) objects to a use of X rather than to X, and goes to the judge with the prose entries and a path's bare leaf. |\n| `judge_verdict` | string or null | What the judge said when a MECHANICAL override replaced it: a `must_not_use_hits` veto, or contamination voiding the verdict. Null otherwise, so the judge's own agreement rate stays measurable across both. When a case is vetoed AND contaminated this still holds the judge's read, not the veto's `no_match`. |\n| `artifactPath` | string | The full judge output under `artifacts/`. |\n| `gold_status` | string | `verified` / `verified_benign` / `suspect` / `verified_wrong`, and never anything else: the case file's `golden.status` is a DIFFERENT vocabulary that shares only `verified` and `verified_wrong`, and only those two fall back into this field. A `provisional` / `invalid` / `ambiguous` key is reported through the verdict's `reason` (`golden_<status>`) instead, so nothing downstream has to match on two vocabularies in one field. **From the judge**, which scored against the golden as written and reports separately whether it believes it; falls back to the case's standing `golden.status` when the judge does not say, written onto the verdict itself so the run summary and the aggregates see it too. `verified_wrong` excludes the case from run aggregates -- from the judge OR from the case file; assigning the fallback into a copy of the verdict meant a key the set had marked wrong stayed in the aggregates unless the judge independently agreed. `suspect` and `verified_wrong` route to the golden side door as `dataset` issues, never to improve. |\n| `gold_note` | string or null | The judge's evidence for a non-`verified` status: the two values, or the model line against the rubric sentence. Null when `verified`. |\n\nA `golden_*` reason means the ANSWER KEY is not something a verdict can be\nissued against. A golden the set stamps `provisional`, `invalid` or `ambiguous`\nis refused, as is a case with no golden at all and one the set marks\n`verified_wrong`; a golden with NO `status` is unguarded rather than\nunscorable, because sets that predate the field would otherwise score zero of\nzero. A golden holding no value is not refused either: that is how a\n`kind: unanswerable` case and a `kind: criteria` golden are written. This\nmatters most for an imported set, because `skill:eval-import` stamps\n`provisional` on every golden it writes and enforces it -- so a set is\nunscorable by construction until its keys are re-derived, and `run_baseline`\nrefuses to spend at all when every case in a set is in that state.\n\n`not_submitted` means the attempt produced NOTHING to judge: no answer text and\nno query. An attempt that wrote prose without querying **is judged**, and\nagainst a golden that holds a value an answer containing none of it is\n`no_match` (`skill:eval-judge`, `reference/refusal.md` rule 10). Excusing those\nas unscorable dropped a confident refusal on an answerable case out of the pass\nrate, which is the one thing the answerable-sounds-unanswerable cases exist to\nmeasure. A refusal is exempt only where `golden.kind` is `unanswerable`, and\nthere naming the gap is the pass and a confident number is the fail.\n\n`answerer_truncated` means the answerer hit the run's `maxTurns` cap and the\nCLI returned whatever it had, which is usually a sentence stopping mid-thought.\nIt is refused BEFORE the golden gate and before a saved verdict is reused, so\n`--rebuild` over a run made without this re-derives the right ledger from its\ntranscripts. A cap is a harness setting, so a case cut off by one says nothing\nabout whether the model could answer it: measured on one 29-case arm, four\nattempts ended at exactly 31 turns and each was judged as a wrong answer, one\nof them on the text \"Let me build the correct monthly trend query.\"\n\n`contaminated` means the attempt breached isolation (`contamination_reasons`\nnames how) and the verdict is withheld rather than lost. The judge's own read\nmoves to `judge_verdict`. Before this reason was written, a voided verdict kept\nthe judge's prose in `reason` beside `verdict: null`, and a run report\nreasonably concluded the ledger was dropping the field -- it recovered nine\n\"lost\" verdicts by re-parsing the stored judge replies, every one of which had\nbeen voided on purpose.\n\nBoth are HARNESS-owned, not model-owned, and a run containing either reports\n`status: incomplete` in `run.json` and **prints no pass rate**. `unscorable`\n(no established golden) and `judge_unparseable` do not suppress the rate: the\nfirst is a dataset state no arm can fix, and the second already has a retry.\n\nAggregates count decided verdicts only. `match` and `no_match` are the pass and\nthe fail; **`near_match`, `needs_human` and null are none of the above** and stay\nout of acceptance arithmetic. `near_match` is excluded because it means \"defensibly\ndifferent\", and an arguable verdict that moves a pass rate is a measurement\nartefact rather than a result (`skill:eval-judge` rule 7). Report its count:\nit rising is how a set tells you its rubrics are going vague.\n\nThis applies to `score`. On `retrieval_score` below, `near_match` **is** counted\ntowards recall and precision, because an overlapping entity is a genuine\nretrieval success. The two are different questions that share a word.\n\n### `retrieval_score` (written by `eval-retrieval`, not by a customer run)\n\nOne per term judged in a fixed-term replay (run-level; no `caseId`).\n\n| Field | Type | Notes |\n|---|---|---|\n| `intentId` / `term` / `entityType` | string | From the term file. |\n| `in_scope` | bool | Does THIS model version represent the concept? False is a coverage gap, not a retrieval failure. |\n| `judgments` | list | Per returned entity: `entityId`, `rank`, `level` (`match` / `near_match` / `no_match`), `confidence`, `why`. Empty when nothing returned. |\n| `judge_version` / `rubric_sha` | string | |\n| `traceId` | string or null | The `get_context` call judged. |\n\nRun-level metrics fall out by counting:\n\n- `coverage` = in-scope terms / valid terms -- a property of the model and its\n data, reported on its own, never as a retrieval number.\n- `recall` (on in-scope terms) = fraction whose judgments contain a `match`.\n- `precision@N` = `match` judgments / judged, with N stated.\n\n### `issue`\n\n| Field | Type | Notes |\n|---|---|---|\n| `issue_id` | string | Stable across status events. |\n| `qids` | list | Affected cases. |\n| `primary_code` / `contributing_codes` | string / list | From `skill:eval-diagnose`, verbatim. An event written before the codes were renamed may carry a retired one: `COVERAGE` is now `MISSING`, `NO-DISAMBIG` is `AMBIGUOUS`, and `CONVENTION` is `RULE_UNWRITTEN`. Read those as the new name; nothing rewrites old events. |\n| `component` | string | `dataset` / `agent-call` / `get_context/model` / `get_context/retrieval` / `construction` / `model-definition`. |\n| `owner` | string | `model` / `retrieval` / `agent-skill` / `dataset`. Environment failures stop the run; they are never diagnosed, so there is no environment owner. |\n| `severity` / `confidence` | string | |\n| `sufficiency` | string | `sufficient` / `insufficient` / `unknown`. |\n| `traceIds` | list | |\n| `diagnosis` | string | Written before any edit exists. |\n\n### `issue_status`\n\n`issue_id` plus `status`: `open` / `batched` / `fixed` / `rejected` /\n`deferred`. Readers take the latest event for that `issue_id`. Do not invent\na status column.\n\n### `candidate`\n\nWritten by `eval-improve`, for every proposed edit, accepted or not. A\nrejected direction keeps its record.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `files` | Paths the edit touched. |\n| `diffSummary` | One line per file. |\n| `probes` | Query and result for each factual claim. |\n| `meaningChanged` | Entities whose *meaning* the edit changed; `[]` for a docs-only edit. |\n| `goldenSuspect` | Each `{qid, entity, stored, rederived}`: a golden this edit may have invalidated. Reported by the improver, never repaired by it. **Non-empty halts the acceptance check** until adjudicated through the golden side door. |\n| `goldenAudit` | The set's `verify_goldens.py` run against the edited model: `{ran, clean, model, tail}`. Catches drift and rubric-vs-model contradictions only; a golden whose value silently moved is invisible to it, which is why `goldenSuspect` exists alongside. |\n\n### `acceptance_check`\n\nCalled `gate` before 2026-09-03. `ledger.py` reads the old kind as this one, so\nrun directories written earlier still validate and nothing rewrites them.\n\nWritten by `eval-loop`, one per acceptance check decision, BEFORE any checkpoint commit.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `decision` | `accepted` / `rejected`. |\n| `class` | `docs` / `definition` / `retrieval` / `justified`. |\n| `baselineRunId` / `finalRunIds` | Plural: acceptance needs two independent runs. |\n| `regressions` | Case ids whose verdict got worse vs baseline. Must be empty to accept. |\n| `holdoutDelta` | Confident-verdict delta on the holdout slice. |\n| `reason` | Including independent deterministic justification when that is the basis. |\n\n### `checkpoint`\n\nWritten by `eval-loop` after an accepted acceptance check, or when a restore runs. The\nmodel bytes live in git, not in this payload.\n\n| Field | Notes |\n|---|---|\n| `action` | `created` / `restored`. |\n| `label` | |\n| `modelGitSha` | The commit this checkpoint names (create), or the commit restored to. |\n| `issueIds` | Issues the accepted edit closed. Empty on restore. |\n\n## `judge-regressions.jsonl`\n\nAppend a line whenever a human overrules a judge verdict: the case or intent,\nthe judge's verdict, the human's, and why. Re-run this file against the judge\nwhenever `skill:eval-judge` or the judge model changes; a rubric change that\nflips old human-settled verdicts is a judge regression, not new truth."
|
|
384207
384385
|
},
|
|
384208
384386
|
{
|
|
384209
384387
|
name: "eval-diagnose",
|
|
@@ -384526,7 +384704,7 @@ model-owned finding deserves a probe of its own before it justifies an edit.
|
|
|
384526
384704
|
|
|
384527
384705
|
## Step 4: Append issue events, then stop
|
|
384528
384706
|
|
|
384529
|
-
Append to
|
|
384707
|
+
Append to \`<workdir>/runs/<runId>/events.jsonl\` with \`kind: issue\`
|
|
384530
384708
|
(shapes in \`skill:eval-answer\` \`reference/ledger-schema.md\`):
|
|
384531
384709
|
|
|
384532
384710
|
- \`issue_id\`, affected \`qids\`, \`primary_code\`, \`contributing_codes\`
|
|
@@ -385237,7 +385415,7 @@ Yours, after applying step 3 and step 4:
|
|
|
385237
385415
|
\`\`\`json
|
|
385238
385416
|
{"qid": "ecom-q-014", "question": "How did denim sell last quarter versus the one before?",
|
|
385239
385417
|
"questionSha": "<stamped>", "split": "dev", "source": "questions.jsonl",
|
|
385240
|
-
"golden": {"status": "provisional", "kind": "scalar", "value": 1830000,
|
|
385418
|
+
"golden": {"status": "provisional", "kind": "scalar", "value": {"denim_change": 1830000},
|
|
385241
385419
|
"canonicalQuery": "SELECT ...", "verifiedBy": "authored_query",
|
|
385242
385420
|
"rubric": "Reports the quarter-over-quarter change for denim, and breaks the figure out by channel."},
|
|
385243
385421
|
"sourceFields": {"difficulty": "hard"}}
|
|
@@ -386031,547 +386209,8 @@ can never fire.`
|
|
|
386031
386209
|
},
|
|
386032
386210
|
{
|
|
386033
386211
|
name: "eval-loop",
|
|
386034
|
-
description: "Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint. You are the conductor: import cases into the file ledger, spawn a blind answerer, then run eval-answer, eval-diagnose, and eval-improve. Persistence is plain files
|
|
386035
|
-
body: `# The Evaluation Loop
|
|
386036
|
-
|
|
386037
|
-
You conduct this loop. There is no batch orchestrator to start, no eval API,
|
|
386038
|
-
and no eval MCP tools. The ledger is plain files in the model package's git
|
|
386039
|
-
repository (\`reference/ledger-schema.md\` in \`skill:eval-answer\` defines every
|
|
386040
|
-
file and event). Scoring is an LLM judge you spawn per case. There is no
|
|
386041
|
-
scripted scorer, and there will not be one: a script that can pass a wrong
|
|
386042
|
-
answer is worse than none. The scripts under \`scripts/\` run the loop -- they
|
|
386043
|
-
answer, re-execute, spawn the judge, compare runs, and write the ledger -- but
|
|
386044
|
-
none of them decides whether an answer was right.
|
|
386045
|
-
|
|
386046
|
-
\`\`\`
|
|
386047
|
-
scrape/run -> eval -> diagnose -> improve -> checkpoint
|
|
386048
|
-
\`\`\`
|
|
386049
|
-
|
|
386050
|
-
**This skill conducts; it does not restate.** Scoring lives in
|
|
386051
|
-
\`skill:eval-answer\`. Components and owners live in \`skill:eval-diagnose\`.
|
|
386052
|
-
Edit rules live in \`skill:eval-improve\`.
|
|
386053
|
-
|
|
386054
|
-
Do not merge **eval** into **diagnose**. A conductor who scores while
|
|
386055
|
-
explaining writes the explanation into the score. Do not skip the **acceptance
|
|
386056
|
-
check** inside improve. The acceptance check decides whether *this* edit
|
|
386057
|
-
stays. **Checkpoint** decides whether a *sequence* of accepted edits can be
|
|
386058
|
-
undone.
|
|
386059
|
-
|
|
386060
|
-
## Where the rest of this lives
|
|
386061
|
-
|
|
386062
|
-
This file is the procedure. Five things it used to carry inline are files beside
|
|
386063
|
-
it now, because each is needed at one moment rather than every run, and loading
|
|
386064
|
-
all of them for every run is how a skill stops being read.
|
|
386065
|
-
|
|
386066
|
-
| When | Read |
|
|
386067
|
-
|---|---|
|
|
386068
|
-
| about to run one | \`reference/running-a-run.md\` |
|
|
386069
|
-
| a golden is wrong, doubted, or out of step with the model | \`reference/golden-side-door.md\` |
|
|
386070
|
-
| auditing a key you doubt, or a set you did not author | \`reference/auditing-an-answer-key.md\` |
|
|
386071
|
-
| deciding whether an edit stays | \`reference/acceptance-check.md\` |
|
|
386072
|
-
| about to quote a number, set the band, or read a set's flips | \`reference/measurement.md\` |
|
|
386073
|
-
| the run finished and someone has to read it | \`skill:eval-report\` |
|
|
386074
|
-
| you changed judge doctrine or its inputs | \`reference/checking-the-judge.md\` |
|
|
386075
|
-
|
|
386076
|
-
Read the file, do not work from the summary here. The acceptance-check rules and
|
|
386077
|
-
the golden side door are both places where acting on a half-memory of the rule
|
|
386078
|
-
produces a confident wrong answer rather than an error.
|
|
386079
|
-
|
|
386080
|
-
## The five steps
|
|
386081
|
-
|
|
386082
|
-
| Step | Job | Writes |
|
|
386083
|
-
|---|---|---|
|
|
386084
|
-
| **a. scrape / run** | Put cases in the ledger; spawn a blind answerer | cases; \`attempt\`, \`tool_call\` |
|
|
386085
|
-
| **b. eval** | Judge the answer; score which required entities retrieval delivered | \`score\` |
|
|
386086
|
-
| **c. diagnose** | Why it failed, who owns it | \`issue\` / \`issue_status\`. Stop. Do not edit. |
|
|
386087
|
-
| **d. improve** | One smallest model edit, then the acceptance check | improve writes \`candidate\`; you write \`acceptance_check\`. Revert on reject. |
|
|
386088
|
-
| **e. checkpoint** | Git commit after an accepted acceptance check | \`checkpoint\` event, then the commit |
|
|
386089
|
-
|
|
386090
|
-
Every run then **reports** (below). A run whose result exists only as JSONL and
|
|
386091
|
-
a scrolled-away console summary has not been delivered.
|
|
386092
|
-
|
|
386093
|
-
**scrape** and **run** share a letter but are not the same job. Scrape writes
|
|
386094
|
-
cases. Run writes attempts. Do not invent questions and score
|
|
386095
|
-
them in one breath.
|
|
386096
|
-
|
|
386097
|
-
**Step d is \`skill:eval-improve\`, not a hand edit plus another arm.** The
|
|
386098
|
-
failure mode is specific and it has happened: the edits were made by hand,
|
|
386099
|
-
published, and validated by re-running the whole arm -- with the answer key
|
|
386100
|
-
repaired in the same window. No \`candidate\`, \`acceptance_check\` or \`checkpoint\`
|
|
386101
|
-
event exists in that run's ledger, and the resulting move cannot be attributed
|
|
386102
|
-
to the model, the key, or the answerer. Re-running a whole arm after changing
|
|
386103
|
-
two things is the one thing the acceptance check exists to replace: it scores
|
|
386104
|
-
the edit against dev AND holdout, and it is cheaper than the arm.
|
|
386105
|
-
|
|
386106
|
-
### Scrape, minimally
|
|
386107
|
-
|
|
386108
|
-
Importing an existing corpus IS the scrape step: copy the set from its home
|
|
386109
|
-
(for example a benchmarks checkout) into \`evals/<set>/\` and convert to the
|
|
386110
|
-
ledger shapes. **\`skill:eval-import\` is that job in full** -- how to classify
|
|
386111
|
-
what arrived with each question, why nothing imports as a verified value, and
|
|
386112
|
-
the seal that makes a later edit to a question detectable. Read it whenever the
|
|
386113
|
-
questions came from outside, which is most of the time. While importing:
|
|
386114
|
-
|
|
386115
|
-
- Freeze each case's \`split\`: \`dev\` or \`holdout\`. Diagnose and improve read
|
|
386116
|
-
dev cases only; the acceptance check runs both. A set that is all dev cannot defend an
|
|
386117
|
-
accept.
|
|
386118
|
-
- Later, each diagnosed-and-fixed failure becomes a new frozen dev case, so a
|
|
386119
|
-
fixed bug cannot silently return.
|
|
386120
|
-
|
|
386121
|
-
Scraping from production logs (chat transcripts, retrieval traces) is the
|
|
386122
|
-
other supported source, and usually the better one: real traffic asks what
|
|
386123
|
-
people actually ask. Where your logs physically live is a host concern; look
|
|
386124
|
-
for a host-specific log-fetching skill. \`skill:eval-import\` takes over once
|
|
386125
|
-
you have the text, and its \`reference/case-format.md\` covers what a log pull
|
|
386126
|
-
needs that a question list does not.
|
|
386127
|
-
|
|
386128
|
-
Prefer variety over volume when you sample, from either source. Cases that
|
|
386129
|
-
differ in grain, source, filter shape, and phrasing are what move a
|
|
386130
|
-
measurement; a second sample of the same case is nearly free of new
|
|
386131
|
-
information.
|
|
386132
|
-
|
|
386133
|
-
### Mode aliases
|
|
386134
|
-
|
|
386135
|
-
Older mode names still work as aliases for how far one run walks:
|
|
386136
|
-
|
|
386137
|
-
| Alias | Steps |
|
|
386138
|
-
|---|---|
|
|
386139
|
-
| \`measure\` | scrape/run + eval |
|
|
386140
|
-
| \`triage\` | plus diagnose |
|
|
386141
|
-
| \`improve\` | plus improve + acceptance check + checkpoint on accept |
|
|
386142
|
-
|
|
386143
|
-
Say which alias (or which steps) you are running before the first question.
|
|
386144
|
-
Record it in \`run.json\`. Do not mix steps in a way that lets the answerer see
|
|
386145
|
-
gold, issues, or the model file.
|
|
386146
|
-
|
|
386147
|
-
Most runs should stop after eval. Diagnose when you need a histogram of
|
|
386148
|
-
components and owners. Improve only for diagnosed *model* gaps, one batch at
|
|
386149
|
-
a time. Checkpoint only after the acceptance check **accepts**.
|
|
386150
|
-
|
|
386151
|
-
## Roles
|
|
386152
|
-
|
|
386153
|
-
| Role | Sees |
|
|
386154
|
-
|---|---|
|
|
386155
|
-
| **Answerer** | The question and the Malloy tools. Never the golden, \`evals/\`, the model file, or any hint it is being evaluated. |
|
|
386156
|
-
| **Judge** | The golden and the prediction. Never conducts, never answers, never edits. One fresh subagent per verdict (\`skill:eval-judge\`). |
|
|
386157
|
-
| **You (conductor / improver)** | Everything, including goldens and traces. |
|
|
386158
|
-
| **Acceptance check** | The edit and the evidence. Never the improver's self-assessment alone. |
|
|
386159
|
-
|
|
386160
|
-
The answerer stays blind. That is not optional. A grader-visible answerer
|
|
386161
|
-
writes toward the expected answer, and the score is fiction.
|
|
386162
|
-
|
|
386163
|
-
There are no eval MCP tools on purpose. The answerer inherits your tools,
|
|
386164
|
-
including Shell and Read, so any eval convenience surface would also be a
|
|
386165
|
-
gold path for it. Blindness is prevention plus detection, not a guarantee:
|
|
386166
|
-
\`eval-answer\` runs the contamination checklist on every attempt, which is
|
|
386167
|
-
why you keep a host-side tool-use log per answerer.
|
|
386168
|
-
|
|
386169
|
-
## Pick the target first
|
|
386170
|
-
|
|
386171
|
-
Both a local model server and a hosted platform expose the same two tools the
|
|
386172
|
-
answerer needs, \`get_context\` and \`execute_query\`, so the loop runs against
|
|
386173
|
-
either. What differs is which model is answering and whose data it reads, and
|
|
386174
|
-
those are two separate axes:
|
|
386175
|
-
|
|
386176
|
-
| Target | Model under test | Data | Can edit and re-test? |
|
|
386177
|
-
|---|---|---|---|
|
|
386178
|
-
| **Local (direct)** | your working files | local (for example duckdb), or a direct warehouse connection | yes |
|
|
386179
|
-
| **Local (proxied)** | your working files | the platform's connection, through a proxy connection type | yes |
|
|
386180
|
-
| **Remote** | the published version, through the platform's hosted \`get_context\`/\`execute_query\` | the platform's | no, publishing is not an eval action |
|
|
386181
|
-
|
|
386182
|
-
The middle row is the one worth knowing about: it decouples the two axes, so you
|
|
386183
|
-
can evaluate a model you are still editing against the customer's real data. It
|
|
386184
|
-
is a connection configuration, not a feature.
|
|
386185
|
-
|
|
386186
|
-
### Three ways to reach the tools
|
|
386187
|
-
|
|
386188
|
-
That table is about which MODEL answers. A second, independent choice is where
|
|
386189
|
-
the answerer's MCP tools come from, and \`--mcp-url\` is the whole of it:
|
|
386190
|
-
|
|
386191
|
-
| | \`--target\` | \`--mcp-url\` | Auth |
|
|
386192
|
-
|---|---|---|---|
|
|
386193
|
-
| **1. Local Publisher** | \`local\` | \`http://localhost:4040/mcp\` (default) | none |
|
|
386194
|
-
| **2. Hosted, through an editor extension's local bridge** | \`platform\` | the localhost URL the extension prints | none: the extension holds the credential |
|
|
386195
|
-
| **3. Hosted, directly** | \`platform\` | the host's \`https\` endpoint, scoped if it offers one | a cached OAuth login, once, interactively |
|
|
386196
|
-
|
|
386197
|
-
\`\`\`bash
|
|
386198
|
-
# 1. local
|
|
386199
|
-
--target local # --mcp-url defaults to the local Publisher
|
|
386200
|
-
|
|
386201
|
-
# 2. hosted via the extension's bridge -- no OAuth, but check what it exposes:
|
|
386202
|
-
# the same proxy may front a local Publisher instead
|
|
386203
|
-
--target platform --mcp-url http://localhost:<port-the-extension-prints>/mcp \\
|
|
386204
|
-
--hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>
|
|
386205
|
-
|
|
386206
|
-
# 3. hosted directly -- authenticate first, under the SAME server name
|
|
386207
|
-
claude mcp add --transport http <name> <scoped-url>
|
|
386208
|
-
claude # /mcp -> <name> -> Authenticate
|
|
386209
|
-
--target platform --mcp-url <scoped-url> \\
|
|
386210
|
-
--hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>
|
|
386211
|
-
\`\`\`
|
|
386212
|
-
|
|
386213
|
-
All three hand the answerer the same three capabilities (\`get_context\`,
|
|
386214
|
-
\`execute_query\`, and the docs search), so a comparison between them is between
|
|
386215
|
-
agents that could do the same things; \`test_the_two_arms_hold_the_same_capabilities\`
|
|
386216
|
-
pins it. Modes 2 and 3 take those names from \`--hosted-tools\`, which defaults to
|
|
386217
|
-
the bare trio; pass it only if this host names them differently.
|
|
386218
|
-
|
|
386219
|
-
A platform run left on the local default \`--mcp-url\` is refused rather than
|
|
386220
|
-
probed, because pointing every answerer at a local Publisher measures a
|
|
386221
|
-
different model over different data than the run claims.
|
|
386222
|
-
|
|
386223
|
-
Two rules follow, and both are the kind of mistake that produces confident
|
|
386224
|
-
nonsense rather than an error:
|
|
386225
|
-
|
|
386226
|
-
- **The answerer and the conductor must hit the same target.** If the answerer
|
|
386227
|
-
queries the published model and you re-execute its query against your edited
|
|
386228
|
-
local copy, the score describes neither. Decide the target before the first
|
|
386229
|
-
question and record it.
|
|
386230
|
-
- **Pin the version the target actually served, not the one you happen to have.**
|
|
386231
|
-
A local target pins a commit; a platform target pins the published version.
|
|
386232
|
-
Recording a local commit for a run that queried a published model is a pin
|
|
386233
|
-
that means nothing.
|
|
386234
|
-
|
|
386235
|
-
Which target for which job:
|
|
386236
|
-
|
|
386237
|
-
- **Baseline what customers experience:** Remote. It is the deployed model
|
|
386238
|
-
through the deployed engine, which is the thing they actually hit. The judge
|
|
386239
|
-
sees no re-executed rows on a Remote run (there is no local copy of the
|
|
386240
|
-
bytes), so its verdicts rest on the answer text and the golden; say so.
|
|
386241
|
-
- **Improve and accept:** local, because the acceptance check needs compile,
|
|
386242
|
-
reload, and a fresh re-answer between edits. Publishing to a customer
|
|
386243
|
-
environment to score an edit is not something this loop does. Where the host
|
|
386244
|
-
offers draft execution, that counts as local for this purpose.
|
|
386245
|
-
- **Measure real data without touching production:** local proxied.
|
|
386246
|
-
|
|
386247
|
-
So a measure-only run can use any target; a run that includes **improve** needs
|
|
386248
|
-
a local one.
|
|
386249
|
-
|
|
386250
|
-
Two things to check before a platform run, because neither errors and both make
|
|
386251
|
-
the run measure something other than what it names:
|
|
386252
|
-
|
|
386253
|
-
- **The answerer's skills must be written for THIS host.** A shared skill names
|
|
386254
|
-
an MCP tool by its bare name (\`get_context\`) so it reads correctly anywhere,
|
|
386255
|
-
but a host/router skill names its own host's tools directly. Install the
|
|
386256
|
-
latter for the wrong host and the answerer is told to call tools it does not
|
|
386257
|
-
have. \`run_baseline.py\` warns when the manifest it loaded names Publisher-only
|
|
386258
|
-
tools on a platform target; point \`--answerer-manifest\`, or \`--skills-root\`,
|
|
386259
|
-
at the checkout that ships this host's manifest.
|
|
386260
|
-
- **The tool names are configuration.** \`--hosted-mcp-server\` is both the
|
|
386261
|
-
\`mcp__<server>__<tool>\` prefix and the OAuth cache key, so it has to match the
|
|
386262
|
-
name the answerer authenticated under, and \`--hosted-tools\` lists the bare
|
|
386263
|
-
tools that host exposes.
|
|
386264
|
-
- **Get the hosted tools in front of a headless answerer, one of two ways.**
|
|
386265
|
-
A spawned answerer cannot complete an OAuth flow, so the tools have to be
|
|
386266
|
-
reachable before the run starts. \`run_baseline.py\` proves it with one cheap
|
|
386267
|
-
probe and refuses to spend an arm otherwise -- a run whose answerers have no
|
|
386268
|
-
tools does not error, it reads as a terrible model.
|
|
386269
|
-
|
|
386270
|
-
1. **Authenticate once, interactively.** Works anywhere, including a plain
|
|
386271
|
-
CLI install, and is the route to assume unless you know otherwise. The
|
|
386272
|
-
token is cached per server NAME, so authenticate under the same name the
|
|
386273
|
-
run passes to \`--hosted-mcp-server\`:
|
|
386274
|
-
|
|
386275
|
-
\`\`\`bash
|
|
386276
|
-
claude mcp add --transport http <name> <scoped-url>
|
|
386277
|
-
claude # then /mcp -> <name> -> Authenticate
|
|
386278
|
-
\`\`\`
|
|
386279
|
-
|
|
386280
|
-
Then come back and run. This is a hand-off to a person; there is no
|
|
386281
|
-
headless equivalent, so plan for it rather than discovering it mid-run.
|
|
386282
|
-
|
|
386283
|
-
2. **A local proxy that already holds the credential.** Some hosts ship an
|
|
386284
|
-
editor extension whose local MCP proxy can expose the hosted
|
|
386285
|
-
\`get_context\` / \`execute_query\` -- often behind a setting that is off by
|
|
386286
|
-
default. Where that exists, point \`--mcp-url\` at the proxy on localhost
|
|
386287
|
-
and no OAuth step is needed, because the extension holds it. Check what
|
|
386288
|
-
the proxy actually exposes before relying on it: the same proxy may serve
|
|
386289
|
-
a local Publisher's tools instead, and then \`--hosted-tools\` is
|
|
386290
|
-
naming tools that are not there. This route is not available to someone
|
|
386291
|
-
running the CLI alone.
|
|
386292
|
-
|
|
386293
|
-
- **Prefer a SCOPED endpoint URL over asking for scope.** A hosted MCP is
|
|
386294
|
-
usually reachable two ways: a global endpoint where every call carries an
|
|
386295
|
-
organization and workspace, and a scoped one where the URL itself is the
|
|
386296
|
-
scope. \`--scope\` and the prompt can only ASK an answerer to stay in one
|
|
386297
|
-
package; a scoped URL enforces it. For an agent being measured that is the
|
|
386298
|
-
difference between a case answered against the package it names and one
|
|
386299
|
-
answered against whatever else the account can see. Authenticate once
|
|
386300
|
-
interactively (\`claude\`, \`/mcp\`) under the same server name the run will use;
|
|
386301
|
-
the token is cached per name, and a spawned headless answerer cannot complete
|
|
386302
|
-
an OAuth flow.
|
|
386303
|
-
|
|
386304
|
-
- **Pin the VERSION in the scope, not just the package.** Write
|
|
386305
|
-
\`--scope <env>/<package>@<version>\`. Both hosted tools take a version and both
|
|
386306
|
-
document the same default for an omitted one: the PINNED version, which is
|
|
386307
|
-
whatever the workspace serves at the moment of the call. So an unversioned run
|
|
386308
|
-
records \`targetVersion\` in \`run.json\` and then answers from whatever is
|
|
386309
|
-
current, and the two part company the moment anyone publishes -- including
|
|
386310
|
-
mid-run, which measures two builds under one label. \`--target-version\` fills
|
|
386311
|
-
the version in when the scope omits it, so a platform run is pinned without
|
|
386312
|
-
opting in; a scope naming a different version is refused rather than taken as
|
|
386313
|
-
an override.
|
|
386314
|
-
|
|
386315
|
-
## Before you start
|
|
386316
|
-
|
|
386317
|
-
1. The model package under evaluation must live in a git repository, with
|
|
386318
|
-
\`evals/<set>/\` in the package, beside the model files. Git is the checkpoint
|
|
386319
|
-
mechanism; without it there is no rollback and no run can include improve.
|
|
386320
|
-
|
|
386321
|
-
Keeping the set IN the package is what stops a model edit and its answer key
|
|
386322
|
-
drifting apart: they move in one commit, so fixing a measure and forgetting
|
|
386323
|
-
the golden that depended on it stops being possible. It is safe -- measured
|
|
386324
|
-
on a running server, a \`cases.jsonl\` inside a package appears in no model
|
|
386325
|
-
listing, no notebook listing, no package resource, and 404s over HTTP, so an
|
|
386326
|
-
MCP-only answerer has no route to it.
|
|
386327
|
-
|
|
386328
|
-
What it buys differs by target. On a LOCAL Publisher it does not get you free
|
|
386329
|
-
versioning -- \`sourceContentSha\` hashes model paths only, so the set needs
|
|
386330
|
-
its own \`datasetSha\`. On a hosted target that publishes the whole package
|
|
386331
|
-
directory as an IMMUTABLE version, the set rides inside that version and
|
|
386332
|
-
\`targetVersion\` pins model and answer key together; nothing can be edited
|
|
386333
|
-
under a published version, which is what makes it a pin. Check which you have
|
|
386334
|
-
before deciding how much of this you need.
|
|
386335
|
-
|
|
386336
|
-
**Look for a set and for prior runs before you author either.** A minute of
|
|
386337
|
-
\`find . -name cases.jsonl\`, a glance at \`evals/*/runs/\` and at your host's
|
|
386338
|
-
own transcripts for this repo. Two sessions fourteen minutes apart built the
|
|
386339
|
-
same 29-case answer key from scratch, because the first had committed
|
|
386340
|
-
nothing before it was deleted and the second had no way to know it existed.
|
|
386341
|
-
Roughly a working day was spent twice, and three specific things were
|
|
386342
|
-
rediscovered at cost: the MCP login flow, the retrieval gate's 401, and a
|
|
386343
|
-
judge-rendering bug that had already cost $17 of arm once. The two keys,
|
|
386344
|
-
independently authored from the same questions, differ by about ten points
|
|
386345
|
-
on comparable answers -- which is the available measure of how much a key
|
|
386346
|
-
depends on its author, and a reason to reuse one rather than rebuild it.
|
|
386347
|
-
|
|
386348
|
-
**Audit the set's entity ids before the first arm.** \`verify_goldens.py\`
|
|
386349
|
-
checks that each id names something in the model; \`check_findable.py\` checks
|
|
386350
|
-
that a search of its own kind actually returns it. Both are free of model
|
|
386351
|
-
calls. An id that fails either scores a retrieval miss on every run, and the
|
|
386352
|
-
miss reads as the model's fault.
|
|
386353
|
-
|
|
386354
|
-
**Commit the set before you spend money on an arm**, and keep durable
|
|
386355
|
-
outputs in the repository. A findings document in \`~/Downloads\` is gone the
|
|
386356
|
-
first time somebody tidies up; the set, the run directory and the write-up
|
|
386357
|
-
belong in git beside the model.
|
|
386358
|
-
|
|
386359
|
-
2. The server must be up. Where your host offers retrieval tracing, turn it on
|
|
386360
|
-
and confirm a trace lookup answers, so a call's ranked results can be
|
|
386361
|
-
recovered afterwards.
|
|
386362
|
-
|
|
386363
|
-
**Open-source Publisher has none.** \`PUBLISHER_MCP_TRACE=retrieval\` is set by
|
|
386364
|
-
\`serve.py\` and read by nothing: the trace store was written and never merged,
|
|
386365
|
-
so there is no trace tool and \`traceId\` is null on every local attempt. This
|
|
386366
|
-
does not block a scored run, because attribution never depended on it:
|
|
386367
|
-
\`rankedSummary\` is copied onto the \`tool_call\` event at capture, precisely so
|
|
386368
|
-
the evidence survives without a store. So do not refuse a local run for want
|
|
386369
|
-
of tracing -- an earlier version of this rule did, and it refused every local
|
|
386370
|
-
run there has ever been. Refuse one whose \`tool_call\` events carry no
|
|
386371
|
-
\`rankedSummary\`, which is what attribution actually reads.
|
|
386372
|
-
|
|
386373
|
-
3. Health-check: your host's status check until it reports serving, and inspect
|
|
386374
|
-
\`loadErrors\`. A dead database that still answers HTTP is an environment
|
|
386375
|
-
failure, not a model failure. Stop and fix it. Four consecutive
|
|
386376
|
-
environment or no-result attempts means stop the run.
|
|
386377
|
-
|
|
386378
|
-
4. Load the set: scrape/import as above, or reuse an existing \`evals/<set>/\`.
|
|
386379
|
-
Never keep two live copies of one set; the set directory in the model repo
|
|
386380
|
-
is the single source of truth, versioned by \`datasetVersion\` in
|
|
386381
|
-
\`set.json\`.
|
|
386382
|
-
|
|
386383
|
-
5. Review goldens before you score. **Check how many cases can take a verdict
|
|
386384
|
-
at all**, not just how many cases there are: a golden the set stamps
|
|
386385
|
-
\`provisional\`, \`invalid\` or \`ambiguous\`, or a case with no golden, scores
|
|
386386
|
-
\`verdict: null\` and stays out of the pass rate. An imported set is
|
|
386387
|
-
\`provisional\` throughout by design (\`skill:eval-import\`), and the only thing
|
|
386388
|
-
that changes that is \`verify_goldens.py --promote\` after a re-derivation
|
|
386389
|
-
through the truth package. A run whose every key is underived is refused
|
|
386390
|
-
rather than spent; a set of bare questions runs, because its answers are
|
|
386391
|
-
what keys get derived from. A verified golden that holds a value but
|
|
386392
|
-
no local artifact stays verified by provenance and is not scorable until you
|
|
386393
|
-
have rows or a scalar to compare (the judge needs both sides). A \`criteria\`
|
|
386394
|
-
golden is the exception: it holds no value, so its clauses are both sides. If diagnosis later marks
|
|
386395
|
-
\`BAD-REFERENCE\` or \`AMBIGUOUS-REFERENCE\`, follow
|
|
386396
|
-
\`reference/golden-side-door.md\`. Both are expected in the wild; both
|
|
386397
|
-
are the golden side door below, not improve, and not a sixth step.
|
|
386398
|
-
|
|
386399
|
-
6. Create \`runs/<runId>/run.json\` with the attribution pins
|
|
386400
|
-
(\`reference/ledger-schema.md\`): mode, dataset version, **the target and the
|
|
386401
|
-
version it served** (a local target pins a commit, so commit or stash first;
|
|
386402
|
-
answering from a dirty tree pins nothing), server version, judge version and
|
|
386403
|
-
rubric sha, answerer model, call budget, trace mode. Freeze those for the
|
|
386404
|
-
whole run. Raising a call budget mid-run moved mean outcomes on an unchanged
|
|
386405
|
-
model.
|
|
386406
|
-
|
|
386407
|
-
The call budget is \`--max-turns\`, written to \`run.json\` as \`maxTurns\`.
|
|
386408
|
-
**Size it from a pilot rather than taking the default of 30.** Run the three
|
|
386409
|
-
cheapest cases uncapped (\`--max-turns 100\`) and set the cap at twice their
|
|
386410
|
-
maximum. A set whose questions need two or three sources joined does not fit
|
|
386411
|
-
a cap sized for single-source lookups: on one such set the completed
|
|
386412
|
-
attempts had a median of 17 turns and a 90th percentile of 26 against a cap
|
|
386413
|
-
of 30, and four cases died at it. A cap 15% above the 90th percentile of
|
|
386414
|
-
completed work is not a safety margin. Note also that \`malloy-analysis\` tells
|
|
386415
|
-
the answerer to persist -- retry a phrasing, let a small query settle whether
|
|
386416
|
-
a field exists -- so a tight cap and that instruction are in direct conflict.
|
|
386417
|
-
|
|
386418
|
-
**Do not change the model and the measuring instrument in the same step.**
|
|
386419
|
-
Those are two different axes and only one of them is cheap to separate.
|
|
386420
|
-
|
|
386421
|
-
Batching MODEL edits is fine and expected. Clustering exists so that one
|
|
386422
|
-
edit closes several cases, and an arm per fix does not survive contact with
|
|
386423
|
-
arithmetic: 20 fixes over 100 cases is 2,000 answers, and at the measured
|
|
386424
|
-
$0.33 a case on a proxied warehouse that is $660 of answering to attribute
|
|
386425
|
-
what the acceptance check attributes for the price of the affected cases
|
|
386426
|
-
plus holdout. Budget five arms for a defensible claim (a baseline, two for
|
|
386427
|
-
the A/A, two post-edit), not one per edit, and let \`skill:eval-improve\`
|
|
386428
|
-
carry each cluster.
|
|
386429
|
-
|
|
386430
|
-
The instrument is the other axis: the answer key, the judge and its prompt,
|
|
386431
|
-
the answerer model, the skills. Move one of those together with the model
|
|
386432
|
-
and there is nothing left holding still, so the result measures neither. On
|
|
386433
|
-
the run this comes from, nine model commits and a re-derived answer key
|
|
386434
|
-
landed between two arms, and the move from 20% to 39% belongs to no one --
|
|
386435
|
-
not because two model edits were batched, but because the ruler changed at
|
|
386436
|
-
the same time as the thing being measured. A key repair mid-improve is not
|
|
386437
|
-
forbidden; it ends that comparison, so re-baseline rather than quoting a
|
|
386438
|
-
delta across it.
|
|
386439
|
-
|
|
386440
|
-
The answerer model is instrument too, and it is the one most often left
|
|
386441
|
-
unstated. The same 23-case set read 12 match / 11 near / 0 no_match on one
|
|
386442
|
-
model and 8 / 4 / 15 on a smaller one. No statement about "the agent's"
|
|
386443
|
-
capability means anything until two arms name the same answerer.
|
|
386444
|
-
|
|
386445
|
-
7. Generate every answerer prompt from the stored case in \`cases.jsonl\`.
|
|
386446
|
-
Never retype the question. A truncated retype is indistinguishable from a
|
|
386447
|
-
real question downstream.
|
|
386448
|
-
|
|
386449
|
-
## Per question
|
|
386450
|
-
|
|
386451
|
-
1. Health-check again.
|
|
386452
|
-
2. Spawn a *fresh* blind subagent. Give it only the question text and the
|
|
386453
|
-
Malloy analysis tools. Tell it to follow the \`malloy-analysis\` skill. Do not
|
|
386454
|
-
mention eval, gold, scoring, or this skill.
|
|
386455
|
-
3. Keep a host-side tool-use log for that subagent (name, input path or
|
|
386456
|
-
command, MCP tool name). Publisher traces see MCP only; a Read of a gold
|
|
386457
|
-
CSV is invisible server-side.
|
|
386458
|
-
4. \`skill:eval-answer\`: contamination first, then re-execute, then the judge,
|
|
386459
|
-
then events.
|
|
386460
|
-
5. \`skill:eval-diagnose\` only when this run includes diagnose, only on dev
|
|
386461
|
-
cases, and only after the score event exists.
|
|
386462
|
-
6. \`skill:eval-improve\` only when this run includes improve, and only for
|
|
386463
|
-
\`owner: model\`. Then run the acceptance check. On accept, checkpoint.
|
|
386464
|
-
|
|
386465
|
-
## Report the run, or nobody can read it
|
|
386466
|
-
|
|
386467
|
-
A run directory is JSONL. It is a record, not a result, and the console summary
|
|
386468
|
-
scrolls away. **Every run ends by producing something a person can open**, and
|
|
386469
|
-
that job is \`skill:eval-report\`: it builds the servable run package (the case
|
|
386470
|
-
matrix app and the aggregate notebook) and gives the template for the write-up.
|
|
386471
|
-
|
|
386472
|
-
Read it at the end of every run, including a run that failed. The standing
|
|
386473
|
-
complaint about this loop is that "a bunch of stuff happens and it is hard to
|
|
386474
|
-
know the actual results", and a ledger nobody renders is why.
|
|
386475
|
-
|
|
386476
|
-
Two rules from it are worth repeating here, because they are the ones a
|
|
386477
|
-
conductor skips:
|
|
386478
|
-
|
|
386479
|
-
- **Separate a MODEL failure from an EVAL failure.** A wrong answer and a
|
|
386480
|
-
broken measurement look identical in a pass rate and have nothing else in
|
|
386481
|
-
common. An arm holding a truncated, contaminated or environment-failed
|
|
386482
|
-
attempt has no rate to quote at all.
|
|
386483
|
-
- **Say when a step did not run, and why.** \`improve\` not running because every
|
|
386484
|
-
cluster came back \`owner: agent-skill\` is a RESULT, and it reads identically
|
|
386485
|
-
to having forgotten unless it is written down.
|
|
386486
|
-
|
|
386487
|
-
Keep the write-up in the repository beside the set, not in a chat log and not
|
|
386488
|
-
in \`~/Downloads\`.
|
|
386489
|
-
|
|
386490
|
-
## Checkpoint
|
|
386491
|
-
|
|
386492
|
-
A checkpoint is a git commit of the model repository, taken after an acceptance check
|
|
386493
|
-
accepts, so a bad improve direction can be rolled back. It is not a report,
|
|
386494
|
-
and it is not a remote publish.
|
|
386495
|
-
|
|
386496
|
-
1. Commit the model files AND the set's ledger in one commit; put the label
|
|
386497
|
-
and the closed issue ids in the message.
|
|
386498
|
-
2. Append the \`checkpoint\` event (\`action: created\`, label, \`modelGitSha\`
|
|
386499
|
-
from the commit you just made, issueIds). The event line itself rides in
|
|
386500
|
-
the next commit; append-only logs trail by one commit and that is fine.
|
|
386501
|
-
3. Confirm \`git status\` is clean for the model files.
|
|
386502
|
-
|
|
386503
|
-
**Restore**: \`git checkout <sha> -- <model files>\` (or \`git revert\` the
|
|
386504
|
-
checkpoint commits), then reload the package, then append a \`checkpoint\`
|
|
386505
|
-
event with \`action: restored\` and the sha. Readers return to the model that
|
|
386506
|
-
existed before the bad direction.
|
|
386507
|
-
|
|
386508
|
-
Take a checkpoint of the current model *before* the first improve batch if no
|
|
386509
|
-
commit pins it yet. Rolling back by hand is guesswork.
|
|
386510
|
-
|
|
386511
|
-
If reload reports \`mode: reinstalled\`, the package was re-fetched from its
|
|
386512
|
-
install location and may have overwritten the restored files. Prefer in-place
|
|
386513
|
-
/ watch-mounted packages for this loop.
|
|
386514
|
-
|
|
386515
|
-
## Out of scope
|
|
386516
|
-
|
|
386517
|
-
This loop is local. The ledger is files, the checkpoints are git, you are the
|
|
386518
|
-
conductor. Do not:
|
|
386519
|
-
|
|
386520
|
-
- publish the model to a hosted platform as a "true" checkpoint or learning
|
|
386521
|
-
curve
|
|
386522
|
-
- start a Python orchestrator (\`loop.py\`, \`run_all.py\`, \`improve_batch.py\`)
|
|
386523
|
-
that runs the five steps end to end unattended. You conduct; the scripts are
|
|
386524
|
-
the steps, not the sequencing. There are more than twenty of them and they
|
|
386525
|
-
are not the exception to this: each does one step you invoke and hands back a
|
|
386526
|
-
result you read. What is forbidden is a script that decides what to do next,
|
|
386527
|
-
because every judgement this loop protects lives in that decision
|
|
386528
|
-
- score by string-diffing rows instead of judging them, or reintroduce a
|
|
386529
|
-
scripted row oracle: one that can pass a wrong answer is worse than none
|
|
386530
|
-
- wait for a bigger gold set before the loop can run; dev/holdout on what
|
|
386531
|
-
exists beats waiting
|
|
386532
|
-
- register eval MCP tools or stand up an eval API
|
|
386533
|
-
- encode unsettled goldens into the model
|
|
386534
|
-
|
|
386535
|
-
## Prime directives
|
|
386536
|
-
|
|
386537
|
-
- The model is the only thing improve edits. No question text, qids, or
|
|
386538
|
-
expected values in any name, doc, or comment.
|
|
386539
|
-
- **You are measuring a model, not reviewing this harness.** Read a script when
|
|
386540
|
-
a number you have to report cannot be explained otherwise, and stop there. Do
|
|
386541
|
-
not audit the scripts, propose fixes to them, or hand back tooling critique
|
|
386542
|
-
in place of a result -- a run that ends in harness feedback has not answered
|
|
386543
|
-
the question it was asked. A defect that changed a number gets one sentence
|
|
386544
|
-
in the report; a defect that changed nothing gets one line in chat, once.
|
|
386545
|
-
Fixing it is a different task, and the user starts it.
|
|
386546
|
-
- When the environment misbehaves, stop. Never diagnose a sick system. The
|
|
386547
|
-
harness is part of the environment: a known-broken measurement does not
|
|
386548
|
-
become quotable by being finished. Measured against this directive, an arm
|
|
386549
|
-
already paid for gets quoted anyway -- it happened three times in one run,
|
|
386550
|
-
once with the confound stated in the same message that started the arm -- so
|
|
386551
|
-
the harness now enforces the parts it can. A run with a truncated or
|
|
386552
|
-
contaminated attempt prints no pass rate and records \`status: incomplete\`,
|
|
386553
|
-
and \`flip_table.py\` names what one arm left unscored that the other did not.
|
|
386554
|
-
Read \`run_error\` on the attempts
|
|
386555
|
-
and the INCOMPLETE line before quoting any number.
|
|
386556
|
-
- When a subagent disagrees with you, probe. Do not win by authority.
|
|
386557
|
-
- When a rule here is wrong, change this file and note it on the run.
|
|
386558
|
-
|
|
386559
|
-
## Related skills
|
|
386560
|
-
|
|
386561
|
-
- \`skill:eval-answer\`: contamination, judge protocol, events. Its
|
|
386562
|
-
\`reference/ledger-schema.md\` is the file contract; \`skill:eval-judge\` is
|
|
386563
|
-
the judge.
|
|
386564
|
-
- \`skill:eval-diagnose\`: component, owner, issue events. No edit.
|
|
386565
|
-
- \`skill:eval-improve\`: smallest model edit, probe receipts, no self-accept.
|
|
386566
|
-
- \`skill:eval-report\`: the run package and the write-up a person reads.
|
|
386567
|
-
- The \`malloy-analysis\` skill: what the blind answerer follows. It is installed
|
|
386568
|
-
from the \`analysis\` manifest group, not the \`eval\` group.
|
|
386569
|
-
|
|
386570
|
-
## Reference files over MCP
|
|
386571
|
-
|
|
386572
|
-
This skill's \`reference/\` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read \`reference/<name>.md\`, get the prompt named \`eval-loop/<name>\` instead.
|
|
386573
|
-
|
|
386574
|
-
Available: acceptance-check, auditing-an-answer-key, checking-the-judge, golden-side-door, measurement, running-a-run.`
|
|
386212
|
+
description: "Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint. You are the conductor: import cases into the file ledger, spawn a blind answerer, then run eval-answer, eval-diagnose, and eval-improve. Persistence is plain files: the set in the model package''s evals/ directory, runs in the set''s workdir; checkpoints are git commits of the model repo. `eval.py` runs each step from the set''s eval.toml. Use to score a model, diagnose failures, improve behind an acceptance check, or roll back a bad direction.",
|
|
386213
|
+
body: "# The Evaluation Loop\n\nYou conduct this loop. There is no batch orchestrator to start, no eval API,\nand no eval MCP tools. The ledger is plain files: the set in the model\npackage's git repository, and each run in the set's workdir\n(`reference/ledger-schema.md` in `skill:eval-answer` defines every file and\nevent). `scripts/eval.py` runs each step from the set's `eval.toml`;\n`reference/running-a-run.md` has the commands. Scoring is an LLM judge you spawn per case. There is no\nscripted scorer, and there will not be one: a script that can pass a wrong\nanswer is worse than none. The scripts under `scripts/` run the loop -- they\nanswer, re-execute, spawn the judge, compare runs, and write the ledger -- but\nnone of them decides whether an answer was right.\n\n```\nscrape/run -> eval -> diagnose -> improve -> checkpoint\n```\n\n**This skill conducts; it does not restate.** Scoring lives in\n`skill:eval-answer`. Components and owners live in `skill:eval-diagnose`.\nEdit rules live in `skill:eval-improve`.\n\nDo not merge **eval** into **diagnose**. A conductor who scores while\nexplaining writes the explanation into the score. Do not skip the **acceptance\ncheck** inside improve. The acceptance check decides whether *this* edit\nstays. **Checkpoint** decides whether a *sequence* of accepted edits can be\nundone.\n\n## Where the rest of this lives\n\nThis file is the procedure. The things it used to carry inline are files beside\nit now, because each is needed at one moment rather than every run, and loading\nall of them for every run is how a skill stops being read.\n\n| When | Read |\n|---|---|\n| the set does not exist yet, or has never run | `reference/setting-up-a-set.md` |\n| about to run one | `reference/running-a-run.md` |\n| a golden is wrong, doubted, or out of step with the model | `reference/golden-side-door.md` |\n| auditing a key you doubt, or a set you did not author | `reference/auditing-an-answer-key.md` |\n| deciding whether an edit stays | `reference/acceptance-check.md` |\n| about to quote a number, set the band, or read a set's flips | `reference/measurement.md` |\n| the run finished and someone has to read it | `skill:eval-report` |\n| you changed judge doctrine or its inputs | `reference/checking-the-judge.md` |\n\nRead the file, do not work from the summary here. The acceptance-check rules and\nthe golden side door are both places where acting on a half-memory of the rule\nproduces a confident wrong answer rather than an error.\n\n## The five steps\n\n| Step | Job | Writes |\n|---|---|---|\n| **a. scrape / run** | Put cases in the ledger; spawn a blind answerer | cases; `attempt`, `tool_call` |\n| **b. eval** | Judge the answer; score which required entities retrieval delivered | `score` |\n| **c. diagnose** | Why it failed, who owns it | `issue` / `issue_status`. Stop. Do not edit. |\n| **d. improve** | One smallest model edit, then the acceptance check | improve writes `candidate`; you write `acceptance_check`. Revert on reject. |\n| **e. checkpoint** | Git commit after an accepted acceptance check | `checkpoint` event, then the commit |\n\nEvery run then **reports** (below). A run whose result exists only as JSONL and\na scrolled-away console summary has not been delivered.\n\n**scrape** and **run** share a letter but are not the same job. Scrape writes\ncases. Run writes attempts. Do not invent questions and score\nthem in one breath.\n\n**Step d is `skill:eval-improve`, not a hand edit plus another arm.** The\nfailure mode is specific and it has happened: the edits were made by hand,\npublished, and validated by re-running the whole arm -- with the answer key\nrepaired in the same window. No `candidate`, `acceptance_check` or `checkpoint`\nevent exists in that run's ledger, and the resulting move cannot be attributed\nto the model, the key, or the answerer. Re-running a whole arm after changing\ntwo things is the one thing the acceptance check exists to replace: it scores\nthe edit against dev AND holdout, and it is cheaper than the arm.\n\n### Scrape, minimally\n\nImporting an existing corpus IS the scrape step: copy the set from its home\n(for example a benchmarks checkout) into `evals/<set>/` and convert to the\nledger shapes. **`skill:eval-import` is that job in full** -- how to classify\nwhat arrived with each question, why nothing imports as a verified value, and\nthe seal that makes a later edit to a question detectable. Read it whenever the\nquestions came from outside, which is most of the time. While importing:\n\n- Freeze each case's `split`: `dev` or `holdout`. Diagnose and improve read\n dev cases only; the acceptance check runs both. A set that is all dev cannot defend an\n accept.\n- Later, each diagnosed-and-fixed failure becomes a new frozen dev case, so a\n fixed bug cannot silently return.\n\nScraping from production logs (chat transcripts, retrieval traces) is the\nother supported source, and usually the better one: real traffic asks what\npeople actually ask. Where your logs physically live is a host concern; look\nfor a host-specific log-fetching skill. `skill:eval-import` takes over once\nyou have the text, and its `reference/case-format.md` covers what a log pull\nneeds that a question list does not.\n\nPrefer variety over volume when you sample, from either source. Cases that\ndiffer in grain, source, filter shape, and phrasing are what move a\nmeasurement; a second sample of the same case is nearly free of new\ninformation.\n\n### Mode aliases\n\nOlder mode names still work as aliases for how far one run walks:\n\n| Alias | Steps |\n|---|---|\n| `measure` | scrape/run + eval (`eval.py package` then needs `--without-diagnosis`) |\n| `triage` | plus diagnose |\n| `improve` | plus improve + acceptance check + checkpoint on accept |\n\nSay which alias (or which steps) you are running before the first question.\nRecord it in `run.json`. Do not mix steps in a way that lets the answerer see\ngold, issues, or the model file.\n\nMost runs should stop after eval. Diagnose when you need a histogram of\ncomponents and owners. Improve only for diagnosed *model* gaps, one batch at\na time. Checkpoint only after the acceptance check **accepts**.\n\n## Roles\n\n| Role | Sees |\n|---|---|\n| **Answerer** | The question and the Malloy tools. Never the golden, `evals/`, the model file, or any hint it is being evaluated. |\n| **Judge** | The golden and the prediction. Never conducts, never answers, never edits. One fresh subagent per verdict (`skill:eval-judge`). |\n| **You (conductor / improver)** | Everything, including goldens and traces. |\n| **Acceptance check** | The edit and the evidence. Never the improver's self-assessment alone. |\n\nThe answerer stays blind. That is not optional. A grader-visible answerer\nwrites toward the expected answer, and the score is fiction.\n\nThere are no eval MCP tools on purpose. The answerer inherits your tools,\nincluding Shell and Read, so any eval convenience surface would also be a\ngold path for it. Blindness is prevention plus detection, not a guarantee:\n`eval-answer` runs the contamination checklist on every attempt, which is\nwhy you keep a host-side tool-use log per answerer.\n\n## Pick the target first\n\nBoth a local model server and a hosted platform expose the same two tools the\nanswerer needs, `get_context` and `execute_query`, so the loop runs against\neither. What differs is which model is answering and whose data it reads, and\nthose are two separate axes:\n\n| Target | Model under test | Data | Can edit and re-test? |\n|---|---|---|---|\n| **Local (direct)** | your working files | local (for example duckdb), or a direct warehouse connection | yes |\n| **Local (proxied)** | your working files | the platform's connection, through a proxy connection type | yes |\n| **Remote** | the published version, through the platform's hosted `get_context`/`execute_query` | the platform's | no, publishing is not an eval action |\n\nThe middle row is the one worth knowing about: it decouples the two axes, so you\ncan evaluate a model you are still editing against the customer's real data. It\nis a connection configuration, not a feature.\n\n### Three ways to reach the tools\n\nThat table is about which MODEL answers. A second, independent choice is where\nthe answerer's MCP tools come from, and `--mcp-url` is the whole of it:\n\n| | `--target` | `--mcp-url` | Auth |\n|---|---|---|---|\n| **1. Local Publisher** | `local` | `http://localhost:4040/mcp` (default) | none |\n| **2. Hosted, through an editor extension's local bridge** | `platform` | the localhost URL the extension prints | none: the extension holds the credential |\n| **3. Hosted, directly** | `platform` | the host's `https` endpoint, scoped if it offers one | a cached OAuth login, once, interactively |\n\n```bash\n# 1. local\n--target local # --mcp-url defaults to the local Publisher\n\n# 2. hosted via the extension's bridge -- no OAuth, but check what it exposes:\n# the same proxy may front a local Publisher instead\n--target platform --mcp-url http://localhost:<port-the-extension-prints>/mcp \\\n --hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>\n\n# 3. hosted directly -- authenticate first, under the SAME server name\nclaude mcp add --transport http <name> <scoped-url>\nclaude # /mcp -> <name> -> Authenticate\n--target platform --mcp-url <scoped-url> \\\n --hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>\n```\n\nAll three hand the answerer the same three capabilities (`get_context`,\n`execute_query`, and the docs search), so a comparison between them is between\nagents that could do the same things; `test_the_two_arms_hold_the_same_capabilities`\npins it. Modes 2 and 3 take those names from `--hosted-tools`, which defaults to\nthe bare trio; pass it only if this host names them differently.\n\nA platform run left on the local default `--mcp-url` is refused rather than\nprobed, because pointing every answerer at a local Publisher measures a\ndifferent model over different data than the run claims.\n\nTwo rules follow, and both are the kind of mistake that produces confident\nnonsense rather than an error:\n\n- **The answerer and the conductor must hit the same target.** If the answerer\n queries the published model and you re-execute its query against your edited\n local copy, the score describes neither. Decide the target before the first\n question and record it.\n- **Pin the version the target actually served, not the one you happen to have.**\n A local target pins a commit; a platform target pins the published version.\n Recording a local commit for a run that queried a published model is a pin\n that means nothing.\n\nWhich target for which job:\n\n- **Baseline what customers experience:** Remote. It is the deployed model\n through the deployed engine, which is the thing they actually hit. The judge\n sees no re-executed rows on a Remote run (there is no local copy of the\n bytes), so its verdicts rest on the answer text and the golden; say so.\n- **Improve and accept:** local, because the acceptance check needs compile,\n reload, and a fresh re-answer between edits. Publishing to a customer\n environment to score an edit is not something this loop does. Where the host\n offers draft execution, that counts as local for this purpose.\n- **Measure real data without touching production:** local proxied.\n\nSo a measure-only run can use any target; a run that includes **improve** needs\na local one.\n\nTwo things to check before a platform run, because neither errors and both make\nthe run measure something other than what it names:\n\n- **The answerer's skills must be written for THIS host.** A shared skill names\n an MCP tool by its bare name (`get_context`) so it reads correctly anywhere,\n but a host/router skill names its own host's tools directly. Install the\n latter for the wrong host and the answerer is told to call tools it does not\n have. `run_baseline.py` warns when the manifest it loaded names Publisher-only\n tools on a platform target; point `--answerer-manifest`, or `--skills-root`,\n at the checkout that ships this host's manifest.\n- **The tool names are configuration.** `--hosted-mcp-server` is both the\n `mcp__<server>__<tool>` prefix and the OAuth cache key, so it has to match the\n name the answerer authenticated under, and `--hosted-tools` lists the bare\n tools that host exposes.\n- **Get the hosted tools in front of a headless answerer, one of two ways.**\n A spawned answerer cannot complete an OAuth flow, so the tools have to be\n reachable before the run starts. `run_baseline.py` proves it with one cheap\n probe and refuses to spend an arm otherwise -- a run whose answerers have no\n tools does not error, it reads as a terrible model.\n\n 1. **Authenticate once, interactively.** Works anywhere, including a plain\n CLI install, and is the route to assume unless you know otherwise. The\n token is cached per server NAME, so authenticate under the same name the\n run passes to `--hosted-mcp-server`:\n\n ```bash\n claude mcp add --transport http <name> <scoped-url>\n claude # then /mcp -> <name> -> Authenticate\n ```\n\n Then come back and run. This is a hand-off to a person; there is no\n headless equivalent, so plan for it rather than discovering it mid-run.\n\n 2. **A local proxy that already holds the credential.** Some hosts ship an\n editor extension whose local MCP proxy can expose the hosted\n `get_context` / `execute_query` -- often behind a setting that is off by\n default. Where that exists, point `--mcp-url` at the proxy on localhost\n and no OAuth step is needed, because the extension holds it. Check what\n the proxy actually exposes before relying on it: the same proxy may serve\n a local Publisher's tools instead, and then `--hosted-tools` is\n naming tools that are not there. This route is not available to someone\n running the CLI alone.\n\n- **Prefer a SCOPED endpoint URL over asking for scope.** A hosted MCP is\n usually reachable two ways: a global endpoint where every call carries an\n organization and workspace, and a scoped one where the URL itself is the\n scope. `--scope` and the prompt can only ASK an answerer to stay in one\n package; a scoped URL enforces it. For an agent being measured that is the\n difference between a case answered against the package it names and one\n answered against whatever else the account can see. Authenticate once\n interactively (`claude`, `/mcp`) under the same server name the run will use;\n the token is cached per name, and a spawned headless answerer cannot complete\n an OAuth flow.\n\n- **Pin the VERSION in the scope, not just the package.** Write\n `--scope <env>/<package>@<version>`. Both hosted tools take a version and both\n document the same default for an omitted one: the PINNED version, which is\n whatever the workspace serves at the moment of the call. So an unversioned run\n records `targetVersion` in `run.json` and then answers from whatever is\n current, and the two part company the moment anyone publishes -- including\n mid-run, which measures two builds under one label. `--target-version` fills\n the version in when the scope omits it, so a platform run is pinned without\n opting in; a scope naming a different version is refused rather than taken as\n an override.\n\n## Before you start\n\n1. The model package under evaluation must live in a git repository, with\n `evals/<set>/` in the package, beside the model files. Git is the checkpoint\n mechanism; without it there is no rollback and no run can include improve.\n\n Keeping the set IN the package is what stops a model edit and its answer key\n drifting apart: they move in one commit, so fixing a measure and forgetting\n the golden that depended on it stops being possible. It is safe -- measured\n on a running server, a `cases.jsonl` inside a package appears in no model\n listing, no notebook listing, no package resource, and 404s over HTTP, so an\n MCP-only answerer has no route to it.\n\n What it buys differs by target. On a LOCAL Publisher it does not get you free\n versioning -- `sourceContentSha` hashes model paths only, so the set needs\n its own `datasetSha`. On a hosted target that publishes the whole package\n directory as an IMMUTABLE version, the set rides inside that version and\n `targetVersion` pins model and answer key together; nothing can be edited\n under a published version, which is what makes it a pin. Check which you have\n before deciding how much of this you need.\n\n **Look for a set and for prior runs before you author either.** A minute of\n `find . -name cases.jsonl`, a glance at the set's workdir (`<workdir>/runs/`,\n `~/.malloy-eval/<set>-<hash>/runs/` unless `eval.toml` moves it) and at your host's\n own transcripts for this repo. Two sessions fourteen minutes apart built the\n same 29-case answer key from scratch, because the first had committed\n nothing before it was deleted and the second had no way to know it existed.\n Roughly a working day was spent twice, and three specific things were\n rediscovered at cost: the MCP login flow, the retrieval gate's 401, and a\n judge-rendering bug that had already cost $17 of arm once. The two keys,\n independently authored from the same questions, differ by about ten points\n on comparable answers -- which is the available measure of how much a key\n depends on its author, and a reason to reuse one rather than rebuild it.\n\n **Audit the set's entity ids before the first arm.** `verify_goldens.py`\n checks that each id names something in the model; `check_findable.py` checks\n that a search of its own kind actually returns it. Both are free of model\n calls. An id that fails either scores a retrieval miss on every run, and the\n miss reads as the model's fault.\n\n **Commit the set before you spend money on an arm**, and keep durable\n outputs in the repository. A findings document in `~/Downloads` is gone the\n first time somebody tidies up; the set and the write-up belong in git\n beside the model.\n\n **Runs go in the set's workdir, never inside the model package.** A run\n directory holds a `model.malloy` snapshot, and a built report is a Malloy\n package; inside the package under test, either puts that package into\n `loadErrors`. The default workdir, `~/.malloy-eval/<set>-<hash>/`, is outside git,\n which suits a measure-only run. A run that will improve and checkpoint needs\n its ledger kept: set `[paths] workdir` in `eval.toml` to a directory in the\n model's repository but outside the package, and gitignore its `servers/`\n and `packages/`, which are a server's database and rebuilt reports.\n\n2. The server must be up. Where your host offers retrieval tracing, turn it on\n and confirm a trace lookup answers, so a call's ranked results can be\n recovered afterwards.\n\n **Open-source Publisher has none.** `PUBLISHER_MCP_TRACE=retrieval` is set by\n `serve.py` and read by nothing: the trace store was written and never merged,\n so there is no trace tool and `traceId` is null on every local attempt. This\n does not block a scored run, because attribution never depended on it:\n `rankedSummary` is copied onto the `tool_call` event at capture, precisely so\n the evidence survives without a store. So do not refuse a local run for want\n of tracing -- an earlier version of this rule did, and it refused every local\n run there has ever been. Refuse one whose `tool_call` events carry no\n `rankedSummary`, which is what attribution actually reads.\n\n3. Health-check: your host's status check until it reports serving, and inspect\n `loadErrors`. A dead database that still answers HTTP is an environment\n failure, not a model failure. Stop and fix it. Four consecutive\n environment or no-result attempts means stop the run.\n\n4. Load the set: scrape/import as above, or reuse an existing `evals/<set>/`.\n `eval.py check --set <set>` names every gap in it before anything starts.\n Never keep two live copies of one set; the set directory in the model repo\n is the single source of truth, versioned by `datasetVersion` in\n `set.json`.\n\n5. Review goldens before you score. **Check how many cases can take a verdict\n at all**, not just how many cases there are: a golden the set stamps\n `provisional`, `invalid` or `ambiguous`, or a case with no golden, scores\n `verdict: null` and stays out of the pass rate. An imported set is\n `provisional` throughout by design (`skill:eval-import`), and the only thing\n that changes that is `verify_goldens.py --promote` after a re-derivation\n through the truth package. A run whose every key is underived is refused\n rather than spent; a set of bare questions runs, because its answers are\n what keys get derived from. A verified golden that holds a value but\n no local artifact stays verified by provenance and is not scorable until you\n have rows or a scalar to compare (the judge needs both sides). A `criteria`\n golden is the exception: it holds no value, so its clauses are both sides. If diagnosis later marks\n `BAD-REFERENCE` or `AMBIGUOUS-REFERENCE`, follow\n `reference/golden-side-door.md`. Both are expected in the wild; both\n are the golden side door below, not improve, and not a sixth step.\n\n6. `eval.py run` creates `<workdir>/runs/<runId>/run.json` with the attribution pins\n (`reference/ledger-schema.md`): mode, dataset version, **the target and the\n version it served** (a local target pins a commit, so commit or stash first;\n answering from a dirty tree pins nothing), server version, judge version and\n rubric sha, answerer model, call budget, trace mode. Freeze those for the\n whole run. Raising a call budget mid-run moved mean outcomes on an unchanged\n model.\n\n The call budget is `--max-turns`, written to `run.json` as `maxTurns`.\n **Size it from a pilot rather than taking the default of 30.** Run the three\n cheapest cases uncapped (`--max-turns 100`) and set the cap at twice their\n maximum. A set whose questions need two or three sources joined does not fit\n a cap sized for single-source lookups: on one such set the completed\n attempts had a median of 17 turns and a 90th percentile of 26 against a cap\n of 30, and four cases died at it. A cap 15% above the 90th percentile of\n completed work is not a safety margin. Note also that `malloy-analysis` tells\n the answerer to persist -- retry a phrasing, let a small query settle whether\n a field exists -- so a tight cap and that instruction are in direct conflict.\n\n **Do not change the model and the measuring instrument in the same step.**\n Those are two different axes and only one of them is cheap to separate.\n\n Batching MODEL edits is fine and expected. Clustering exists so that one\n edit closes several cases, and an arm per fix does not survive contact with\n arithmetic: 20 fixes over 100 cases is 2,000 answers, and at the measured\n $0.33 a case on a proxied warehouse that is $660 of answering to attribute\n what the acceptance check attributes for the price of the affected cases\n plus holdout. Budget five arms for a defensible claim (a baseline, two for\n the A/A, two post-edit), not one per edit, and let `skill:eval-improve`\n carry each cluster.\n\n The instrument is the other axis: the answer key, the judge and its prompt,\n the answerer model, the skills. Move one of those together with the model\n and there is nothing left holding still, so the result measures neither. On\n the run this comes from, nine model commits and a re-derived answer key\n landed between two arms, and the move from 20% to 39% belongs to no one --\n not because two model edits were batched, but because the ruler changed at\n the same time as the thing being measured. A key repair mid-improve is not\n forbidden; it ends that comparison, so re-baseline rather than quoting a\n delta across it.\n\n The answerer model is instrument too, and it is the one most often left\n unstated. The same 23-case set read 12 match / 11 near / 0 no_match on one\n model and 8 / 4 / 15 on a smaller one. No statement about \"the agent's\"\n capability means anything until two arms name the same answerer.\n\n7. Generate every answerer prompt from the stored case in `cases.jsonl`.\n Never retype the question. A truncated retype is indistinguishable from a\n real question downstream.\n\n## Per question\n\n1. Health-check again.\n2. Spawn a *fresh* blind subagent. Give it only the question text and the\n Malloy analysis tools. Tell it to follow the `malloy-analysis` skill. Do not\n mention eval, gold, scoring, or this skill.\n3. Keep a host-side tool-use log for that subagent (name, input path or\n command, MCP tool name). Publisher traces see MCP only; a Read of a gold\n CSV is invisible server-side.\n4. `skill:eval-answer`: contamination first, then re-execute, then the judge,\n then events.\n5. `skill:eval-diagnose` only when this run includes diagnose, only on dev\n cases, and only after the score event exists.\n6. `skill:eval-improve` only when this run includes improve, and only for\n `owner: model`. Then run the acceptance check. On accept, checkpoint.\n\n## Report the run, or nobody can read it\n\nA run directory is JSONL. It is a record, not a result, and the console summary\nscrolls away. **Every run ends by producing something a person can open**, and\nthat job is `skill:eval-report`: it builds the servable run package (the case\nmatrix app and the aggregate notebook) and gives the template for the write-up.\n\nRead it at the end of every run, including a run that failed. The standing\ncomplaint about this loop is that \"a bunch of stuff happens and it is hard to\nknow the actual results\", and a ledger nobody renders is why.\n\nTwo rules from it are worth repeating here, because they are the ones a\nconductor skips:\n\n- **Separate a MODEL failure from an EVAL failure.** A wrong answer and a\n broken measurement look identical in a pass rate and have nothing else in\n common. An arm holding a truncated, contaminated or environment-failed\n attempt has no rate to quote at all.\n- **Say when a step did not run, and why.** `improve` not running because every\n cluster came back `owner: agent-skill` is a RESULT, and it reads identically\n to having forgotten unless it is written down.\n\nKeep the write-up in the repository beside the set, not in a chat log and not\nin `~/Downloads`.\n\n## Checkpoint\n\nA checkpoint is a git commit of the model repository, taken after an acceptance check\naccepts, so a bad improve direction can be rolled back. It is not a report,\nand it is not a remote publish.\n\n1. Commit the model files AND the set's ledger in one commit; put the label\n and the closed issue ids in the message. The run's ledger is in that commit\n only when the workdir is in the repository (Before you start, item 1).\n2. Append the `checkpoint` event (`action: created`, label, `modelGitSha`\n from the commit you just made, issueIds). The event line itself rides in\n the next commit; append-only logs trail by one commit and that is fine.\n3. Confirm `git status` is clean for the model files.\n\n**Restore**: `git checkout <sha> -- <model files>` (or `git revert` the\ncheckpoint commits), then reload the package, then append a `checkpoint`\nevent with `action: restored` and the sha. Readers return to the model that\nexisted before the bad direction.\n\nTake a checkpoint of the current model *before* the first improve batch if no\ncommit pins it yet. Rolling back by hand is guesswork.\n\nIf reload reports `mode: reinstalled`, the package was re-fetched from its\ninstall location and may have overwritten the restored files. Prefer in-place\n/ watch-mounted packages for this loop.\n\n## Out of scope\n\nThis loop is local. The ledger is files, the checkpoints are git, you are the\nconductor. Do not:\n\n- publish the model to a hosted platform as a \"true\" checkpoint or learning\n curve\n- start a Python orchestrator (`loop.py`, `run_all.py`, `improve_batch.py`)\n that runs the five steps end to end unattended. You conduct; the scripts are\n the steps, not the sequencing. There are more than twenty of them and they\n are not the exception to this: each does one step you invoke and hands back a\n result you read. What is forbidden is a script that decides what to do next,\n because every judgement this loop protects lives in that decision\n- score by string-diffing rows instead of judging them, or reintroduce a\n scripted row oracle: one that can pass a wrong answer is worse than none\n- wait for a bigger gold set before the loop can run; dev/holdout on what\n exists beats waiting\n- register eval MCP tools or stand up an eval API\n- encode unsettled goldens into the model\n\n## Prime directives\n\n- The model is the only thing improve edits. No question text, qids, or\n expected values in any name, doc, or comment.\n- **You are measuring a model, not reviewing this harness.** Read a script when\n a number you have to report cannot be explained otherwise, and stop there. Do\n not audit the scripts, propose fixes to them, or hand back tooling critique\n in place of a result -- a run that ends in harness feedback has not answered\n the question it was asked. A defect that changed a number gets one sentence\n in the report; a defect that changed nothing gets one line in chat, once.\n Fixing it is a different task, and the user starts it.\n- When the environment misbehaves, stop. Never diagnose a sick system. The\n harness is part of the environment: a known-broken measurement does not\n become quotable by being finished. Measured against this directive, an arm\n already paid for gets quoted anyway -- it happened three times in one run,\n once with the confound stated in the same message that started the arm -- so\n the harness now enforces the parts it can. A run with a truncated or\n contaminated attempt prints no pass rate and records `status: incomplete`,\n and `flip_table.py` names what one arm left unscored that the other did not.\n Read `run_error` on the attempts\n and the INCOMPLETE line before quoting any number.\n- When a subagent disagrees with you, probe. Do not win by authority.\n- When a rule here is wrong, change this file and note it on the run.\n\n## Related skills\n\n- `skill:eval-answer`: contamination, judge protocol, events. Its\n `reference/ledger-schema.md` is the file contract; `skill:eval-judge` is\n the judge.\n- `skill:eval-diagnose`: component, owner, issue events. No edit.\n- `skill:eval-improve`: smallest model edit, probe receipts, no self-accept.\n- `skill:eval-report`: the run package and the write-up a person reads.\n- The `malloy-analysis` skill: what the blind answerer follows. It is installed\n from the `analysis` manifest group, not the `eval` group.\n\n## Reference files over MCP\n\nThis skill's `reference/` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read `reference/<name>.md`, get the prompt named `eval-loop/<name>` instead.\n\nAvailable: acceptance-check, auditing-an-answer-key, checking-the-judge, golden-side-door, measurement, running-a-run, setting-up-a-set."
|
|
386575
386214
|
},
|
|
386576
386215
|
{
|
|
386577
386216
|
name: "eval-loop/acceptance-check",
|
|
@@ -387328,25 +386967,58 @@ model score.`
|
|
|
387328
386967
|
one fresh answerer per case with only the Publisher MCP tools, a contamination
|
|
387329
386968
|
check, a judge, and a conformant \`events.jsonl\`.
|
|
387330
386969
|
|
|
386970
|
+
Every step reads the set's \`eval.toml\`: the model server's environment,
|
|
386971
|
+
package, repo and ports, and the truth server's. Write it once per set, beside
|
|
386972
|
+
\`set.json\`. Relative paths resolve against the file. A set that does not exist
|
|
386973
|
+
yet starts at \`setting-up-a-set.md\`, beside this file.
|
|
386974
|
+
|
|
386975
|
+
\`\`\`toml
|
|
386976
|
+
[model] # the Publisher the answerer queries
|
|
386977
|
+
environment = "<env>"
|
|
386978
|
+
package = "<pkg>"
|
|
386979
|
+
repo = "<path to the model package>"
|
|
386980
|
+
port = 4811
|
|
386981
|
+
mcp_port = 4812
|
|
386982
|
+
|
|
386983
|
+
[truth] # a second Publisher, serving only the truth package
|
|
386984
|
+
environment = "truth"
|
|
386985
|
+
port = 4881
|
|
386986
|
+
mcp_port = 4882
|
|
386987
|
+
\`\`\`
|
|
386988
|
+
|
|
386989
|
+
No script defaults to any particular set's environment or package. A missing
|
|
386990
|
+
one stops with the key to add. Runs, built packages and server roots go under
|
|
386991
|
+
\`~/.malloy-eval/<set>-<hash>/\` unless \`[paths] workdir\` says otherwise, never inside
|
|
386992
|
+
the repository: a run holds a \`model.malloy\` snapshot and a built package is a
|
|
386993
|
+
Malloy package, and nested in the package under test either can put that
|
|
386994
|
+
package into \`loadErrors\`. The hash is of the set's path, so two sets with one
|
|
386995
|
+
name never share a \`runs/\`. Each command below is \`python3 skills/eval-loop/scripts/eval.py\`,
|
|
386996
|
+
and any flag the underlying script takes passes through.
|
|
386997
|
+
|
|
387331
386998
|
\`\`\`bash
|
|
387332
|
-
#
|
|
387333
|
-
#
|
|
386999
|
+
# 0. every gap in the set at once, before anything starts: missing files, a
|
|
387000
|
+
# case the importer refuses, a truth package the model server would serve,
|
|
387001
|
+
# a table path that resolves to nothing once served, a port already held.
|
|
387002
|
+
# Free, and it starts nothing. Exits 1 until nothing blocks a run.
|
|
387003
|
+
eval.py check --set <set>
|
|
387004
|
+
|
|
387005
|
+
# 1. serve the model under test, and the TRUTH package on a second server the
|
|
387006
|
+
# answerer has no route to. Each runs in its own session, so the shell's
|
|
387007
|
+
# exit cannot take it down, and returns only once it answers a query. The
|
|
387008
|
+
# script writes each server's publisher.config.json from eval.toml, and
|
|
387009
|
+
# refuses a truth server on the model server's ports.
|
|
387334
387010
|
# --warm-retrieval also waits for the embedding index to settle and exits 3
|
|
387335
387011
|
# if it does not reach \`ready\`: the sync is lazy, so without it the first
|
|
387336
387012
|
# cases are answered LEXICALLY and the run reports that as the model's
|
|
387337
387013
|
# number.
|
|
387338
387014
|
# Semantic retrieval needs EMBEDDING_API_KEY in the server's environment
|
|
387339
|
-
# (the Publisher does not read OPENAI_API_KEY)
|
|
387340
|
-
# lexical, the warm-up reports
|
|
387341
|
-
# only in publisher.log
|
|
387342
|
-
|
|
387343
|
-
|
|
387344
|
-
--warm-retrieval --environment <env> --package <pkg> \\
|
|
387015
|
+
# (the Publisher does not read OPENAI_API_KEY). The script warns when it is
|
|
387016
|
+
# unset; without it every ranking is lexical, the warm-up reports
|
|
387017
|
+
# \`cooldown\`, and the provider's own error is only in publisher.log
|
|
387018
|
+
# (grep Embedding).
|
|
387019
|
+
eval.py serve model --set <set> --warm-retrieval \\
|
|
387345
387020
|
[--allow-proxy] # required for a \`publisher\`-type (proxied) connection
|
|
387346
|
-
|
|
387347
|
-
# has no route to:
|
|
387348
|
-
python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/server \\
|
|
387349
|
-
--server-root <truthroot> --port 4881 --mcp-port 4882 [--allow-proxy]
|
|
387021
|
+
eval.py serve truth --set <set> [--allow-proxy]
|
|
387350
387022
|
|
|
387351
387023
|
# 2a. audit the set against the package it is about to be scored on. Free, and
|
|
387352
387024
|
# it catches the failure that reads as a model regression: an entity id
|
|
@@ -387358,10 +387030,9 @@ python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/s
|
|
|
387358
387030
|
# --definitions <ledger> (verify_definitions.py), a set whose every
|
|
387359
387031
|
# value-bearing case rests on validated definitions exits 0 instead:
|
|
387360
387032
|
# the composition rule, values not re-derived but their definitions
|
|
387361
|
-
# checked.
|
|
387362
|
-
|
|
387363
|
-
|
|
387364
|
-
--model <repo>/ecommerce --target-package ecommerce
|
|
387033
|
+
# checked. --model adds the entity-id audit; it greps the model text, so a
|
|
387034
|
+
# column a source exposes implicitly reads as missing.
|
|
387035
|
+
eval.py verify --set <set> [--model <package>]
|
|
387365
387036
|
|
|
387366
387037
|
# 2b. ONLY on a set whose keys are still provisional -- an imported one is,
|
|
387367
387038
|
# throughout, by design. Nothing else in this toolchain writes
|
|
@@ -387370,59 +387041,50 @@ python3 skills/eval-answer/scripts/verify_goldens.py \\
|
|
|
387370
387041
|
# AND carries a second derivation, and prints the reason for every golden
|
|
387371
387042
|
# it left alone. \`--refresh\` is NOT this: it rewrites a drifted value and
|
|
387372
387043
|
# never touches a status.
|
|
387373
|
-
|
|
387374
|
-
--set <repo>/evals/ecommerce --publisher http://localhost:4881 \\
|
|
387375
|
-
--target-package ecommerce --promote
|
|
387044
|
+
eval.py verify --set <set> --promote
|
|
387376
387045
|
|
|
387377
387046
|
# 2. smoke one case first ($0.13), then the arm. Goldens are re-derived from
|
|
387378
387047
|
# the truth server before either starts; a drifted set refuses to run.
|
|
387379
|
-
|
|
387380
|
-
|
|
387381
|
-
|
|
387382
|
-
|
|
387383
|
-
|
|
387384
|
-
|
|
387385
|
-
|
|
387386
|
-
|
|
387387
|
-
# directory inside it the -dirty marker is decided over
|
|
387388
|
-
# (relative to the repo). Without --model-dir any
|
|
387389
|
-
# untracked file anywhere in the repo marks the model
|
|
387390
|
-
# dirty. The repo cannot be inferred: Publisher serves a
|
|
387391
|
-
# copy under publisher_data/, whose surrounding tree is
|
|
387392
|
-
# the server's storage, not the model's history.
|
|
387048
|
+
# [model] repo is the model PACKAGE directory. The run records the commit
|
|
387049
|
+
# of the checkout it sits in as modelGitSha, and decides the -dirty marker
|
|
387050
|
+
# over that package directory only, so an untracked file elsewhere in the
|
|
387051
|
+
# repository does not mark the model dirty. It cannot be inferred:
|
|
387052
|
+
# Publisher serves a copy under publisher_data/, whose surrounding tree is
|
|
387053
|
+
# the server's storage, not the model's history.
|
|
387054
|
+
eval.py run --set <set> --label smoke --only <qid> --no-judge
|
|
387055
|
+
eval.py run --set <set> --parallel 4
|
|
387393
387056
|
# the run names itself <set>-<phase>-<nn> (ecommerce-baseline-01, then -02
|
|
387394
|
-
# for the second arm of the A/A). Pass --label
|
|
387395
|
-
# human name; hand-typed arm names stop being
|
|
387057
|
+
# for the second arm of the A/A) and prints its directory. Pass --label
|
|
387058
|
+
# only for a run that needs a human name; hand-typed arm names stop being
|
|
387059
|
+
# readable within an afternoon.
|
|
387396
387060
|
|
|
387397
387061
|
# 3. compare two arms, or two runs of one arm. It exits 2 when the arms used
|
|
387398
387062
|
# different retrievers, because those flips measure the retriever.
|
|
387399
|
-
python3 skills/eval-loop/scripts/flip_table.py --a
|
|
387063
|
+
python3 skills/eval-loop/scripts/flip_table.py --a <run a> --b <run b>
|
|
387400
387064
|
|
|
387401
387065
|
# 3a. on an A/A, write the band into the set's calibration record. Nothing
|
|
387402
387066
|
# else may quote a band, and a band only covers runs whose pins match.
|
|
387403
387067
|
python3 skills/eval-loop/scripts/flip_table.py \\
|
|
387404
|
-
--a
|
|
387405
|
-
>> <repo>/evals/ecommerce/CALIBRATION.md
|
|
387068
|
+
--a <aa-1 run> --b <aa-2 run> --calibration >> <set>/CALIBRATION.md
|
|
387406
387069
|
|
|
387407
387070
|
# 3b. check the JUDGE, not the model, after any change to the judge skill, a
|
|
387408
387071
|
# rubric, or what the judge is shown. It also reports which fixtures are
|
|
387409
387072
|
# unpinned and which decision classes nothing covers.
|
|
387410
|
-
python3 skills/eval-loop/scripts/check_judge.py
|
|
387411
|
-
--set <repo>/evals/ecommerce --repeat 3
|
|
387073
|
+
python3 skills/eval-loop/scripts/check_judge.py --set <set> --repeat 3
|
|
387412
387074
|
|
|
387413
387075
|
# 4. FIRST: any golden in doubt, from the judge or the set. \`jq .doubtedGoldens
|
|
387414
|
-
#
|
|
387415
|
-
#
|
|
387416
|
-
#
|
|
387417
|
-
|
|
387418
|
-
--run results/<arm> --set <repo>/evals/ecommerce --model-dir <package>
|
|
387076
|
+
# <run>/run.json\` -- non-empty means settle those through the golden side
|
|
387077
|
+
# door before diagnosing, or you send a modelling agent at a model that is
|
|
387078
|
+
# already right. It probes the server the run recorded.
|
|
387079
|
+
eval.py diagnose --set <set> --label <label>
|
|
387419
387080
|
# (cluster_failures.py gives a free mechanical first look, as
|
|
387420
387081
|
# clusters-mechanical.jsonl; it groups by retrieval outcome and is not a
|
|
387421
387082
|
# diagnosis)
|
|
387422
387083
|
|
|
387423
|
-
# 5. build the browsable package
|
|
387424
|
-
|
|
387425
|
-
|
|
387084
|
+
# 5. build the browsable package. Refused until every run has a diagnosis
|
|
387085
|
+
# (--without-diagnosis overrides); a run where everything passed records an
|
|
387086
|
+
# empty one. Pass --run twice for an A/B.
|
|
387087
|
+
eval.py package --set <set> --label <label>
|
|
387426
387088
|
\`\`\`
|
|
387427
387089
|
|
|
387428
387090
|
### What the run prints at the end
|
|
@@ -387442,12 +387104,12 @@ Three layers, in this order, and the order is what makes it readable:
|
|
|
387442
387104
|
report, so this layer is the two commands that turn it into something you
|
|
387443
387105
|
read: \`build_run_package.py\` (a Malloy model over the run's CSVs,
|
|
387444
387106
|
\`notebooks/eval_run.malloy\` for the aggregate tables, and an in-package HTML app for
|
|
387445
|
-
the case matrix and its per-case drawer),
|
|
387446
|
-
|
|
387447
|
-
the
|
|
387448
|
-
|
|
387449
|
-
|
|
387450
|
-
|
|
387107
|
+
the case matrix and its per-case drawer), registered on a Publisher that
|
|
387108
|
+
is already running, with no restart. \`eval.py package\` builds it,
|
|
387109
|
+
registers it on the TRUTH server, and prints both URLs; the package holds
|
|
387110
|
+
the answer key, and that is the server the answerer has no route to. The
|
|
387111
|
+
registration command is also written into the package's README.md, for
|
|
387112
|
+
serving it again later; \`--no-register\` builds without registering.
|
|
387451
387113
|
|
|
387452
387114
|
Order of magnitude for planning, **calibrated on ecommerce over local duckdb**:
|
|
387453
387115
|
a Sonnet arm over a few dozen cases costs single-digit dollars and finishes in
|
|
@@ -387485,6 +387147,163 @@ Two failure modes worth pre-empting, because both produce a clean-looking run:
|
|
|
387485
387147
|
- **Check the served revision is the one you edited.** Publisher serves a
|
|
387486
387148
|
snapshot copy, so a model fix can be absent from the run that is supposed to
|
|
387487
387149
|
measure it. Query the changed measure once before spending an arm on it.`
|
|
387150
|
+
},
|
|
387151
|
+
{
|
|
387152
|
+
name: "eval-loop/setting-up-a-set",
|
|
387153
|
+
description: "Setting up a set on your own model. Reference detail for the eval-loop skill.",
|
|
387154
|
+
body: `<!-- The first run of your own set, in order. Read it when a set does not exist yet. -->
|
|
387155
|
+
|
|
387156
|
+
# Setting up a set on your own model
|
|
387157
|
+
|
|
387158
|
+
\`examples/storefront/evals/storefront-tour\` is a finished set. This is the path
|
|
387159
|
+
to one of your own: which files a set needs, what writes each one, and the
|
|
387160
|
+
order they go in. Every command is \`python3 skills/eval-loop/scripts/eval.py\`,
|
|
387161
|
+
run from the Publisher clone, unless it names another script.
|
|
387162
|
+
|
|
387163
|
+
\`eval.py check --set <set>\` reads everything below and names every gap at once,
|
|
387164
|
+
without starting a server or calling a model. Run it after each step; the set is
|
|
387165
|
+
ready when it prints \`ready: nothing blocks a run\`.
|
|
387166
|
+
|
|
387167
|
+
## What you need first
|
|
387168
|
+
|
|
387169
|
+
- A Malloy package with a \`publisher.json\`, in a git repository. Git is how a
|
|
387170
|
+
run pins the model it measured and how an improve step is rolled back.
|
|
387171
|
+
- A built Publisher clone (\`bun install && bun run build\`), Python 3.11+, and a
|
|
387172
|
+
Java runtime for the SDK build.
|
|
387173
|
+
- \`EMBEDDING_API_KEY\` exported, for semantic retrieval. Without it the model
|
|
387174
|
+
server ranks lexically and says so on start.
|
|
387175
|
+
|
|
387176
|
+
## 1. Make the set directory
|
|
387177
|
+
|
|
387178
|
+
One directory per set, in the same repository as the model package.
|
|
387179
|
+
\`skill:eval-import\` "Where the set goes" has the rule about how close to the
|
|
387180
|
+
package it may sit. It holds:
|
|
387181
|
+
|
|
387182
|
+
| File | What it is | Written by |
|
|
387183
|
+
|---|---|---|
|
|
387184
|
+
| \`set.json\` | the set's name, the package and model under test, the truth package | you |
|
|
387185
|
+
| \`cases.jsonl\` | one case per question: text, split, golden | \`skill:eval-import\` |
|
|
387186
|
+
| \`as-received/\` | the questions exactly as they arrived | you, untouched |
|
|
387187
|
+
| \`eval.toml\` | where each step finds its servers | you |
|
|
387188
|
+
| \`gold/<qid>.json\` | a golden's second derivation | you, when you derive one |
|
|
387189
|
+
|
|
387190
|
+
\`set.json\`, at minimum (\`reference/ledger-schema.md\` in \`skill:eval-answer\`
|
|
387191
|
+
has every field):
|
|
387192
|
+
|
|
387193
|
+
\`\`\`json
|
|
387194
|
+
{"name": "<set>", "description": "<what it measures>", "datasetVersion": 1,
|
|
387195
|
+
"targetPackage": "<package name>", "targetModelPath": "<model>.malloy"}
|
|
387196
|
+
\`\`\`
|
|
387197
|
+
|
|
387198
|
+
## 2. Import the questions
|
|
387199
|
+
|
|
387200
|
+
\`skill:eval-import\` turns questions in any shape into \`cases.jsonl\` and says what
|
|
387201
|
+
each one is worth. A set with no questions yet has nothing to import: write the
|
|
387202
|
+
questions a person would actually ask into \`as-received/\` first, then import
|
|
387203
|
+
them, rather than writing cases by hand. Then stamp and validate:
|
|
387204
|
+
|
|
387205
|
+
\`\`\`bash
|
|
387206
|
+
python3 skills/eval-import/scripts/import_cases.py --set <set> --stamp
|
|
387207
|
+
\`\`\`
|
|
387208
|
+
|
|
387209
|
+
It refuses what later steps would fail on, with the fix: no \`split\`, a value
|
|
387210
|
+
claiming \`verified\` on arrival, a \`scalar\` value that is a bare number rather
|
|
387211
|
+
than \`{"<column>": <value>}\`. A set of bare questions is valid. It runs and
|
|
387212
|
+
measures coverage, and cannot score accuracy until keys exist.
|
|
387213
|
+
|
|
387214
|
+
## 3. Build the truth package, if any case holds a value
|
|
387215
|
+
|
|
387216
|
+
A golden holding a value is re-derived before every run from a truth package:
|
|
387217
|
+
the raw tables your model reads, with no modelling, served on a second server
|
|
387218
|
+
the answerer cannot reach. Scaffold it OUTSIDE the model package, because
|
|
387219
|
+
Publisher serves every \`.malloy\` under a package directory:
|
|
387220
|
+
|
|
387221
|
+
\`\`\`bash
|
|
387222
|
+
python3 skills/eval-answer/scripts/init_truth_package.py \\
|
|
387223
|
+
--package <model package> --out <outside it>/<set>-truth --name <set>-truth
|
|
387224
|
+
\`\`\`
|
|
387225
|
+
|
|
387226
|
+
It writes one source per raw table and links the model's data directory in, so
|
|
387227
|
+
file paths still resolve once Publisher serves its copy of the package. The link
|
|
387228
|
+
holds this machine's path; to commit the truth package, copy the data in
|
|
387229
|
+
instead, as \`examples/storefront-tour-truth/\` does. Add the package's scope
|
|
387230
|
+
filters by hand, as its header says, and nothing else. Then set
|
|
387231
|
+
\`"truthPackage": "<set>-truth"\` in \`set.json\`.
|
|
387232
|
+
|
|
387233
|
+
A set of criteria goldens or bare questions needs no truth package; \`check\`
|
|
387234
|
+
notes its absence and moves on.
|
|
387235
|
+
|
|
387236
|
+
## 4. Write eval.toml
|
|
387237
|
+
|
|
387238
|
+
\`\`\`toml
|
|
387239
|
+
[model] # the Publisher the answerer queries
|
|
387240
|
+
environment = "<env>"
|
|
387241
|
+
package = "<package name>"
|
|
387242
|
+
repo = "<path to the model package>" # relative to this file
|
|
387243
|
+
port = 4811
|
|
387244
|
+
mcp_port = 4812
|
|
387245
|
+
|
|
387246
|
+
[truth] # only with a truth package; an empty [truth] takes 4881/4882
|
|
387247
|
+
environment = "truth"
|
|
387248
|
+
package_dir = "<path to the truth package>"
|
|
387249
|
+
port = 4881
|
|
387250
|
+
mcp_port = 4882
|
|
387251
|
+
\`\`\`
|
|
387252
|
+
|
|
387253
|
+
\`package\` here and \`targetPackage\` in \`set.json\` must name the same package.
|
|
387254
|
+
Where both are set, \`eval.toml\` wins, and \`check\` refuses the set until they
|
|
387255
|
+
agree.
|
|
387256
|
+
|
|
387257
|
+
Pick ports nothing else on the machine holds; \`check\` names any that are taken.
|
|
387258
|
+
Every later step reads them from this file, so change them here rather than
|
|
387259
|
+
passing \`--port\`, which \`serve\` refuses when it disagrees with the file.
|
|
387260
|
+
|
|
387261
|
+
## 5. Check, serve, verify
|
|
387262
|
+
|
|
387263
|
+
\`\`\`bash
|
|
387264
|
+
eval.py check --set <set> # until: ready
|
|
387265
|
+
eval.py serve model --set <set> --warm-retrieval
|
|
387266
|
+
eval.py serve truth --set <set> # with a truth package
|
|
387267
|
+
eval.py verify --set <set> # free: re-derives every golden
|
|
387268
|
+
\`\`\`
|
|
387269
|
+
|
|
387270
|
+
\`verify\` exits 0 when every golden re-derives, 1 when one drifted, and 3 when it
|
|
387271
|
+
could not check (no truth package, or no truth server). Read 3 as "not run",
|
|
387272
|
+
never as a pass.
|
|
387273
|
+
|
|
387274
|
+
## 6. Turn provisional keys into scorable ones
|
|
387275
|
+
|
|
387276
|
+
An imported value is \`provisional\`, and a provisional golden takes no verdict.
|
|
387277
|
+
\`--promote\` marks one \`verified\` only when it re-derived cleanly through the
|
|
387278
|
+
truth package AND a second, differently shaped derivation exists: a
|
|
387279
|
+
\`golden.verification\` block, or \`gold/<qid>.json\` holding \`verifyRows\`
|
|
387280
|
+
(\`reference/ledger-schema.md\`, the \`golden\` row, says what the second one must
|
|
387281
|
+
vary).
|
|
387282
|
+
|
|
387283
|
+
\`\`\`bash
|
|
387284
|
+
eval.py verify --set <set> --promote
|
|
387285
|
+
\`\`\`
|
|
387286
|
+
|
|
387287
|
+
It prints why it left each golden alone. \`check\` shows the scorable count.
|
|
387288
|
+
Where a person has checked a value by hand instead of deriving it a second way,
|
|
387289
|
+
\`--promote --attest "<who checked, when, how>"\` promotes it and writes that text
|
|
387290
|
+
on the golden, so a reader sees a person stood behind it rather than a query.
|
|
387291
|
+
|
|
387292
|
+
## 7. The first run
|
|
387293
|
+
|
|
387294
|
+
\`\`\`bash
|
|
387295
|
+
eval.py run --set <set> --label smoke --only <qid>[,<qid>...] --max-turns 40 # a case or two first
|
|
387296
|
+
eval.py run --set <set> --label baseline-01 --max-turns 40
|
|
387297
|
+
eval.py diagnose --set <set> --label baseline-01
|
|
387298
|
+
eval.py package --set <set> --label baseline-01
|
|
387299
|
+
\`\`\`
|
|
387300
|
+
|
|
387301
|
+
Then \`reference/running-a-run.md\` for what the run prints and how to read it,
|
|
387302
|
+
and \`skill:eval-report\` for the write-up.
|
|
387303
|
+
|
|
387304
|
+
Measured on the tour's local DuckDB data, with sonnet answering and judging:
|
|
387305
|
+
about $0.35 a case to run, and $0.60 and three minutes to diagnose one failed
|
|
387306
|
+
case. A warehouse over a proxy costs several times more per case.`
|
|
387488
387307
|
},
|
|
387489
387308
|
{
|
|
387490
387309
|
name: "eval-report",
|
|
@@ -387538,15 +387357,23 @@ different task with its own turn.
|
|
|
387538
387357
|
## Step 1: build the artifacts, before writing a word
|
|
387539
387358
|
|
|
387540
387359
|
\`\`\`bash
|
|
387541
|
-
python3 skills/eval-loop/scripts/
|
|
387542
|
-
--run <run-dir> --set <set-dir> --out /tmp/eval-<label>
|
|
387543
|
-
curl -sS -X POST http://<publisher>/api/v0/environments/<env>/packages \\
|
|
387544
|
-
-H 'content-type: application/json' \\
|
|
387545
|
-
-d '{"name":"eval-<label>","location":"/tmp/eval-<label>"}'
|
|
387360
|
+
python3 skills/eval-loop/scripts/eval.py package --set <set-dir> --label <label>
|
|
387546
387361
|
\`\`\`
|
|
387547
387362
|
|
|
387548
|
-
That builds a Malloy package over the run's own CSVs
|
|
387549
|
-
restart. It
|
|
387363
|
+
That builds a Malloy package over the run's own CSVs, registers it with no
|
|
387364
|
+
restart, and prints the two URLs below. It registers on the TRUTH server,
|
|
387365
|
+
because the package holds the answer key and the answerer must not reach it.
|
|
387366
|
+
If the truth server is not running it says so and prints the \`curl\` to run
|
|
387367
|
+
once it is. A set with no truth server gets no registration: the only
|
|
387368
|
+
Publisher is the answerer's. Add a \`[truth]\` section and
|
|
387369
|
+
\`eval.py serve truth\`, or pass \`--on-model-server\`, which prints the \`curl\`
|
|
387370
|
+
and the \`DELETE\` to run before the next run.
|
|
387371
|
+
|
|
387372
|
+
It refuses a run with no diagnosis, since the report's cluster views would be
|
|
387373
|
+
empty. Run \`eval.py diagnose\` first; when this run skipped diagnosis on purpose,
|
|
387374
|
+
pass \`--without-diagnosis\` and say in the report that it has no clusters.
|
|
387375
|
+
|
|
387376
|
+
It gives you two things to link:
|
|
387550
387377
|
|
|
387551
387378
|
| Artifact | What it is | URL |
|
|
387552
387379
|
|---|---|---|
|
|
@@ -395391,7 +395218,7 @@ function initializeMcpServer(environmentStore) {
|
|
|
395391
395218
|
// src/mcp_config.ts
|
|
395392
395219
|
init_config();
|
|
395393
395220
|
init_logger();
|
|
395394
|
-
import * as
|
|
395221
|
+
import * as fs12 from "fs";
|
|
395395
395222
|
import * as os4 from "os";
|
|
395396
395223
|
import * as path15 from "path";
|
|
395397
395224
|
var MCP_CONFIG_FILENAME = ".mcp.json";
|
|
@@ -395419,7 +395246,7 @@ function mcpEndpoint(host, port) {
|
|
|
395419
395246
|
function findGitWorkTreeRoot(dir) {
|
|
395420
395247
|
let current = path15.resolve(dir);
|
|
395421
395248
|
for (;; ) {
|
|
395422
|
-
if (
|
|
395249
|
+
if (fs12.existsSync(path15.join(current, ".git")))
|
|
395423
395250
|
return current;
|
|
395424
395251
|
const parent = path15.dirname(current);
|
|
395425
395252
|
if (parent === current)
|
|
@@ -395429,10 +395256,10 @@ function findGitWorkTreeRoot(dir) {
|
|
|
395429
395256
|
}
|
|
395430
395257
|
function conflictingMalloyUrl(file, endpoint) {
|
|
395431
395258
|
try {
|
|
395432
|
-
const stats =
|
|
395259
|
+
const stats = fs12.lstatSync(file);
|
|
395433
395260
|
if (!stats.isFile() || stats.size > 64 * 1024)
|
|
395434
395261
|
return;
|
|
395435
|
-
const parsed = JSON.parse(
|
|
395262
|
+
const parsed = JSON.parse(fs12.readFileSync(file, "utf8"));
|
|
395436
395263
|
const url2 = parsed?.mcpServers?.malloy?.url;
|
|
395437
395264
|
return typeof url2 === "string" && url2 !== endpoint ? url2 : undefined;
|
|
395438
395265
|
} catch {
|
|
@@ -395450,7 +395277,7 @@ function ensureMcpConfig(options) {
|
|
|
395450
395277
|
try {
|
|
395451
395278
|
return {
|
|
395452
395279
|
path: file,
|
|
395453
|
-
isSymlink:
|
|
395280
|
+
isSymlink: fs12.lstatSync(file).isSymbolicLink()
|
|
395454
395281
|
};
|
|
395455
395282
|
} catch {
|
|
395456
395283
|
return;
|
|
@@ -395469,7 +395296,7 @@ function ensureMcpConfig(options) {
|
|
|
395469
395296
|
}
|
|
395470
395297
|
const realish = (p2) => {
|
|
395471
395298
|
try {
|
|
395472
|
-
return
|
|
395299
|
+
return fs12.realpathSync(p2);
|
|
395473
395300
|
} catch {
|
|
395474
395301
|
return path15.resolve(p2);
|
|
395475
395302
|
}
|
|
@@ -395487,7 +395314,7 @@ function ensureMcpConfig(options) {
|
|
|
395487
395314
|
action: "skipped-git",
|
|
395488
395315
|
dir,
|
|
395489
395316
|
gitRoot,
|
|
395490
|
-
rootConfig:
|
|
395317
|
+
rootConfig: fs12.existsSync(rootCandidate) ? rootCandidate : undefined,
|
|
395491
395318
|
endpoint,
|
|
395492
395319
|
staleConfig
|
|
395493
395320
|
};
|
|
@@ -395496,7 +395323,7 @@ function ensureMcpConfig(options) {
|
|
|
395496
395323
|
return { action: "exists", file, endpoint };
|
|
395497
395324
|
const body = JSON.stringify({ mcpServers: { malloy: malloyServer(endpoint) } }, null, 2) + `
|
|
395498
395325
|
`;
|
|
395499
|
-
|
|
395326
|
+
fs12.writeFileSync(file, body, { encoding: "utf8", flag: "wx" });
|
|
395500
395327
|
return { action: "created", file };
|
|
395501
395328
|
} catch (error2) {
|
|
395502
395329
|
const code = error2?.code;
|
|
@@ -400154,11 +399981,11 @@ function stripNonTagText(input) {
|
|
|
400154
399981
|
return current;
|
|
400155
399982
|
}
|
|
400156
399983
|
async function listPackageDataApps(environmentName, packageName, publicRoot) {
|
|
400157
|
-
const
|
|
399984
|
+
const fs13 = await import("fs/promises");
|
|
400158
399985
|
const out = [];
|
|
400159
399986
|
let realPublicRoot;
|
|
400160
399987
|
try {
|
|
400161
|
-
realPublicRoot = await
|
|
399988
|
+
realPublicRoot = await fs13.realpath(publicRoot);
|
|
400162
399989
|
} catch {
|
|
400163
399990
|
return out;
|
|
400164
399991
|
}
|
|
@@ -400167,7 +399994,7 @@ async function listPackageDataApps(environmentName, packageName, publicRoot) {
|
|
|
400167
399994
|
return;
|
|
400168
399995
|
let entries;
|
|
400169
399996
|
try {
|
|
400170
|
-
entries = await
|
|
399997
|
+
entries = await fs13.readdir(dir, { withFileTypes: true });
|
|
400171
399998
|
} catch {
|
|
400172
399999
|
return;
|
|
400173
400000
|
}
|
|
@@ -400177,7 +400004,7 @@ async function listPackageDataApps(environmentName, packageName, publicRoot) {
|
|
|
400177
400004
|
const full = path17.join(dir, entry.name);
|
|
400178
400005
|
let realFull;
|
|
400179
400006
|
try {
|
|
400180
|
-
realFull = await
|
|
400007
|
+
realFull = await fs13.realpath(full);
|
|
400181
400008
|
} catch {
|
|
400182
400009
|
continue;
|
|
400183
400010
|
}
|
|
@@ -400191,7 +400018,7 @@ async function listPackageDataApps(environmentName, packageName, publicRoot) {
|
|
|
400191
400018
|
let title = rel;
|
|
400192
400019
|
let fit;
|
|
400193
400020
|
try {
|
|
400194
|
-
const fh = await
|
|
400021
|
+
const fh = await fs13.open(full, "r");
|
|
400195
400022
|
try {
|
|
400196
400023
|
const buf = Buffer.alloc(4096);
|
|
400197
400024
|
const { bytesRead } = await fh.read(buf, 0, 4096, 0);
|