Files
rohitg00__agentmemory/plugin/scripts/diagnostics.mjs
Rohit Ghumare 5d70ecfb3c feat: migrate agentmemory to iii v0.11 and add upgrade command (#116)
* feat: migrate agentmemory to iii v0.11 and add upgrade command

Migrate the codebase from legacy iii-sdk v0.3 APIs to v0.11 trigger/register patterns, update runtime configs, and align stream/state behavior with newer engine semantics. Add a new `agentmemory upgrade` CLI command so users can refresh dependencies and iii runtime components with one entrypoint.

* chore: remove pnpm lockfile from migration PR

Drop pnpm-lock.yaml from this branch to keep the migration PR focused on source and config changes only.

* fix(ci): sync npm lockfile with iii-sdk v0.11 dependency

Update package-lock.json so npm ci in CI matches package.json after the iii-sdk migration.

* fix(ci): resolve build parse errors after v0.11 migration

Fix malformed braces introduced during API migration in triggers/health/branch-aware files so tsdown build passes in CI.

* fix: harden migration follow-up fixes and audits

Apply the reviewed v0.11 follow-up fixes across runtime, docs, and tests by tightening validation, correcting upgrade/error handling, and adding missing audit coverage on state mutations. This also addresses stream fallback behavior, lock consistency, retention source-bucket cleanup, and test/mock alignment so build and test remain green.

* fix: address 9 unresolved CodeRabbit findings on iii v0.11 migration

CodeRabbit flagged 9 remaining issues on #116 — all real. Each was
verified against the current branch code before applying. Two
findings were deliberately skipped as policy/scope issues and are
documented at the bottom.

### Applied (9 real findings)

- src/triggers/api.ts — api.ts numeric query param validation:
  multiple sites forwarded `parseInt(params.limit)` result to the
  downstream function without a Number.isFinite check. A non-numeric
  query value produced NaN at the iii-sdk trigger boundary. Added
  parseOptionalInt and parseOptionalFloat helpers at the top of the
  file, wired into api::crystal-list, api::lesson-list, and
  api::insight-list (5 sites total).

- src/triggers/api.ts — api::observe, api::context, api::session::start,
  api::session::end were forwarding req.body verbatim to sdk.trigger
  with no validation. Added explicit type checks mirroring the existing
  api::search pattern, construct a sanitized payload object before
  triggering.

- src/cli.ts — p.confirm() can return a cancel Symbol on Ctrl+C, which
  is truthy, so the upgrade path ran even when the user cancelled.
  Added p.isCancel() check and explicit boolean comparison.

- src/cli.ts — removed dead `failed` flag in runUpgrade: every caller
  already calls process.exit(1) immediately, so the final `if (failed)`
  branch was unreachable. Simplified requireSuccess accordingly.

- src/functions/leases.ts — mem::lease-renew recorded audit events as
  "lease_acquire", which conflated renewals with first claims. Renamed
  the audit event to "lease_renew" so downstream audit filters can tell
  the two operations apart.

- src/functions/relations.ts — the pair lock
  `mem:${firstId}:${secondId}` serialized concurrent relate(A,B) calls
  with identical pairs, but did NOT protect concurrent relate(A,B) +
  relate(A,C). Both modify memory A's `relatedIds` array, which is a
  classic last-writer-wins race producing lost relation edges. Fixed
  by replacing the pair lock with nested per-entity locks in canonical
  sort order: withKeyedLock(mem:firstId) wrapping withKeyedLock(mem:secondId).
  Since the ids are sorted deterministically, no deadlock is possible.

- src/functions/sketches.ts + src/functions/summarize.ts + new
  src/functions/audit.ts safeAudit helper — recordAudit was awaited
  after kv.set calls. An audit write failure would reject and the
  caller would see an error even though the target state was already
  persisted. New safeAudit wrapper swallows audit errors and logs them
  via ctx.logger.warn, preserving the mutation's success. Applied to
  all 11 audit sites in sketches.ts and the single site in summarize.ts.

- src/mcp/server.ts — three issues in the MCP handler:
  1. memory_profile's refresh flag used `args.refresh === "true"`,
     ignoring boolean `true` from MCP clients that send proper types.
     Now accepts both.
  2. memory_sketch_create built sketchPayload with
     `asNonEmptyString(args.title)` which could return undefined,
     then forwarded that to the downstream function. Added explicit
     validation + 400 response at the MCP boundary.
  3. memory_recall, memory_team_feed, memory_audit_query used
     `(args.limit as number) || 10` which replaced an explicit 0 with
     10. Changed to typeof check so explicit 0 (rare but legal) is
     preserved.

- src/functions/governance.ts — mem::governance-bulk used Promise.all
  for the delete batch, which fails fast on the first error. The audit
  record still said `deleted: candidates.length` even if only half
  actually succeeded. Switched to Promise.allSettled, split results
  into successfulIds and failures arrays, record audit with both counts
  plus the per-failure details for traceability.

- src/functions/mesh.ts — mem::mesh-register, mem::mesh-sync,
  mem::mesh-receive, mem::mesh-remove all dereferenced `data.*` without
  checking that data was passed. A TypeError would bubble up on null
  payload. Added early null/type guards returning structured errors.

- src/functions/obsidian-export.ts — resolveVaultDir(data.vaultDir)
  was called without validating that vaultDir was a string, and
  `new Set(data.types)` without validating types was an array of
  strings. Added explicit validation returning 400-style error
  responses.

- src/functions/retention.ts — the eviction loop silently swallowed
  kv.delete errors via a bare `continue`. Added ctx.logger.warn on
  catch, included memoryId and sourceBucket in the log, and now
  returns `failed` count alongside `evicted` in the response.

- src/viewer/index.html — two WebSocket bugs:
  1. connectWs assigned to the mutable global state.ws before binding
     handlers, so old sockets could have their callbacks fire and
     mutate retry/direct state after state.ws was overwritten by a
     new socket. Fixed by creating a local `ws` variable, binding
     all handlers to it, only then assigning state.ws = ws. Each
     handler guards `if (state.ws !== ws) return` so stale callbacks
     are dropped.
  2. handleStreamEvent routed EVERY incoming event to routeWsMessage,
     so non-observation events (session.activity, etc.) were being
     treated as timeline observations and causing UI confusion. Added
     a looksLikeObservation helper + event_type gate that only routes
     real observation payloads.

- test/retention.test.ts — added an explicit sourceBucket eviction
  test that seeds a semantic memory at high age, runs retention-score,
  then runs retention-evict at high threshold and asserts BOTH
  KV.semantic and KV.memories are empty. Proves the candidate.sourceBucket
  branch added in this PR actually routes to the right bucket.

- src/types.ts — side fix: the ExportData.version union on line 254
  used comma separators instead of pipes in 3 positions (0.7.9,
  0.8.0, 0.8.1 → needed | between them). tsdown strips types so the
  build passed, but tsc --noEmit would have thrown and any IDE showed
  squiggles. Pre-existing latent bug, fixed while in the file.

### Deliberately skipped

- retention.ts eviction audit (CodeRabbit asked to add recordAudit
  to the eviction loop): policy change, not a bug fix. Verified:
  auto-forget.ts, evict.ts, retention-evict, remember-forget all
  skip audit by convention — only governance audits. Adding audit
  everywhere is a policy decision tracked in issue #125.

- file-index.ts sequential → parallel session lookup: micro-opt,
  the loop is already cache-backed, negligible savings, not worth
  the readability cost.

Tests: 655/655. Build clean.

* fix(api): harden request validation at HTTP boundaries

Validate and sanitize session, observe, and context inputs plus numeric query params before forwarding to memory functions, returning 400 for invalid values instead of propagating malformed payloads. Also remove duplicate CLI command registration introduced during merge resolution.

* fix: address 6 new CodeRabbit findings on iii v0.11 migration (#116 round 2)

CodeRabbit's second review pass on #116 flagged 6 real issues introduced
by the previous round of fixes (commit 3e3ac7e). Each was verified
against the current code before applying.

### Fixed

- src/functions/governance.ts — mem::governance-bulk switched to
  Promise.allSettled in the previous round but fanned out every
  kv.delete concurrently. The governance endpoint can target tens of
  thousands of memories in a single call; firing them all at once
  overwhelms the state worker. Batched with BATCH_SIZE=50 chunks,
  awaited serially while preserving the per-batch allSettled so a
  single failure doesn't abort the rest.
- src/functions/governance.ts — the recordAudit call AFTER the bulk
  delete is already committed was still using `await recordAudit`, so
  an audit write failure would bubble up as a "failed" response even
  though the rows were actually deleted. Switched to safeAudit so the
  deletes are reflected faithfully in the response regardless of
  audit health.
- src/functions/relations.ts — the mem::relate handler was emitting
  three recordAudit calls INTERLEAVED with three kv.set writes:
  relation → audit → source update → audit → target update → audit.
  A thrown audit in the middle would leave KV.relations and
  source/target.relatedIds in a partial state. Restructured to
  complete all three durable writes first, then emit a single
  safeAudit at the end. Also changed the operation from the generic
  "evolve" (which made audit queries indistinguishable from actual
  version evolutions) to a new "relation_create" event, added to the
  AuditEntry.operation union in src/types.ts.
- src/functions/retention.ts — mem::retention-score used
  `{ ...DEFAULT_DECAY, ...data.config }` which is only a shallow
  merge. A caller passing `{ config: { tierThresholds: { cold: 0.2 } } }`
  would drop the hot and warm thresholds, causing every downstream
  `s.score >= config.tierThresholds.hot` comparison to produce NaN
  and misclassify every tier bucket. Deep-merged tierThresholds
  explicitly.
- src/functions/retention.ts — mem::retention-evict previously
  used `candidate.sourceBucket || KV.memories` as the delete bucket.
  That's correct for new retention scores (which now store
  sourceBucket) but wrong for pre-migration scores whose sourceBucket
  is undefined: the fallback would try to delete semantic ids from
  KV.memories and silently no-op, leaving the real semantic memory
  alive. Fixed by splitting the delete path: if sourceBucket is set,
  use it; otherwise attempt both KV.memories and KV.semantic with
  individual .catch(() => {}) wrappers so whichever bucket actually
  contains the id succeeds and the other is a no-op. Legacy rows
  retire naturally on the next scoring run, which writes sourceBucket.
- src/triggers/api.ts — api::obsidian-export was validated at the
  downstream mem::obsidian-export layer in the previous round, but
  the API boundary handler was still forwarding body.vaultDir without
  a type check. Added the same 400 response pattern used by
  api::search / api::observe / api::context for consistency.

### Skipped from CodeRabbit's suggestions

- CodeRabbit's proposed fix for retention.ts:249 was
  `candidate.sourceBucket || KV.retentionScores` which would try to
  delete the memory from the retention scores namespace — that's
  where the scoring entry lives, not where the memory lives. That
  fix is wrong. The two-bucket fallback approach above is the
  correct one.

Tests: 655/655. Build clean.

* fix(agentmemory): harden deletion, relation, and API validation paths

Bound retention/governance side effects and make audit logging best-effort so successful state writes are not masked by telemetry failures. Also tighten boundary validation for MCP/API inputs, resolve relation lock edge cases, and align retention test mocks with v0.11 trigger/registerFunction call shapes.

* fix: address remaining CodeRabbit validation and eviction issues

Remove duplicate CLI help examples, harden eviction/delete bookkeeping, allow file-context without sessionId, sanitize governance failure output, validate retention-evict inputs with bounded limits, and tighten summarize/observations/obsidian-export input validation.

* fix(v0.11): align stream send payloads and migration docs

Use the engine-compatible stream payload key `type` for stream::send events and update docs/plugin examples to reflect v0.11 function registration and trigger request shapes.
2026-04-15 14:02:22 +01:00

551 lines
19 KiB
JavaScript

//#region src/state/schema.ts
const KV = {
sessions: "mem:sessions",
observations: (sessionId) => `mem:obs:${sessionId}`,
memories: "mem:memories",
summaries: "mem:summaries",
config: "mem:config",
metrics: "mem:metrics",
health: "mem:health",
embeddings: (obsId) => `mem:emb:${obsId}`,
bm25Index: "mem:index:bm25",
relations: "mem:relations",
profiles: "mem:profiles",
claudeBridge: "mem:claude-bridge",
graphNodes: "mem:graph:nodes",
graphEdges: "mem:graph:edges",
semantic: "mem:semantic",
procedural: "mem:procedural",
teamShared: (teamId) => `mem:team:${teamId}:shared`,
teamUsers: (teamId, userId) => `mem:team:${teamId}:users:${userId}`,
teamProfile: (teamId) => `mem:team:${teamId}:profile`,
audit: "mem:audit",
actions: "mem:actions",
actionEdges: "mem:action-edges",
leases: "mem:leases",
routines: "mem:routines",
routineRuns: "mem:routine-runs",
signals: "mem:signals",
checkpoints: "mem:checkpoints",
mesh: "mem:mesh",
sketches: "mem:sketches",
facets: "mem:facets",
sentinels: "mem:sentinels",
crystals: "mem:crystals"
};
//#endregion
//#region src/state/keyed-mutex.ts
const locks = /* @__PURE__ */ new Map();
function withKeyedLock(key, fn) {
const next = (locks.get(key) ?? Promise.resolve()).then(fn, fn);
const cleanup = next.then(() => {}, () => {});
locks.set(key, cleanup);
cleanup.then(() => {
if (locks.get(key) === cleanup) locks.delete(key);
});
return next;
}
//#endregion
//#region src/functions/diagnostics.ts
const ALL_CATEGORIES = [
"actions",
"leases",
"sentinels",
"sketches",
"signals",
"sessions",
"memories",
"mesh"
];
const TWENTY_FOUR_HOURS_MS = 1440 * 60 * 1e3;
const ONE_HOUR_MS = 3600 * 1e3;
function registerDiagnosticsFunction(sdk, kv) {
sdk.registerFunction("mem::diagnose", async (data) => {
const categories = data.categories && data.categories.length > 0 ? data.categories.filter((c) => ALL_CATEGORIES.includes(c)) : ALL_CATEGORIES;
const checks = [];
const now = Date.now();
if (categories.includes("actions")) {
const actions = await kv.list(KV.actions);
const allEdges = await kv.list(KV.actionEdges);
const leases = await kv.list(KV.leases);
const actionMap = new Map(actions.map((a) => [a.id, a]));
for (const action of actions) {
if (action.status === "active") {
if (!leases.some((l) => l.actionId === action.id && l.status === "active" && new Date(l.expiresAt).getTime() > now)) checks.push({
name: `active-no-lease:${action.id}`,
category: "actions",
status: "warn",
message: `Action "${action.title}" is active but has no active lease`,
fixable: false
});
}
if (action.status === "blocked") {
const deps = allEdges.filter((e) => e.sourceActionId === action.id && e.type === "requires");
if (deps.length > 0) {
if (deps.every((d) => {
const target = actionMap.get(d.targetActionId);
return target && target.status === "done";
})) checks.push({
name: `blocked-deps-done:${action.id}`,
category: "actions",
status: "fail",
message: `Action "${action.title}" is blocked but all dependencies are done`,
fixable: true
});
}
}
if (action.status === "pending") {
const deps = allEdges.filter((e) => e.sourceActionId === action.id && e.type === "requires");
if (deps.length > 0) {
if (deps.some((d) => {
const target = actionMap.get(d.targetActionId);
return !target || target.status !== "done";
})) checks.push({
name: `pending-unsatisfied-deps:${action.id}`,
category: "actions",
status: "fail",
message: `Action "${action.title}" is pending but has unsatisfied dependencies`,
fixable: true
});
}
}
}
if (!checks.some((c) => c.category === "actions" && c.status !== "pass")) checks.push({
name: "actions-ok",
category: "actions",
status: "pass",
message: `All ${actions.length} actions are consistent`,
fixable: false
});
}
if (categories.includes("leases")) {
const leases = await kv.list(KV.leases);
const actions = await kv.list(KV.actions);
const actionIds = new Set(actions.map((a) => a.id));
let leaseIssues = 0;
for (const lease of leases) {
if (lease.status === "active" && new Date(lease.expiresAt).getTime() <= now) {
checks.push({
name: `expired-lease:${lease.id}`,
category: "leases",
status: "fail",
message: `Lease ${lease.id} for action ${lease.actionId} expired at ${lease.expiresAt}`,
fixable: true
});
leaseIssues++;
}
if (!actionIds.has(lease.actionId)) {
checks.push({
name: `orphaned-lease:${lease.id}`,
category: "leases",
status: "fail",
message: `Lease ${lease.id} references non-existent action ${lease.actionId}`,
fixable: true
});
leaseIssues++;
}
}
if (leaseIssues === 0) checks.push({
name: "leases-ok",
category: "leases",
status: "pass",
message: `All ${leases.length} leases are healthy`,
fixable: false
});
}
if (categories.includes("sentinels")) {
const sentinels = await kv.list(KV.sentinels);
const actions = await kv.list(KV.actions);
const actionIds = new Set(actions.map((a) => a.id));
let sentinelIssues = 0;
for (const sentinel of sentinels) {
if (sentinel.status === "watching" && sentinel.expiresAt && new Date(sentinel.expiresAt).getTime() <= now) {
checks.push({
name: `expired-sentinel:${sentinel.id}`,
category: "sentinels",
status: "fail",
message: `Sentinel "${sentinel.name}" expired at ${sentinel.expiresAt}`,
fixable: true
});
sentinelIssues++;
}
for (const actionId of sentinel.linkedActionIds) if (!actionIds.has(actionId)) {
checks.push({
name: `sentinel-missing-action:${sentinel.id}:${actionId}`,
category: "sentinels",
status: "warn",
message: `Sentinel "${sentinel.name}" references non-existent action ${actionId}`,
fixable: false
});
sentinelIssues++;
}
}
if (sentinelIssues === 0) checks.push({
name: "sentinels-ok",
category: "sentinels",
status: "pass",
message: `All ${sentinels.length} sentinels are healthy`,
fixable: false
});
}
if (categories.includes("sketches")) {
const sketches = await kv.list(KV.sketches);
let sketchIssues = 0;
for (const sketch of sketches) if (sketch.status === "active" && new Date(sketch.expiresAt).getTime() <= now) {
checks.push({
name: `expired-sketch:${sketch.id}`,
category: "sketches",
status: "fail",
message: `Sketch "${sketch.title}" expired at ${sketch.expiresAt}`,
fixable: true
});
sketchIssues++;
}
if (sketchIssues === 0) checks.push({
name: "sketches-ok",
category: "sketches",
status: "pass",
message: `All ${sketches.length} sketches are healthy`,
fixable: false
});
}
if (categories.includes("signals")) {
const signals = await kv.list(KV.signals);
let signalIssues = 0;
for (const signal of signals) if (signal.expiresAt && new Date(signal.expiresAt).getTime() <= now) {
checks.push({
name: `expired-signal:${signal.id}`,
category: "signals",
status: "fail",
message: `Signal from "${signal.from}" expired at ${signal.expiresAt}`,
fixable: true
});
signalIssues++;
}
if (signalIssues === 0) checks.push({
name: "signals-ok",
category: "signals",
status: "pass",
message: `All ${signals.length} signals are healthy`,
fixable: false
});
}
if (categories.includes("sessions")) {
const sessions = await kv.list(KV.sessions);
let sessionIssues = 0;
for (const session of sessions) if (session.status === "active" && now - new Date(session.startedAt).getTime() > TWENTY_FOUR_HOURS_MS) {
checks.push({
name: `abandoned-session:${session.id}`,
category: "sessions",
status: "warn",
message: `Session ${session.id} has been active for over 24 hours`,
fixable: false
});
sessionIssues++;
}
if (sessionIssues === 0) checks.push({
name: "sessions-ok",
category: "sessions",
status: "pass",
message: `All ${sessions.length} sessions are healthy`,
fixable: false
});
}
if (categories.includes("memories")) {
const memories = await kv.list(KV.memories);
const memoryIds = new Set(memories.map((m) => m.id));
const supersededBy = /* @__PURE__ */ new Map();
let memoryIssues = 0;
for (const memory of memories) if (memory.supersedes && memory.supersedes.length > 0) for (const sid of memory.supersedes) {
if (!memoryIds.has(sid)) {
checks.push({
name: `memory-missing-supersedes:${memory.id}:${sid}`,
category: "memories",
status: "warn",
message: `Memory "${memory.title}" supersedes non-existent memory ${sid}`,
fixable: false
});
memoryIssues++;
}
supersededBy.set(sid, memory.id);
}
for (const memory of memories) if (memory.isLatest && supersededBy.has(memory.id)) {
checks.push({
name: `memory-stale-latest:${memory.id}`,
category: "memories",
status: "fail",
message: `Memory "${memory.title}" has isLatest=true but is superseded by ${supersededBy.get(memory.id)}`,
fixable: true
});
memoryIssues++;
}
if (memoryIssues === 0) checks.push({
name: "memories-ok",
category: "memories",
status: "pass",
message: `All ${memories.length} memories are consistent`,
fixable: false
});
}
if (categories.includes("mesh")) {
const peers = await kv.list(KV.mesh);
let meshIssues = 0;
for (const peer of peers) {
if (peer.lastSyncAt && now - new Date(peer.lastSyncAt).getTime() > ONE_HOUR_MS) {
checks.push({
name: `stale-peer:${peer.id}`,
category: "mesh",
status: "warn",
message: `Peer "${peer.name}" last synced over 1 hour ago`,
fixable: false
});
meshIssues++;
}
if (peer.status === "error") {
checks.push({
name: `error-peer:${peer.id}`,
category: "mesh",
status: "warn",
message: `Peer "${peer.name}" is in error state`,
fixable: false
});
meshIssues++;
}
}
if (meshIssues === 0) checks.push({
name: "mesh-ok",
category: "mesh",
status: "pass",
message: `All ${peers.length} mesh peers are healthy`,
fixable: false
});
}
return {
success: true,
checks,
summary: {
pass: checks.filter((c) => c.status === "pass").length,
warn: checks.filter((c) => c.status === "warn").length,
fail: checks.filter((c) => c.status === "fail").length,
fixable: checks.filter((c) => c.fixable).length
}
};
});
sdk.registerFunction("mem::heal", async (data) => {
const dryRun = data.dryRun ?? false;
const categories = data.categories && data.categories.length > 0 ? data.categories.filter((c) => ALL_CATEGORIES.includes(c)) : ALL_CATEGORIES;
let fixed = 0;
let skipped = 0;
const details = [];
const now = Date.now();
if (categories.includes("actions")) {
const actions = await kv.list(KV.actions);
const allEdges = await kv.list(KV.actionEdges);
const actionMap = new Map(actions.map((a) => [a.id, a]));
for (const action of actions) {
if (action.status === "blocked") {
const deps = allEdges.filter((e) => e.sourceActionId === action.id && e.type === "requires");
if (deps.length > 0) {
if (deps.every((d) => {
const target = actionMap.get(d.targetActionId);
return target && target.status === "done";
})) {
if (dryRun) {
details.push(`[dry-run] Would unblock action "${action.title}" (${action.id})`);
fixed++;
continue;
}
if (await withKeyedLock(`mem:action:${action.id}`, async () => {
const fresh = await kv.get(KV.actions, action.id);
if (!fresh || fresh.status !== "blocked") return false;
const freshDeps = (await kv.list(KV.actionEdges)).filter((e) => e.sourceActionId === fresh.id && e.type === "requires");
const freshActions = await kv.list(KV.actions);
const freshMap = new Map(freshActions.map((a) => [a.id, a]));
if (!freshDeps.every((d) => {
const target = freshMap.get(d.targetActionId);
return target && target.status === "done";
})) return false;
fresh.status = "pending";
fresh.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
await kv.set(KV.actions, fresh.id, fresh);
return true;
})) {
details.push(`Unblocked action "${action.title}" (${action.id})`);
fixed++;
} else skipped++;
}
}
}
if (action.status === "pending") {
const deps = allEdges.filter((e) => e.sourceActionId === action.id && e.type === "requires");
if (deps.length > 0) {
if (deps.some((d) => {
const target = actionMap.get(d.targetActionId);
return !target || target.status !== "done";
})) {
if (dryRun) {
details.push(`[dry-run] Would block action "${action.title}" (${action.id})`);
fixed++;
continue;
}
if (await withKeyedLock(`mem:action:${action.id}`, async () => {
const fresh = await kv.get(KV.actions, action.id);
if (!fresh || fresh.status !== "pending") return false;
const freshDeps = (await kv.list(KV.actionEdges)).filter((e) => e.sourceActionId === fresh.id && e.type === "requires");
const freshActions = await kv.list(KV.actions);
const freshMap = new Map(freshActions.map((a) => [a.id, a]));
if (!freshDeps.some((d) => {
const target = freshMap.get(d.targetActionId);
return !target || target.status !== "done";
})) return false;
fresh.status = "blocked";
fresh.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
await kv.set(KV.actions, fresh.id, fresh);
return true;
})) {
details.push(`Blocked action "${action.title}" (${action.id})`);
fixed++;
} else skipped++;
}
}
}
}
}
if (categories.includes("leases")) {
const leases = await kv.list(KV.leases);
const actions = await kv.list(KV.actions);
const actionIds = new Set(actions.map((a) => a.id));
for (const lease of leases) {
if (lease.status === "active" && new Date(lease.expiresAt).getTime() <= now) {
if (dryRun) {
details.push(`[dry-run] Would expire lease ${lease.id} for action ${lease.actionId}`);
fixed++;
continue;
}
if (await withKeyedLock(`mem:action:${lease.actionId}`, async () => {
const fresh = await kv.get(KV.leases, lease.id);
if (!fresh || fresh.status !== "active" || new Date(fresh.expiresAt).getTime() > Date.now()) return false;
fresh.status = "expired";
await kv.set(KV.leases, fresh.id, fresh);
const action = await kv.get(KV.actions, fresh.actionId);
if (action && action.status === "active" && action.assignedTo === fresh.agentId) {
action.status = "pending";
action.assignedTo = void 0;
action.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
await kv.set(KV.actions, action.id, action);
}
return true;
})) {
details.push(`Expired lease ${lease.id} for action ${lease.actionId}`);
fixed++;
} else skipped++;
continue;
}
if (!actionIds.has(lease.actionId)) {
if (dryRun) {
details.push(`[dry-run] Would delete orphaned lease ${lease.id}`);
fixed++;
continue;
}
await kv.delete(KV.leases, lease.id);
details.push(`Deleted orphaned lease ${lease.id}`);
fixed++;
}
}
}
if (categories.includes("sentinels")) {
const sentinels = await kv.list(KV.sentinels);
for (const sentinel of sentinels) if (sentinel.status === "watching" && sentinel.expiresAt && new Date(sentinel.expiresAt).getTime() <= now) {
if (dryRun) {
details.push(`[dry-run] Would expire sentinel "${sentinel.name}" (${sentinel.id})`);
fixed++;
continue;
}
if (await withKeyedLock(`mem:sentinel:${sentinel.id}`, async () => {
const fresh = await kv.get(KV.sentinels, sentinel.id);
if (!fresh || fresh.status !== "watching") return false;
if (!fresh.expiresAt || new Date(fresh.expiresAt).getTime() > Date.now()) return false;
fresh.status = "expired";
await kv.set(KV.sentinels, fresh.id, fresh);
return true;
})) {
details.push(`Expired sentinel "${sentinel.name}" (${sentinel.id})`);
fixed++;
} else skipped++;
}
}
if (categories.includes("sketches")) {
const sketches = await kv.list(KV.sketches);
for (const sketch of sketches) if (sketch.status === "active" && new Date(sketch.expiresAt).getTime() <= now) {
if (dryRun) {
details.push(`[dry-run] Would discard expired sketch "${sketch.title}" (${sketch.id})`);
fixed++;
continue;
}
if (await withKeyedLock(`mem:sketch:${sketch.id}`, async () => {
const fresh = await kv.get(KV.sketches, sketch.id);
if (!fresh || fresh.status !== "active" || new Date(fresh.expiresAt).getTime() > Date.now()) return false;
const allEdges = await kv.list(KV.actionEdges);
const actionIdSet = new Set(fresh.actionIds);
for (const edge of allEdges) if (actionIdSet.has(edge.sourceActionId) || actionIdSet.has(edge.targetActionId)) await kv.delete(KV.actionEdges, edge.id);
for (const actionId of fresh.actionIds) await kv.delete(KV.actions, actionId);
fresh.status = "discarded";
fresh.discardedAt = (/* @__PURE__ */ new Date()).toISOString();
await kv.set(KV.sketches, fresh.id, fresh);
return true;
})) {
details.push(`Discarded expired sketch "${sketch.title}" (${sketch.id})`);
fixed++;
} else skipped++;
}
}
if (categories.includes("signals")) {
const signals = await kv.list(KV.signals);
for (const signal of signals) if (signal.expiresAt && new Date(signal.expiresAt).getTime() <= now) {
if (dryRun) {
details.push(`[dry-run] Would delete expired signal ${signal.id}`);
fixed++;
continue;
}
await kv.delete(KV.signals, signal.id);
details.push(`Deleted expired signal ${signal.id}`);
fixed++;
}
}
if (categories.includes("memories")) {
const memories = await kv.list(KV.memories);
const supersededBy = /* @__PURE__ */ new Map();
for (const memory of memories) if (memory.supersedes && memory.supersedes.length > 0) for (const sid of memory.supersedes) supersededBy.set(sid, memory.id);
for (const memory of memories) if (memory.isLatest && supersededBy.has(memory.id)) {
if (dryRun) {
details.push(`[dry-run] Would set isLatest=false on memory "${memory.title}" (${memory.id})`);
fixed++;
continue;
}
if (await withKeyedLock(`mem:memory:${memory.id}`, async () => {
const fresh = await kv.get(KV.memories, memory.id);
if (!fresh || !fresh.isLatest) return false;
fresh.isLatest = false;
fresh.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
await kv.set(KV.memories, fresh.id, fresh);
return true;
})) {
details.push(`Set isLatest=false on memory "${memory.title}" (${memory.id})`);
fixed++;
} else skipped++;
}
}
return {
success: true,
fixed,
skipped,
details
};
});
}
//#endregion
export { registerDiagnosticsFunction };
//# sourceMappingURL=diagnostics.mjs.map