mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
refactor(daemon): use the shared SQLite time parser in three more places (#330)
health-detectors.ts, snapshot-repository.ts and restore-plan-preview.ts each
kept a private copy of the SQLite-stamp-to-UTC conversion that sqlite-time.ts
now provides. The copies had drifted:
- restore-plan-preview always appended "Z", so an already-zoned ISO
last-verified stamp became "...ZZ", parsed to NaN, and skipped the
freshness check: a 2h-old token showed as present instead of stale.
- health-detectors treated any value containing "T" as zoned, so a
zone-less ISO value would have been read as host-local time.
All writers currently store datetime('now') stamps, so neither drift was
reachable in production; this removes the traps rather than fixing a live
bug. Adds a restore-plan-preview case for the ISO stamp (fails before this
change) and a helper case for the T-separated zone-less form.
Co-authored-by: Coder8124 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
5 files changed
+15
-18
No files matched your search
@@ -19,6 +19,7 @@ import type { ContextUsageStore } from "./context-usage-store.js";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
import type { SessionRegistry } from "./session-registry.js";
|
||||
import { queryUsageSeries } from "./usage-series.js";
|
||||
import { parseSqliteUtcMs } from "./sqlite-time.js";
|
||||
|
||||
const CONTEXT_PRESSURE_PERCENT = 95;
|
||||
const CONTEXT_CRITICAL_PERCENT = 99;
|
||||
@@ -213,7 +214,7 @@ export class LiveContextHealthSource implements HealthObservationSource {
|
||||
})
|
||||
.filter((sample) => sample.nodeId === node.id
|
||||
&& sample.sampledAt !== null
|
||||
&& Date.parse(sample.sampledAt) >= sqliteTimestampMs(tenureStartedAt)
|
||||
&& Date.parse(sample.sampledAt) >= parseSqliteUtcMs(tenureStartedAt)
|
||||
&& Date.parse(sample.sampledAt) <= Date.parse(usage.sampledAt!))
|
||||
.map((sample, sourceOrder): HealthEvidenceReference => ({
|
||||
type: "context-usage",
|
||||
@@ -292,10 +293,6 @@ function selectContextEpisodeEvidence(
|
||||
.map((item, sourceOrder) => ({ ...item, sourceOrder }));
|
||||
}
|
||||
|
||||
function sqliteTimestampMs(value: string): number {
|
||||
return Date.parse(value.includes("T") ? value : `${value.replace(" ", "T")}Z`);
|
||||
}
|
||||
|
||||
export function healthScopeId(scope: HealthScope): string {
|
||||
switch (scope.type) {
|
||||
case "instance": return scope.instanceId;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { resolveActiveOccupantRow, resolveActiveSnapshotSession, deriveRehydrateSessionIdByNode, activeOccupantAmbiguityError, type ActiveOccupantResolution } from "./active-occupant.js";
|
||||
import type { RigWithRelations, Snapshot } from "./types.js";
|
||||
import { parseSqliteUtcMs } from "./sqlite-time.js";
|
||||
|
||||
/** OPR.0.4.3.20 FR-6 — a present token whose last verification is older than this
|
||||
* is surfaced as `stale` (age-based staleness — the "stale while running" signal
|
||||
@@ -103,6 +104,7 @@ function tokenStateFor(
|
||||
return { tokenState: "unverified", provenance, lastVerified };
|
||||
}
|
||||
if (lastVerified) {
|
||||
// NaN (unparseable) skips the age check: an unreadable stamp is never stale-by-age.
|
||||
const verifiedMs = parseSqliteUtcMs(lastVerified);
|
||||
if (!Number.isNaN(verifiedMs) && nowMs - verifiedMs > RESUME_FRESHNESS_THRESHOLD_MS) {
|
||||
return { tokenState: "stale", provenance, lastVerified };
|
||||
@@ -111,13 +113,6 @@ function tokenStateFor(
|
||||
return { tokenState: "present", provenance, lastVerified };
|
||||
}
|
||||
|
||||
/** Parse a SQLite `datetime('now')` value ("YYYY-MM-DD HH:MM:SS", UTC, no zone
|
||||
* marker) to epoch ms. Returns NaN on an unparseable value (age check is then
|
||||
* skipped — an unparseable stamp is never treated as stale-by-age). */
|
||||
function parseSqliteUtcMs(value: string): number {
|
||||
return new Date(value.replace(" ", "T") + "Z").getTime();
|
||||
}
|
||||
|
||||
/** OPR.0.4.3.20 FR-6 — forecast the runtime prompt a resume WOULD hit, so it is
|
||||
* an expected operator step, not a terminal surprise. Only meaningful when the
|
||||
* seat would attempt a resume (has a token). */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { ulid } from "ulid";
|
||||
import type { RestoreSnapshotSelection, RestoreSnapshotSummary, Snapshot, SnapshotData } from "./types.js";
|
||||
import { parseSqliteUtcMs } from "./sqlite-time.js";
|
||||
|
||||
export type RestoreSnapshotSelectionOutcome =
|
||||
| { ok: true; snapshot: Snapshot; selection: RestoreSnapshotSelection }
|
||||
@@ -115,7 +116,7 @@ export class SnapshotRepository {
|
||||
// A concurrent pruner can remove the selected row after it was read. Its
|
||||
// insertion position is then unknown, so only strictly newer timestamps count.
|
||||
const newerRows = selectedIndex < 0
|
||||
? candidates.filter((row) => Date.parse(sqliteUtc(row.created_at)) > Date.parse(sqliteUtc(snapshot!.createdAt)))
|
||||
? candidates.filter((row) => parseSqliteUtcMs(row.created_at) > parseSqliteUtcMs(snapshot!.createdAt))
|
||||
: candidates.slice(0, selectedIndex);
|
||||
const newer = newerRows.flatMap((row) => {
|
||||
const candidate = this.restoreUsableRow(row);
|
||||
@@ -243,16 +244,12 @@ export class SnapshotRepository {
|
||||
}
|
||||
}
|
||||
|
||||
function sqliteUtc(value: string): string {
|
||||
return /Z$|[+-]\d\d:\d\d$/.test(value) ? value : value.replace(" ", "T") + "Z";
|
||||
}
|
||||
|
||||
export function summarizeSnapshot(snapshot: Snapshot, nowMs: number = Date.now()): RestoreSnapshotSummary {
|
||||
return {
|
||||
snapshotId: snapshot.id,
|
||||
kind: snapshot.kind,
|
||||
createdAt: snapshot.createdAt,
|
||||
ageMs: Math.max(0, nowMs - Date.parse(sqliteUtc(snapshot.createdAt))),
|
||||
ageMs: Math.max(0, nowMs - parseSqliteUtcMs(snapshot.createdAt)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,12 @@ describe("FR-6 restore-plan token state", () => {
|
||||
expect(p.nodes[0]!.tokenState).toBe("stale");
|
||||
});
|
||||
|
||||
it("stale (age): an already-zoned ISO last-verified stamp is aged too, not skipped as unparseable", () => {
|
||||
const rig = rigWith([{ id: "n1", logicalId: "a", runtime: "claude-code" }]);
|
||||
const p = buildRestorePlanPreview(rig, null, [row("n1", { resumeLastVerified: "2026-07-02T10:00:00.000Z", resumeLastProbeStatus: "resumable" })], undefined, NOW);
|
||||
expect(p.nodes[0]!.tokenState).toBe("stale");
|
||||
});
|
||||
|
||||
it("codex runtime prompt surfaced for a resumable Codex seat", () => {
|
||||
const rig = rigWith([{ id: "n1", logicalId: "a", runtime: "codex" }]);
|
||||
const p = buildRestorePlanPreview(rig, null, [row("n1", { resumeType: "codex_id", resumeLastProbeStatus: "resumable", resumeLastVerified: FRESH })], undefined, NOW);
|
||||
|
||||
@@ -12,6 +12,8 @@ describe("parseSqliteUtcMs", () => {
|
||||
// A non-UTC host is where a bare Date.parse skews the stamp by the UTC offset.
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
expect(parseSqliteUtcMs("2026-09-28 02:45:08")).toBe(Date.UTC(2026, 8, 28, 2, 45, 8));
|
||||
// Same for the T-separated form: no zone marker means UTC, whatever the separator.
|
||||
expect(parseSqliteUtcMs("2026-09-28T02:45:08")).toBe(Date.UTC(2026, 8, 28, 2, 45, 8));
|
||||
});
|
||||
|
||||
it("leaves zoned ISO values unchanged and returns NaN for garbage", () => {
|
||||
|
||||
Reference in new issue
Block a user