diff --git a/cli/src/main/java/com/labwatcher/config/ConfigLoader.java b/cli/src/main/java/com/labwatcher/config/ConfigLoader.java index 1ab3932..c1b350e 100644 --- a/cli/src/main/java/com/labwatcher/config/ConfigLoader.java +++ b/cli/src/main/java/com/labwatcher/config/ConfigLoader.java @@ -12,7 +12,11 @@ /** Loads {@link AppConfig} from a TOML file with {@code ${VAR}} interpolation. */ public final class ConfigLoader { - private static final Pattern VAR = Pattern.compile("\\$\\{([A-Z0-9_]+)}"); + // Match POSIX-style env var names: letters (either case), digits and + // underscore, not starting with a digit. The previous uppercase-only + // pattern silently skipped lowercase vars like ${home} or ${my_token}. + private static final Pattern VAR = + Pattern.compile("\\$\\{([A-Za-z_][A-Za-z0-9_]*)}"); private final Function envLookup; diff --git a/cli/src/main/java/com/labwatcher/dispatch/SlackDispatcher.java b/cli/src/main/java/com/labwatcher/dispatch/SlackDispatcher.java index f83e88a..7d7380c 100644 --- a/cli/src/main/java/com/labwatcher/dispatch/SlackDispatcher.java +++ b/cli/src/main/java/com/labwatcher/dispatch/SlackDispatcher.java @@ -54,6 +54,12 @@ public boolean dispatch(FileSummary summary) { } LOG.error("slack: HTTP {} body={}", resp.statusCode(), resp.body()); return false; + } catch (InterruptedException e) { + // Preserve interrupt status so the surrounding shutdown flow + // (FileProcessor's executor drain) can observe it and stop. + Thread.currentThread().interrupt(); + LOG.warn("slack: dispatch interrupted"); + return false; } catch (Exception e) { LOG.error("slack: dispatch failed: {}", e.getMessage()); return false; diff --git a/cli/src/main/java/com/labwatcher/state/SqliteStateRepository.java b/cli/src/main/java/com/labwatcher/state/SqliteStateRepository.java index b0cd66a..e446490 100644 --- a/cli/src/main/java/com/labwatcher/state/SqliteStateRepository.java +++ b/cli/src/main/java/com/labwatcher/state/SqliteStateRepository.java @@ -112,8 +112,13 @@ public Optional findByHash(String sha256) { @Override public List recent(int limit) { + // processed_at is stored as second-precision ISO strings, so two + // files ingested in the same second would sort non-deterministically + // and could even be swapped between calls. Break ties on the + // monotonic primary key so recent() matches the dashboard's + // id-ordered `recent` query. final String sql = "SELECT * FROM processed_files " - + "ORDER BY processed_at DESC LIMIT ?"; + + "ORDER BY processed_at DESC, id DESC LIMIT ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, limit); try (ResultSet rs = ps.executeQuery()) { diff --git a/dashboard/src/routes/api.ts b/dashboard/src/routes/api.ts index 2e2db67..fcf5134 100644 --- a/dashboard/src/routes/api.ts +++ b/dashboard/src/routes/api.ts @@ -11,8 +11,12 @@ export function apiRouter(db: StateDb): Router { r.get("/files/:id", (req, res) => { const id = Number(req.params.id); - if (!Number.isFinite(id)) { - res.status(400).json({ error: "id must be a number" }); + // The SQLite primary key is a positive integer; reject fractional, + // negative, zero and non-numeric inputs up front instead of handing + // them to better-sqlite3 (which would coerce 1.5 -> 1 silently or + // throw for NaN). + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: "id must be a positive integer" }); return; } const row = db.byId(id); diff --git a/engine/src/validator.cpp b/engine/src/validator.cpp index cc6dced..f6db605 100644 --- a/engine/src/validator.cpp +++ b/engine/src/validator.cpp @@ -119,6 +119,18 @@ ValidationReport validate(const ParseResult& parsed, const SchemaSpec& schema) { ", got: " + cell)); continue; } + if (col.type == ColumnType::INTEGER) { + // strtod happily accepts "1.5" / "1e-3" / NaN / Inf for + // an INTEGER column; reject any value that isn't a + // finite integral number. + double int_part; + if (!std::isfinite(v) || std::modf(v, &int_part) != 0.0) { + report.errors.push_back(make_err(line, col.name, + ErrorCode::TYPE_MISMATCH, Severity::ERROR_, + "expected integer, got: " + cell)); + continue; + } + } if (col.min_value && v < *col.min_value) { std::ostringstream m; m << "value " << v << " below min " << *col.min_value;