hotsheet 0.18.1-beta.3 → 0.19.0-beta.2

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.
Files changed (2) hide show
  1. package/dist/cli.js +771 -188
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -15198,6 +15198,132 @@ var init_zod = __esm({
15198
15198
  }
15199
15199
  });
15200
15200
 
15201
+ // src/hashWorker.ts
15202
+ var hashWorker_exports = {};
15203
+ __export(hashWorker_exports, {
15204
+ _resetHashWorkerForTests: () => _resetHashWorkerForTests,
15205
+ hashFileInProcess: () => hashFileInProcess,
15206
+ hashFileOffThread: () => hashFileOffThread,
15207
+ terminateHashWorker: () => terminateHashWorker
15208
+ });
15209
+ import { createHash } from "crypto";
15210
+ import { createReadStream } from "fs";
15211
+ import { pipeline } from "stream/promises";
15212
+ import { Worker } from "worker_threads";
15213
+ function failWorker(err) {
15214
+ for (const p of pending.values()) p.reject(err);
15215
+ pending.clear();
15216
+ if (worker !== null) {
15217
+ const w = worker;
15218
+ worker = null;
15219
+ void w.terminate().catch(() => {
15220
+ });
15221
+ }
15222
+ crashCount++;
15223
+ if (crashCount >= MAX_WORKER_CRASHES) workerUnusable = true;
15224
+ }
15225
+ function ensureWorker() {
15226
+ if (workerUnusable) return null;
15227
+ if (worker !== null) return worker;
15228
+ try {
15229
+ const w = new Worker(WORKER_SOURCE, { eval: true });
15230
+ w.on("message", (msg) => {
15231
+ const p = pending.get(msg.id);
15232
+ if (p === void 0) return;
15233
+ pending.delete(msg.id);
15234
+ if (msg.error !== void 0) p.reject(new Error(msg.error));
15235
+ else p.resolve({ sha: msg.sha ?? "", size: msg.size ?? 0 });
15236
+ });
15237
+ w.on("error", (err) => {
15238
+ failWorker(err);
15239
+ });
15240
+ w.on("exit", (code) => {
15241
+ if (code !== 0) failWorker(new Error(`hash worker exited with code ${code.toString()}`));
15242
+ });
15243
+ w.unref();
15244
+ worker = w;
15245
+ return w;
15246
+ } catch {
15247
+ workerUnusable = true;
15248
+ return null;
15249
+ }
15250
+ }
15251
+ async function hashFileOffThread(path) {
15252
+ const w = ensureWorker();
15253
+ if (w === null) return hashFileInProcess(path);
15254
+ return new Promise((resolve11, reject2) => {
15255
+ const id = nextId++;
15256
+ pending.set(id, { resolve: resolve11, reject: reject2 });
15257
+ try {
15258
+ w.postMessage({ id, path });
15259
+ } catch (err) {
15260
+ pending.delete(id);
15261
+ reject2(err instanceof Error ? err : new Error(String(err)));
15262
+ }
15263
+ });
15264
+ }
15265
+ async function hashFileInProcess(path) {
15266
+ const hash2 = createHash("sha256");
15267
+ let size = 0;
15268
+ await pipeline(
15269
+ createReadStream(path),
15270
+ async function* (source) {
15271
+ for await (const chunk of source) {
15272
+ size += chunk.length;
15273
+ hash2.update(chunk);
15274
+ yield chunk;
15275
+ }
15276
+ },
15277
+ async function(source) {
15278
+ for await (const _ of source) {
15279
+ }
15280
+ }
15281
+ );
15282
+ return { sha: hash2.digest("hex"), size };
15283
+ }
15284
+ async function terminateHashWorker() {
15285
+ const w = worker;
15286
+ worker = null;
15287
+ pending.clear();
15288
+ if (w !== null) {
15289
+ try {
15290
+ await w.terminate();
15291
+ } catch {
15292
+ }
15293
+ }
15294
+ }
15295
+ function _resetHashWorkerForTests() {
15296
+ void terminateHashWorker();
15297
+ workerUnusable = false;
15298
+ crashCount = 0;
15299
+ pending.clear();
15300
+ }
15301
+ var WORKER_SOURCE, MAX_WORKER_CRASHES, worker, workerUnusable, crashCount, nextId, pending;
15302
+ var init_hashWorker = __esm({
15303
+ "src/hashWorker.ts"() {
15304
+ "use strict";
15305
+ WORKER_SOURCE = `
15306
+ const { parentPort } = require('worker_threads');
15307
+ const { createHash } = require('crypto');
15308
+ const { createReadStream } = require('fs');
15309
+ parentPort.on('message', (msg) => {
15310
+ const hash = createHash('sha256');
15311
+ let size = 0;
15312
+ const stream = createReadStream(msg.path);
15313
+ stream.on('data', (chunk) => { size += chunk.length; hash.update(chunk); });
15314
+ stream.on('end', () => parentPort.postMessage({ id: msg.id, sha: hash.digest('hex'), size: size }));
15315
+ stream.on('error', (err) => parentPort.postMessage({ id: msg.id, error: (err && err.message) ? err.message : String(err) }));
15316
+ });
15317
+ `;
15318
+ MAX_WORKER_CRASHES = 3;
15319
+ worker = null;
15320
+ workerUnusable = false;
15321
+ crashCount = 0;
15322
+ nextId = 1;
15323
+ pending = /* @__PURE__ */ new Map();
15324
+ }
15325
+ });
15326
+
15201
15327
  // src/attachmentBackup.ts
15202
15328
  var attachmentBackup_exports = {};
15203
15329
  __export(attachmentBackup_exports, {
@@ -15214,10 +15340,8 @@ __export(attachmentBackup_exports, {
15214
15340
  runAttachmentGc: () => runAttachmentGc,
15215
15341
  writeManifestAtomically: () => writeManifestAtomically
15216
15342
  });
15217
- import { createHash } from "crypto";
15218
15343
  import {
15219
15344
  copyFileSync,
15220
- createReadStream,
15221
15345
  existsSync,
15222
15346
  mkdirSync,
15223
15347
  promises as fsp,
@@ -15227,7 +15351,6 @@ import {
15227
15351
  statSync
15228
15352
  } from "fs";
15229
15353
  import { join } from "path";
15230
- import { pipeline } from "stream/promises";
15231
15354
  import { gunzipSync } from "zlib";
15232
15355
  function manifestSiblingFilename(tarballFilename) {
15233
15356
  if (!tarballFilename.endsWith(".tar.gz")) {
@@ -15236,24 +15359,7 @@ function manifestSiblingFilename(tarballFilename) {
15236
15359
  return `${tarballFilename.slice(0, -".tar.gz".length)}.attachments.json`;
15237
15360
  }
15238
15361
  async function hashFile(path) {
15239
- const hash2 = createHash("sha256");
15240
- let size = 0;
15241
- await pipeline(
15242
- createReadStream(path),
15243
- async function* (source) {
15244
- for await (const chunk of source) {
15245
- size += chunk.length;
15246
- hash2.update(chunk);
15247
- yield chunk;
15248
- }
15249
- },
15250
- // Sink: just consume the bytes; pipeline needs a writable end.
15251
- async function(source) {
15252
- for await (const _ of source) {
15253
- }
15254
- }
15255
- );
15256
- return { sha: hash2.digest("hex"), size };
15362
+ return hashFileOffThread(path);
15257
15363
  }
15258
15364
  function attachmentBlobsDir(backupRoot) {
15259
15365
  return join(backupRoot, "attachments");
@@ -15400,6 +15506,7 @@ async function runAttachmentGc(backupRoot) {
15400
15506
  const manifestPaths = collectManifestPaths(backupRoot);
15401
15507
  const liveShas = /* @__PURE__ */ new Set();
15402
15508
  let parseFailure = false;
15509
+ let scanned = 0;
15403
15510
  for (const p of manifestPaths) {
15404
15511
  const m = readManifest(p);
15405
15512
  if (m === null) {
@@ -15408,23 +15515,27 @@ async function runAttachmentGc(backupRoot) {
15408
15515
  break;
15409
15516
  }
15410
15517
  for (const e of m.entries) liveShas.add(e.sha);
15518
+ if (++scanned % 25 === 0) await yieldToEventLoop();
15411
15519
  }
15412
15520
  if (parseFailure) {
15413
15521
  return { deleted: 0, bytesReclaimed: 0, scannedManifests: manifestPaths.length, skippedDueToParseFailure: true };
15414
15522
  }
15415
15523
  let deleted = 0;
15416
15524
  let bytesReclaimed = 0;
15417
- for (const name of readdirSync(blobsDir)) {
15525
+ const blobNames = await fsp.readdir(blobsDir);
15526
+ let iterated = 0;
15527
+ for (const name of blobNames) {
15528
+ if (++iterated % 500 === 0) await yieldToEventLoop();
15418
15529
  if (name.endsWith(".tmp")) continue;
15419
15530
  if (liveShas.has(name)) continue;
15420
15531
  const p = join(blobsDir, name);
15421
15532
  let size = 0;
15422
15533
  try {
15423
- size = statSync(p).size;
15534
+ size = (await fsp.stat(p)).size;
15424
15535
  } catch {
15425
15536
  }
15426
15537
  try {
15427
- rmSync(p, { force: true });
15538
+ await fsp.rm(p, { force: true });
15428
15539
  deleted++;
15429
15540
  bytesReclaimed += size;
15430
15541
  } catch (err) {
@@ -15634,6 +15745,7 @@ var init_attachmentBackup = __esm({
15634
15745
  "src/attachmentBackup.ts"() {
15635
15746
  "use strict";
15636
15747
  init_zod();
15748
+ init_hashWorker();
15637
15749
  ATTACHMENT_MANIFEST_VERSION = 1;
15638
15750
  JsonCosaveAttachmentRowSchema = external_exports.object({
15639
15751
  id: external_exports.number(),
@@ -15949,10 +16061,14 @@ __export(freezeLogger_exports, {
15949
16061
  FREEZE_LOG_MAX_BYTES: () => FREEZE_LOG_MAX_BYTES,
15950
16062
  FREEZE_LOG_TARGET_BYTES_AFTER_TRUNCATE: () => FREEZE_LOG_TARGET_BYTES_AFTER_TRUNCATE,
15951
16063
  LONG_TASK_THRESHOLD_MS: () => LONG_TASK_THRESHOLD_MS,
16064
+ WAKE_GAP_THRESHOLD_MS: () => WAKE_GAP_THRESHOLD_MS,
15952
16065
  _resetForTesting: () => _resetForTesting,
16066
+ _simulateHeartbeatGapForTesting: () => _simulateHeartbeatGapForTesting,
15953
16067
  appendFreezeLog: () => appendFreezeLog,
16068
+ getRecentEventLoopLagMs: () => getRecentEventLoopLagMs,
15954
16069
  instrumentAsync: () => instrumentAsync,
15955
16070
  instrumentSync: () => instrumentSync,
16071
+ onServerWake: () => onServerWake,
15956
16072
  startServerEventLoopHeartbeat: () => startServerEventLoopHeartbeat,
15957
16073
  stopServerEventLoopHeartbeat: () => stopServerEventLoopHeartbeat
15958
16074
  });
@@ -16011,6 +16127,44 @@ async function rotateIfNeeded(path, pendingBytes) {
16011
16127
  console.warn("[hotsheet freeze.log] rotate writeFile failed:", err instanceof Error ? err.message : String(err));
16012
16128
  }
16013
16129
  }
16130
+ function onServerWake(listener) {
16131
+ wakeListeners.add(listener);
16132
+ return () => {
16133
+ wakeListeners.delete(listener);
16134
+ };
16135
+ }
16136
+ function handleHeartbeatGap(blockMs) {
16137
+ if (blockMs >= WAKE_GAP_THRESHOLD_MS) {
16138
+ lastEventLoopLagMs = 0;
16139
+ if (heartbeatDataDir !== null) {
16140
+ void appendFreezeLog(heartbeatDataDir, {
16141
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
16142
+ source: "server-wake",
16143
+ durationMs: Math.round(blockMs),
16144
+ context: `resumed from suspend after ~${Math.round(blockMs / 1e3).toString()}s`
16145
+ });
16146
+ }
16147
+ for (const listener of wakeListeners) {
16148
+ try {
16149
+ listener(blockMs);
16150
+ } catch {
16151
+ }
16152
+ }
16153
+ return;
16154
+ }
16155
+ lastEventLoopLagMs = blockMs > 0 ? blockMs : 0;
16156
+ if (blockMs >= LONG_TASK_THRESHOLD_MS && heartbeatDataDir !== null) {
16157
+ void appendFreezeLog(heartbeatDataDir, {
16158
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
16159
+ source: "server-heartbeat",
16160
+ durationMs: Math.round(blockMs),
16161
+ context: "event-loop blocked"
16162
+ });
16163
+ }
16164
+ }
16165
+ function _simulateHeartbeatGapForTesting(blockMs) {
16166
+ handleHeartbeatGap(blockMs);
16167
+ }
16014
16168
  function startServerEventLoopHeartbeat(dataDir) {
16015
16169
  if (heartbeatTimer !== null) return;
16016
16170
  heartbeatDataDir = dataDir;
@@ -16019,18 +16173,13 @@ function startServerEventLoopHeartbeat(dataDir) {
16019
16173
  const now = process.hrtime.bigint();
16020
16174
  const elapsedMs = Number(now - lastHeartbeatNs) / 1e6;
16021
16175
  lastHeartbeatNs = now;
16022
- const blockMs = elapsedMs - HEARTBEAT_INTERVAL_MS;
16023
- if (blockMs >= LONG_TASK_THRESHOLD_MS && heartbeatDataDir !== null) {
16024
- void appendFreezeLog(heartbeatDataDir, {
16025
- ts: (/* @__PURE__ */ new Date()).toISOString(),
16026
- source: "server-heartbeat",
16027
- durationMs: Math.round(blockMs),
16028
- context: "event-loop blocked"
16029
- });
16030
- }
16176
+ handleHeartbeatGap(elapsedMs - HEARTBEAT_INTERVAL_MS);
16031
16177
  }, HEARTBEAT_INTERVAL_MS);
16032
16178
  heartbeatTimer.unref();
16033
16179
  }
16180
+ function getRecentEventLoopLagMs() {
16181
+ return lastEventLoopLagMs;
16182
+ }
16034
16183
  function stopServerEventLoopHeartbeat() {
16035
16184
  if (heartbeatTimer !== null) {
16036
16185
  clearInterval(heartbeatTimer);
@@ -16077,9 +16226,11 @@ function _resetForTesting() {
16077
16226
  }
16078
16227
  heartbeatDataDir = null;
16079
16228
  lastHeartbeatNs = 0n;
16229
+ lastEventLoopLagMs = 0;
16230
+ wakeListeners.clear();
16080
16231
  appendQueue.clear();
16081
16232
  }
16082
- var FREEZE_LOG_FILENAME, LONG_TASK_THRESHOLD_MS, FREEZE_LOG_MAX_BYTES, FREEZE_LOG_TARGET_BYTES_AFTER_TRUNCATE, HEARTBEAT_INTERVAL_MS, appendQueue, heartbeatTimer, lastHeartbeatNs, heartbeatDataDir;
16233
+ var FREEZE_LOG_FILENAME, LONG_TASK_THRESHOLD_MS, FREEZE_LOG_MAX_BYTES, FREEZE_LOG_TARGET_BYTES_AFTER_TRUNCATE, HEARTBEAT_INTERVAL_MS, WAKE_GAP_THRESHOLD_MS, appendQueue, heartbeatTimer, lastHeartbeatNs, lastEventLoopLagMs, heartbeatDataDir, wakeListeners;
16083
16234
  var init_freezeLogger = __esm({
16084
16235
  "src/diagnostics/freezeLogger.ts"() {
16085
16236
  "use strict";
@@ -16088,10 +16239,213 @@ var init_freezeLogger = __esm({
16088
16239
  FREEZE_LOG_MAX_BYTES = 1048576;
16089
16240
  FREEZE_LOG_TARGET_BYTES_AFTER_TRUNCATE = 524288;
16090
16241
  HEARTBEAT_INTERVAL_MS = 50;
16242
+ WAKE_GAP_THRESHOLD_MS = 1e4;
16091
16243
  appendQueue = /* @__PURE__ */ new Map();
16092
16244
  heartbeatTimer = null;
16093
16245
  lastHeartbeatNs = 0n;
16246
+ lastEventLoopLagMs = 0;
16094
16247
  heartbeatDataDir = null;
16248
+ wakeListeners = /* @__PURE__ */ new Set();
16249
+ }
16250
+ });
16251
+
16252
+ // src/scheduler/backgroundScheduler.ts
16253
+ var backgroundScheduler_exports = {};
16254
+ __export(backgroundScheduler_exports, {
16255
+ PRIORITY: () => PRIORITY,
16256
+ _resetDefaultSchedulerForTests: () => _resetDefaultSchedulerForTests,
16257
+ createBackgroundScheduler: () => createBackgroundScheduler,
16258
+ getBackgroundScheduler: () => getBackgroundScheduler
16259
+ });
16260
+ function createBackgroundScheduler(opts = {}) {
16261
+ const concurrency = opts.concurrency ?? 2;
16262
+ const lagProvider = opts.lagProvider ?? getRecentEventLoopLagMs;
16263
+ const lagThresholdMs = opts.lagThresholdMs ?? 200;
16264
+ const reDrainDelayMs = opts.reDrainDelayMs ?? 250;
16265
+ const now = opts.now ?? (() => Date.now());
16266
+ const wakeStaggerWindowMs = opts.wakeStaggerWindowMs ?? 15e3;
16267
+ const wakeStaggerStepMs = opts.wakeStaggerStepMs ?? 250;
16268
+ const onError = opts.onError ?? ((key, err) => {
16269
+ console.warn("[hotsheet backgroundScheduler] job failed:", key, err instanceof Error ? err.message : String(err));
16270
+ });
16271
+ const pending2 = /* @__PURE__ */ new Map();
16272
+ const running = /* @__PURE__ */ new Set();
16273
+ const runningGroups = /* @__PURE__ */ new Set();
16274
+ const awaiters = /* @__PURE__ */ new Map();
16275
+ const runningAwaiters = /* @__PURE__ */ new Map();
16276
+ let seq = 0;
16277
+ const lastServedSeq = /* @__PURE__ */ new Map();
16278
+ let reDrainTimer = null;
16279
+ const idleWaiters = [];
16280
+ let staggerUntil = 0;
16281
+ let lastStartAt = Number.NEGATIVE_INFINITY;
16282
+ let staggerTimer = null;
16283
+ function projectKeyOf(job) {
16284
+ return job.projectKey ?? job.key;
16285
+ }
16286
+ function settleIdleIfDone() {
16287
+ if (running.size === 0 && pending2.size === 0 && idleWaiters.length > 0) {
16288
+ const waiters = idleWaiters.splice(0, idleWaiters.length);
16289
+ for (const w of waiters) w();
16290
+ }
16291
+ }
16292
+ function pickNext(highLag) {
16293
+ let best = null;
16294
+ let lagDeferred = false;
16295
+ for (const job of pending2.values()) {
16296
+ if (running.has(job.key)) continue;
16297
+ if (job.exclusiveGroup !== void 0 && runningGroups.has(job.exclusiveGroup)) continue;
16298
+ if (highLag && job.deferUnderLag === true) {
16299
+ lagDeferred = true;
16300
+ continue;
16301
+ }
16302
+ if (best === null) {
16303
+ best = job;
16304
+ continue;
16305
+ }
16306
+ if (job.priority !== best.priority) {
16307
+ if (job.priority < best.priority) best = job;
16308
+ continue;
16309
+ }
16310
+ const a = lastServedSeq.get(projectKeyOf(job)) ?? -1;
16311
+ const b = lastServedSeq.get(projectKeyOf(best)) ?? -1;
16312
+ if (a < b) best = job;
16313
+ }
16314
+ return { job: best, lagDeferred };
16315
+ }
16316
+ function drain() {
16317
+ const highLag = lagProvider() > lagThresholdMs;
16318
+ const t = now();
16319
+ const inStagger = t < staggerUntil;
16320
+ const cap = inStagger ? 1 : concurrency;
16321
+ let armReDrain = false;
16322
+ while (running.size < cap) {
16323
+ if (inStagger) {
16324
+ const sinceLast = t - lastStartAt;
16325
+ if (sinceLast < wakeStaggerStepMs) {
16326
+ scheduleStaggerDrain(wakeStaggerStepMs - sinceLast);
16327
+ break;
16328
+ }
16329
+ }
16330
+ const { job, lagDeferred } = pickNext(highLag);
16331
+ if (job === null) {
16332
+ armReDrain = lagDeferred;
16333
+ break;
16334
+ }
16335
+ pending2.delete(job.key);
16336
+ running.add(job.key);
16337
+ if (job.exclusiveGroup !== void 0) runningGroups.add(job.exclusiveGroup);
16338
+ runningAwaiters.set(job.key, awaiters.get(job.key) ?? []);
16339
+ awaiters.delete(job.key);
16340
+ lastServedSeq.set(projectKeyOf(job), ++seq);
16341
+ lastStartAt = t;
16342
+ void runJob(job);
16343
+ }
16344
+ if (armReDrain) scheduleReDrain();
16345
+ settleIdleIfDone();
16346
+ }
16347
+ async function runJob(job) {
16348
+ try {
16349
+ await job.run();
16350
+ } catch (err) {
16351
+ onError(job.key, err);
16352
+ } finally {
16353
+ running.delete(job.key);
16354
+ if (job.exclusiveGroup !== void 0) runningGroups.delete(job.exclusiveGroup);
16355
+ const settled = runningAwaiters.get(job.key);
16356
+ runningAwaiters.delete(job.key);
16357
+ if (settled !== void 0) for (const r of settled) r();
16358
+ drain();
16359
+ }
16360
+ }
16361
+ function scheduleReDrain() {
16362
+ if (reDrainTimer !== null) return;
16363
+ reDrainTimer = setTimeout(() => {
16364
+ reDrainTimer = null;
16365
+ drain();
16366
+ }, reDrainDelayMs);
16367
+ reDrainTimer.unref();
16368
+ }
16369
+ function scheduleStaggerDrain(delayMs) {
16370
+ if (staggerTimer !== null) clearTimeout(staggerTimer);
16371
+ staggerTimer = setTimeout(() => {
16372
+ staggerTimer = null;
16373
+ drain();
16374
+ }, Math.max(0, delayMs));
16375
+ staggerTimer.unref();
16376
+ }
16377
+ return {
16378
+ submit(job) {
16379
+ pending2.set(job.key, job);
16380
+ const done = new Promise((resolve11) => {
16381
+ const list = awaiters.get(job.key) ?? [];
16382
+ list.push(resolve11);
16383
+ awaiters.set(job.key, list);
16384
+ });
16385
+ drain();
16386
+ return done;
16387
+ },
16388
+ runningCount: () => running.size,
16389
+ pendingCount: () => pending2.size,
16390
+ onIdle() {
16391
+ if (running.size === 0 && pending2.size === 0) return Promise.resolve();
16392
+ return new Promise((resolve11) => {
16393
+ idleWaiters.push(resolve11);
16394
+ });
16395
+ },
16396
+ noteWake() {
16397
+ staggerUntil = now() + wakeStaggerWindowMs;
16398
+ drain();
16399
+ },
16400
+ clear() {
16401
+ pending2.clear();
16402
+ if (reDrainTimer !== null) {
16403
+ clearTimeout(reDrainTimer);
16404
+ reDrainTimer = null;
16405
+ }
16406
+ if (staggerTimer !== null) {
16407
+ clearTimeout(staggerTimer);
16408
+ staggerTimer = null;
16409
+ }
16410
+ staggerUntil = 0;
16411
+ const orphaned = [...awaiters.values()].flat();
16412
+ awaiters.clear();
16413
+ for (const r of orphaned) r();
16414
+ settleIdleIfDone();
16415
+ }
16416
+ };
16417
+ }
16418
+ function getBackgroundScheduler() {
16419
+ if (defaultScheduler === null) {
16420
+ defaultScheduler = createBackgroundScheduler();
16421
+ }
16422
+ return defaultScheduler;
16423
+ }
16424
+ function _resetDefaultSchedulerForTests() {
16425
+ defaultScheduler?.clear();
16426
+ defaultScheduler = null;
16427
+ }
16428
+ var PRIORITY, defaultScheduler;
16429
+ var init_backgroundScheduler = __esm({
16430
+ "src/scheduler/backgroundScheduler.ts"() {
16431
+ "use strict";
16432
+ init_freezeLogger();
16433
+ PRIORITY = {
16434
+ /** Startup restoration of the previous session's projects (load-resilience
16435
+ * epic HS-8722, docs/75 — the startup restore path). Highest
16436
+ * priority because each restored project is a user-visible tab — they should
16437
+ * fill in ahead of routine git/markdown/backup churn — but still bounded by
16438
+ * the scheduler's concurrency cap + lag backpressure so the serial fan-out
16439
+ * of N projects can't saturate the event loop on launch (the HS-8721 freeze,
16440
+ * on the one path never migrated onto the scheduler). */
16441
+ PROJECT_RESTORE: 5,
16442
+ GIT_STATUS: 10,
16443
+ MARKDOWN_SYNC: 20,
16444
+ SNAPSHOT: 30,
16445
+ BACKUP: 40,
16446
+ GC: 50
16447
+ };
16448
+ defaultScheduler = null;
16095
16449
  }
16096
16450
  });
16097
16451
 
@@ -16148,7 +16502,7 @@ function initSnapshotScheduler(dataDir) {
16148
16502
  const state = getOrCreateState(dataDir);
16149
16503
  if (state.safetyTimer !== null) return;
16150
16504
  state.safetyTimer = setInterval(() => {
16151
- if (state.dirty && !state.inProgress) void writeSnapshotNow(dataDir);
16505
+ if (state.dirty && !state.inProgress) void submitSnapshotJob(dataDir);
16152
16506
  }, numericSetting(dataDir, "db_snapshot_safety_interval_ms", DEFAULT_SAFETY_INTERVAL_MS));
16153
16507
  state.safetyTimer.unref();
16154
16508
  }
@@ -16161,9 +16515,20 @@ function scheduleSnapshot(dataDir) {
16161
16515
  if (state.debounceTimer) clearTimeout(state.debounceTimer);
16162
16516
  state.debounceTimer = setTimeout(() => {
16163
16517
  state.debounceTimer = null;
16164
- void writeSnapshotNow(dir);
16518
+ void submitSnapshotJob(dir);
16165
16519
  }, numericSetting(dir, "db_snapshot_debounce_ms", DEFAULT_DEBOUNCE_MS));
16166
16520
  }
16521
+ function submitSnapshotJob(dataDir) {
16522
+ return getBackgroundScheduler().submit({
16523
+ key: `snapshot:${dataDir}`,
16524
+ priority: PRIORITY.SNAPSHOT,
16525
+ projectKey: dataDir,
16526
+ deferUnderLag: false,
16527
+ run: async () => {
16528
+ await writeSnapshotNow(dataDir);
16529
+ }
16530
+ });
16531
+ }
16167
16532
  async function writeSnapshotNow(dataDir) {
16168
16533
  if (!isSnapshotProtectionEnabled(dataDir)) return null;
16169
16534
  if (!existsSync4(join5(dataDir, "db"))) return null;
@@ -16205,7 +16570,7 @@ async function snapshotAllForShutdown() {
16205
16570
  for (const dir of dirs) {
16206
16571
  if (!isSnapshotProtectionEnabled(dir)) continue;
16207
16572
  try {
16208
- await writeSnapshotNow(dir);
16573
+ await submitSnapshotJob(dir);
16209
16574
  } catch (err) {
16210
16575
  console.error(`[snapshot] shutdown snapshot failed for ${dir}:`, err);
16211
16576
  }
@@ -16257,6 +16622,7 @@ var init_snapshot = __esm({
16257
16622
  "use strict";
16258
16623
  init_freezeLogger();
16259
16624
  init_file_settings();
16625
+ init_backgroundScheduler();
16260
16626
  init_connection();
16261
16627
  DEFAULT_DEBOUNCE_MS = 2e3;
16262
16628
  DEFAULT_SAFETY_INTERVAL_MS = 12e4;
@@ -16570,10 +16936,10 @@ async function renameDirWithRetry(from, to) {
16570
16936
  }
16571
16937
  async function completeDeferredRecovery(dbPath) {
16572
16938
  const dataDir = dbPath.replace(/[\\/]db$/, "");
16573
- const pending = readPendingRecovery(dataDir);
16574
- if (pending === null) return null;
16575
- if (pending.attempts > MAX_DEFERRED_RECOVERY_ATTEMPTS) {
16576
- console.error(`[db] deferred recovery gave up after ${String(pending.attempts)} attempts; leaving the corrupt cluster for manual rescue.`);
16939
+ const pending2 = readPendingRecovery(dataDir);
16940
+ if (pending2 === null) return null;
16941
+ if (pending2.attempts > MAX_DEFERRED_RECOVERY_ATTEMPTS) {
16942
+ console.error(`[db] deferred recovery gave up after ${String(pending2.attempts)} attempts; leaving the corrupt cluster for manual rescue.`);
16577
16943
  clearPendingRecovery(dataDir);
16578
16944
  return null;
16579
16945
  }
@@ -16586,7 +16952,7 @@ async function completeDeferredRecovery(dbPath) {
16586
16952
  try {
16587
16953
  await renameDirWithRetry(dbPath, corruptPath);
16588
16954
  } catch (renameErr) {
16589
- writePendingRecovery(dataDir, pending.attempts + 1);
16955
+ writePendingRecovery(dataDir, pending2.attempts + 1);
16590
16956
  console.error(`[db] deferred recovery could not move db/ yet: ${getErrorMessage(renameErr)}`);
16591
16957
  return null;
16592
16958
  }
@@ -16883,6 +17249,22 @@ async function initSchema(db) {
16883
17249
  CREATE INDEX IF NOT EXISTS idx_otel_spans_session_ts ON otel_spans(session_id, start_ts);
16884
17250
  CREATE INDEX IF NOT EXISTS idx_otel_spans_prompt ON otel_spans(prompt_id);
16885
17251
  CREATE INDEX IF NOT EXISTS idx_otel_spans_trace ON otel_spans(trace_id);
17252
+
17253
+ -- HS-8730 (per-ticket cost, time-window correlation) \u2014 records when each
17254
+ -- ticket was actively being worked (its status was 'started'), so the
17255
+ -- per-ticket rollup can attribute api_request cost by timestamp instead of
17256
+ -- only the channelUI prompt marker. Lives in the telemetry DB (this is the
17257
+ -- default/primary project's DB per getTelemetryDb) so the rollup join with
17258
+ -- otel_events is single-DB. Keyed by project_secret (matching otel_events).
17259
+ CREATE TABLE IF NOT EXISTS ticket_work_intervals (
17260
+ id SERIAL PRIMARY KEY,
17261
+ project_secret TEXT NOT NULL,
17262
+ ticket_number TEXT NOT NULL,
17263
+ started_at TIMESTAMPTZ NOT NULL,
17264
+ ended_at TIMESTAMPTZ
17265
+ );
17266
+ CREATE INDEX IF NOT EXISTS idx_twi_secret_ticket ON ticket_work_intervals(project_secret, ticket_number);
17267
+ CREATE INDEX IF NOT EXISTS idx_twi_open ON ticket_work_intervals(project_secret, ticket_number, ended_at);
16886
17268
  `);
16887
17269
  await migrateNoteIds(db);
16888
17270
  }
@@ -16916,7 +17298,7 @@ var init_connection = __esm({
16916
17298
  init_zod();
16917
17299
  init_errorMessage();
16918
17300
  init_pglite();
16919
- SCHEMA_VERSION = 4;
17301
+ SCHEMA_VERSION = 5;
16920
17302
  RECOVERY_MARKER_FILENAME = ".db-recovery-marker.json";
16921
17303
  PENDING_RECOVERY_FILENAME = ".db-pending-recovery.json";
16922
17304
  MAX_DEFERRED_RECOVERY_ATTEMPTS = 3;
@@ -17011,20 +17393,27 @@ function getOrCreateState2(dataDir) {
17011
17393
  }
17012
17394
  return state;
17013
17395
  }
17014
- async function withGlobalBackupLock(fn) {
17015
- while (activeBackup !== null) {
17016
- try {
17017
- await activeBackup;
17018
- } catch {
17396
+ function withGlobalBackupLock(fn) {
17397
+ let settle;
17398
+ let fail;
17399
+ const out = new Promise((resolve11, reject2) => {
17400
+ settle = resolve11;
17401
+ fail = reject2;
17402
+ });
17403
+ void getBackgroundScheduler().submit({
17404
+ key: `backup:${++backupLockSeq}`,
17405
+ priority: PRIORITY.BACKUP,
17406
+ exclusiveGroup: BACKUP_EXCLUSIVE_GROUP,
17407
+ deferUnderLag: false,
17408
+ run: async () => {
17409
+ try {
17410
+ settle(await fn());
17411
+ } catch (e) {
17412
+ fail(e);
17413
+ }
17019
17414
  }
17020
- }
17021
- const p = (async () => fn())();
17022
- activeBackup = p;
17023
- try {
17024
- return await p;
17025
- } finally {
17026
- if (activeBackup === p) activeBackup = null;
17027
- }
17415
+ });
17416
+ return out;
17028
17417
  }
17029
17418
  function backupsDir(dataDir) {
17030
17419
  return getBackupDir(dataDir);
@@ -17288,12 +17677,18 @@ function initBackupScheduler(dataDir) {
17288
17677
  });
17289
17678
  }, 3e4);
17290
17679
  state.attachmentGcInterval = setInterval(() => {
17291
- void instrumentAsync(dataDir, "attachmentBackup.orphanGc:daily", () => runAttachmentGc(backupsDir(dataDir))).then((stats) => {
17292
- if (stats.deleted > 0) {
17293
- console.log(`[attachmentBackup] GC: reclaimed ${stats.deleted} blob(s), ${(stats.bytesReclaimed / 1024 / 1024).toFixed(2)} MB`);
17294
- }
17295
- }).catch((err) => {
17296
- console.error("[attachmentBackup] GC daily run failed:", err);
17680
+ void getBackgroundScheduler().submit({
17681
+ key: `attachment-gc:${dataDir}`,
17682
+ priority: PRIORITY.GC,
17683
+ projectKey: dataDir,
17684
+ deferUnderLag: true,
17685
+ run: () => instrumentAsync(dataDir, "attachmentBackup.orphanGc:daily", () => runAttachmentGc(backupsDir(dataDir))).then((stats) => {
17686
+ if (stats.deleted > 0) {
17687
+ console.log(`[attachmentBackup] GC: reclaimed ${stats.deleted} blob(s), ${(stats.bytesReclaimed / 1024 / 1024).toFixed(2)} MB`);
17688
+ }
17689
+ }).catch((err) => {
17690
+ console.error("[attachmentBackup] GC daily run failed:", err);
17691
+ })
17297
17692
  });
17298
17693
  }, 24 * 60 * 60 * 1e3);
17299
17694
  }
@@ -17312,7 +17707,7 @@ async function triggerMissedBackups(dataDir) {
17312
17707
  await createBackup(dataDir, tier);
17313
17708
  }
17314
17709
  }
17315
- var TIERS, backupStates, activePreviews, activeBackup, VALID_TIERS;
17710
+ var TIERS, backupStates, activePreviews, backupLockSeq, BACKUP_EXCLUSIVE_GROUP, VALID_TIERS;
17316
17711
  var init_backup = __esm({
17317
17712
  "src/backup.ts"() {
17318
17713
  "use strict";
@@ -17323,6 +17718,7 @@ var init_backup = __esm({
17323
17718
  init_dbJsonExport();
17324
17719
  init_freezeLogger();
17325
17720
  init_file_settings();
17721
+ init_backgroundScheduler();
17326
17722
  TIERS = {
17327
17723
  "5min": { intervalMs: 5 * 60 * 1e3, maxAge: 60 * 60 * 1e3, maxCount: 12 },
17328
17724
  "hourly": { intervalMs: 60 * 60 * 1e3, maxAge: 12 * 60 * 60 * 1e3, maxCount: 12 },
@@ -17330,7 +17726,8 @@ var init_backup = __esm({
17330
17726
  };
17331
17727
  backupStates = /* @__PURE__ */ new Map();
17332
17728
  activePreviews = /* @__PURE__ */ new Map();
17333
- activeBackup = null;
17729
+ backupLockSeq = 0;
17730
+ BACKUP_EXCLUSIVE_GROUP = "backup";
17334
17731
  VALID_TIERS = /* @__PURE__ */ new Set(["5min", "hourly", "daily"]);
17335
17732
  }
17336
17733
  });
@@ -17905,6 +18302,47 @@ var init_ticketNumber = __esm({
17905
18302
  }
17906
18303
  });
17907
18304
 
18305
+ // src/db/ticketWorkIntervals.ts
18306
+ async function recordTicketWorkTransition(secret, ticketNumber, status) {
18307
+ if (secret === "" || ticketNumber === "") return;
18308
+ try {
18309
+ const db = await getTelemetryDb();
18310
+ await db.query(
18311
+ `UPDATE ticket_work_intervals SET ended_at = NOW()
18312
+ WHERE project_secret = $1 AND ticket_number = $2 AND ended_at IS NULL`,
18313
+ [secret, ticketNumber]
18314
+ );
18315
+ if (status === "started") {
18316
+ await db.query(
18317
+ `INSERT INTO ticket_work_intervals (project_secret, ticket_number, started_at)
18318
+ VALUES ($1, $2, NOW())`,
18319
+ [secret, ticketNumber]
18320
+ );
18321
+ }
18322
+ } catch (err) {
18323
+ console.warn("[ticketWorkIntervals] failed to record transition:", err instanceof Error ? err.message : String(err));
18324
+ }
18325
+ }
18326
+ async function closeOpenTicketIntervalsForProject(secret) {
18327
+ if (secret === "") return;
18328
+ try {
18329
+ const db = await getTelemetryDb();
18330
+ await db.query(
18331
+ `UPDATE ticket_work_intervals SET ended_at = NOW()
18332
+ WHERE project_secret = $1 AND ended_at IS NULL`,
18333
+ [secret]
18334
+ );
18335
+ } catch (err) {
18336
+ console.warn("[ticketWorkIntervals] failed to close open intervals:", err instanceof Error ? err.message : String(err));
18337
+ }
18338
+ }
18339
+ var init_ticketWorkIntervals = __esm({
18340
+ "src/db/ticketWorkIntervals.ts"() {
18341
+ "use strict";
18342
+ init_connection();
18343
+ }
18344
+ });
18345
+
17908
18346
  // src/db/tickets.ts
17909
18347
  function escapeIlike(value) {
17910
18348
  return value.replace(/[%_\\]/g, "\\$&");
@@ -18037,7 +18475,17 @@ async function updateTicket(id, updates, options) {
18037
18475
  `UPDATE tickets SET ${sets.join(", ")} WHERE id = $${paramIdx} RETURNING *`,
18038
18476
  values
18039
18477
  );
18040
- return result.rows[0] ?? null;
18478
+ const updated = result.rows[0] ?? null;
18479
+ if (updates.status !== void 0) {
18480
+ try {
18481
+ const secret = readFileSettings(getDataDir()).secret;
18482
+ if (secret !== void 0 && secret !== "") {
18483
+ void recordTicketWorkTransition(secret, updated.ticket_number, updates.status);
18484
+ }
18485
+ } catch {
18486
+ }
18487
+ }
18488
+ return updated;
18041
18489
  }
18042
18490
  async function deleteTicket(id) {
18043
18491
  await updateTicket(id, { status: "deleted" });
@@ -18376,9 +18824,11 @@ var QUERYABLE_FIELDS, PRIORITY_ORD, STATUS_ORD, PRIORITY_RANK, STATUS_RANK;
18376
18824
  var init_tickets = __esm({
18377
18825
  "src/db/tickets.ts"() {
18378
18826
  "use strict";
18827
+ init_file_settings();
18379
18828
  init_ticketNumber();
18380
18829
  init_connection();
18381
18830
  init_notes();
18831
+ init_ticketWorkIntervals();
18382
18832
  QUERYABLE_FIELDS = /* @__PURE__ */ new Set(["category", "priority", "status", "title", "details", "up_next", "tags"]);
18383
18833
  PRIORITY_ORD = `CASE priority WHEN 'highest' THEN 1 WHEN 'high' THEN 2 WHEN 'default' THEN 3 WHEN 'low' THEN 4 WHEN 'lowest' THEN 5 ELSE 3 END`;
18384
18834
  STATUS_ORD = `CASE status WHEN 'backlog' THEN 1 WHEN 'not_started' THEN 2 WHEN 'started' THEN 3 WHEN 'completed' THEN 4 WHEN 'verified' THEN 5 WHEN 'archive' THEN 6 ELSE 2 END`;
@@ -19483,7 +19933,14 @@ function scheduleWorklistSync(dir) {
19483
19933
  if (!state) return;
19484
19934
  if (state.worklistTimeout) clearTimeout(state.worklistTimeout);
19485
19935
  state.worklistTimeout = setTimeout(() => {
19486
- void runWithDataDir(state.dataDir, () => instrumentAsync(state.dataDir, "markdown.syncWorklist", () => syncWorklist(state)));
19936
+ state.worklistTimeout = null;
19937
+ void getBackgroundScheduler().submit({
19938
+ key: `markdown-worklist:${state.dataDir}`,
19939
+ priority: PRIORITY.MARKDOWN_SYNC,
19940
+ projectKey: state.dataDir,
19941
+ deferUnderLag: true,
19942
+ run: () => runWithDataDir(state.dataDir, () => instrumentAsync(state.dataDir, "markdown.syncWorklist", () => syncWorklist(state)))
19943
+ });
19487
19944
  }, WORKLIST_SYNC_DEBOUNCE_MS);
19488
19945
  }
19489
19946
  function scheduleOpenTicketsSync(dir) {
@@ -19491,7 +19948,14 @@ function scheduleOpenTicketsSync(dir) {
19491
19948
  if (!state) return;
19492
19949
  if (state.openTicketsTimeout) clearTimeout(state.openTicketsTimeout);
19493
19950
  state.openTicketsTimeout = setTimeout(() => {
19494
- void runWithDataDir(state.dataDir, () => instrumentAsync(state.dataDir, "markdown.syncOpenTickets", () => syncOpenTickets(state)));
19951
+ state.openTicketsTimeout = null;
19952
+ void getBackgroundScheduler().submit({
19953
+ key: `markdown-opentickets:${state.dataDir}`,
19954
+ priority: PRIORITY.MARKDOWN_SYNC,
19955
+ projectKey: state.dataDir,
19956
+ deferUnderLag: true,
19957
+ run: () => runWithDataDir(state.dataDir, () => instrumentAsync(state.dataDir, "markdown.syncOpenTickets", () => syncOpenTickets(state)))
19958
+ });
19495
19959
  }, OPEN_TICKETS_SYNC_DEBOUNCE_MS);
19496
19960
  }
19497
19961
  function scheduleAllSync(dir) {
@@ -19809,6 +20273,7 @@ var init_markdown = __esm({
19809
20273
  init_freezeLogger();
19810
20274
  init_file_settings();
19811
20275
  init_limits();
20276
+ init_backgroundScheduler();
19812
20277
  init_schemas3();
19813
20278
  syncStates = /* @__PURE__ */ new Map();
19814
20279
  defaultDataDir2 = null;
@@ -19876,7 +20341,10 @@ async function registerProject(dataDir, port) {
19876
20341
  const existing = projects.get(existingSecret);
19877
20342
  if (existing) return existing;
19878
20343
  }
20344
+ const dbT0 = Date.now();
19879
20345
  const db = await getDbForDir(absDataDir);
20346
+ const dbMs = Date.now() - dbT0;
20347
+ if (dbMs > 500) startupLog(`[restore-step] getDbForDir took ${String(dbMs)}ms for ${absDataDir}`);
19880
20348
  const { migrateDbSettingsToFile: migrateDbSettingsToFile2 } = await Promise.resolve().then(() => (init_migrate_settings(), migrate_settings_exports));
19881
20349
  await runWithDataDir(absDataDir, () => migrateDbSettingsToFile2(absDataDir));
19882
20350
  const secret = ensureSecret(absDataDir, port);
@@ -19986,6 +20454,7 @@ var init_projects = __esm({
19986
20454
  init_file_settings();
19987
20455
  init_lock();
19988
20456
  init_skills();
20457
+ init_startup_log();
19989
20458
  init_markdown();
19990
20459
  init_isExecutableOnPath();
19991
20460
  projects = /* @__PURE__ */ new Map();
@@ -21365,6 +21834,25 @@ var init_sessionStore = __esm({
21365
21834
  }
21366
21835
  });
21367
21836
 
21837
+ // src/activeProjects.ts
21838
+ function markProjectActive(dataDir) {
21839
+ lastActiveAt.set(dataDir, Date.now());
21840
+ }
21841
+ function isProjectActive(dataDir) {
21842
+ if (lastActiveAt.size === 0) return true;
21843
+ const at = lastActiveAt.get(dataDir);
21844
+ if (at === void 0) return false;
21845
+ return Date.now() - at < ACTIVE_TTL_MS;
21846
+ }
21847
+ var ACTIVE_TTL_MS, lastActiveAt;
21848
+ var init_activeProjects = __esm({
21849
+ "src/activeProjects.ts"() {
21850
+ "use strict";
21851
+ ACTIVE_TTL_MS = 9e4;
21852
+ lastActiveAt = /* @__PURE__ */ new Map();
21853
+ }
21854
+ });
21855
+
21368
21856
  // src/gitignore.ts
21369
21857
  import { execFileSync as execFileSync3 } from "child_process";
21370
21858
  import { appendFileSync as appendFileSync3, existsSync as existsSync16, readFileSync as readFileSync13 } from "fs";
@@ -21420,56 +21908,72 @@ var init_gitignore = __esm({
21420
21908
  });
21421
21909
 
21422
21910
  // src/git/status.ts
21423
- import { spawnSync } from "child_process";
21911
+ import { execFile as execFile3 } from "child_process";
21424
21912
  import { join as join23 } from "path";
21913
+ import { promisify as promisify2 } from "util";
21914
+ function bufToStr(v) {
21915
+ if (typeof v === "string") return v;
21916
+ if (v !== void 0) return v.toString();
21917
+ return "";
21918
+ }
21425
21919
  function makeGitInvoker({ timeoutMs, includeStderr = false }) {
21426
- return (args, cwd) => {
21427
- const res = spawnSync("git", args, {
21920
+ return async (args, cwd) => {
21921
+ const opts = {
21428
21922
  cwd,
21429
21923
  encoding: "utf-8",
21430
21924
  timeout: timeoutMs,
21925
+ maxBuffer: GIT_MAX_BUFFER,
21431
21926
  env: {
21432
21927
  ...process.env,
21433
21928
  GIT_TERMINAL_PROMPT: "0",
21434
21929
  GIT_OPTIONAL_LOCKS: "0"
21435
21930
  }
21436
- });
21437
- const stdout = typeof res.stdout === "string" ? res.stdout : "";
21438
- const stderr = typeof res.stderr === "string" ? res.stderr : "";
21439
- return { stdout: includeStderr ? stdout + stderr : stdout, status: res.status };
21931
+ };
21932
+ try {
21933
+ const { stdout, stderr } = await execFileAsync2("git", args, opts);
21934
+ const out = bufToStr(stdout);
21935
+ const err = bufToStr(stderr);
21936
+ return { stdout: includeStderr ? out + err : out, status: 0 };
21937
+ } catch (e) {
21938
+ const errObj = e;
21939
+ const out = bufToStr(errObj.stdout);
21940
+ const err = bufToStr(errObj.stderr);
21941
+ const status = typeof errObj.code === "number" ? errObj.code : null;
21942
+ return { stdout: includeStderr ? out + err : out, status };
21943
+ }
21440
21944
  };
21441
21945
  }
21442
- function getGitStatus(projectRoot3, invoker = defaultInvoker) {
21946
+ async function getGitStatus(projectRoot3, invoker = defaultInvoker) {
21443
21947
  if (!isGitRepo(projectRoot3)) return null;
21444
- return instrumentSync(join23(projectRoot3, ".hotsheet"), "git.getStatus", () => getGitStatusUnwrapped(projectRoot3, invoker));
21948
+ return instrumentAsync(join23(projectRoot3, ".hotsheet"), "git.getStatus", () => getGitStatusUnwrapped(projectRoot3, invoker));
21445
21949
  }
21446
- function getGitStatusUnwrapped(projectRoot3, invoker) {
21950
+ async function getGitStatusUnwrapped(projectRoot3, invoker) {
21447
21951
  const root2 = getGitRoot(projectRoot3) ?? projectRoot3;
21448
- const branchRes = invoker(["symbolic-ref", "--short", "HEAD"], root2);
21952
+ const branchRes = await invoker(["symbolic-ref", "--short", "HEAD"], root2);
21449
21953
  let branch;
21450
21954
  let detached = false;
21451
21955
  if (branchRes.status === 0 && branchRes.stdout.trim() !== "") {
21452
21956
  branch = branchRes.stdout.trim();
21453
21957
  } else {
21454
21958
  detached = true;
21455
- const sha = invoker(["rev-parse", "--short", "HEAD"], root2);
21959
+ const sha = await invoker(["rev-parse", "--short", "HEAD"], root2);
21456
21960
  branch = sha.status === 0 && sha.stdout.trim() !== "" ? sha.stdout.trim() : "(detached)";
21457
21961
  }
21458
- const porcelain = invoker(["status", "--porcelain=v1", "--no-renames"], root2);
21962
+ const porcelain = await invoker(["status", "--porcelain=v1", "--no-renames"], root2);
21459
21963
  const counts = porcelain.status === 0 ? bucketPorcelain(porcelain.stdout) : { staged: 0, unstaged: 0, untracked: 0, conflicted: 0 };
21460
21964
  let upstream = null;
21461
21965
  let ahead = 0;
21462
21966
  let behind = 0;
21463
21967
  if (!detached) {
21464
- const upRes = invoker(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root2);
21968
+ const upRes = await invoker(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root2);
21465
21969
  if (upRes.status === 0 && upRes.stdout.trim() !== "") {
21466
21970
  upstream = upRes.stdout.trim();
21467
- const aheadRes = invoker(["rev-list", "--count", "@{u}..HEAD"], root2);
21971
+ const aheadRes = await invoker(["rev-list", "--count", "@{u}..HEAD"], root2);
21468
21972
  if (aheadRes.status === 0) {
21469
21973
  const n = Number.parseInt(aheadRes.stdout.trim(), 10);
21470
21974
  if (Number.isFinite(n)) ahead = n;
21471
21975
  }
21472
- const behindRes = invoker(["rev-list", "--count", "HEAD..@{u}"], root2);
21976
+ const behindRes = await invoker(["rev-list", "--count", "HEAD..@{u}"], root2);
21473
21977
  if (behindRes.status === 0) {
21474
21978
  const n = Number.parseInt(behindRes.stdout.trim(), 10);
21475
21979
  if (Number.isFinite(n)) behind = n;
@@ -21492,16 +21996,16 @@ function getGitStatusUnwrapped(projectRoot3, invoker) {
21492
21996
  function getLastFetchedAt(projectRoot3) {
21493
21997
  return lastFetchedAt.get(projectRoot3) ?? null;
21494
21998
  }
21495
- function runGitFetch(projectRoot3, invoker = makeGitInvoker({ timeoutMs: 3e4, includeStderr: true })) {
21999
+ async function runGitFetch(projectRoot3, invoker = makeGitInvoker({ timeoutMs: 3e4, includeStderr: true })) {
21496
22000
  if (!isGitRepo(projectRoot3)) {
21497
22001
  return { ok: false, lastFetchedAt: null, error: "Not a git repository" };
21498
22002
  }
21499
22003
  const root2 = getGitRoot(projectRoot3) ?? projectRoot3;
21500
- const upRes = invoker(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root2);
22004
+ const upRes = await invoker(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root2);
21501
22005
  if (upRes.status !== 0 || upRes.stdout.trim() === "") {
21502
22006
  return { ok: false, lastFetchedAt: null, error: "No upstream branch \u2014 set one with `git push -u <remote> <branch>`." };
21503
22007
  }
21504
- const fetchRes = invoker(["fetch", "--quiet", "--no-write-fetch-head"], root2);
22008
+ const fetchRes = await invoker(["fetch", "--quiet", "--no-write-fetch-head"], root2);
21505
22009
  if (fetchRes.status === 0) {
21506
22010
  const now = Date.now();
21507
22011
  lastFetchedAt.set(projectRoot3, now);
@@ -21528,11 +22032,11 @@ function bucketPorcelain(output) {
21528
22032
  }
21529
22033
  return out;
21530
22034
  }
21531
- function getGitStatusFiles(projectRoot3, invoker = defaultInvoker) {
22035
+ async function getGitStatusFiles(projectRoot3, invoker = defaultInvoker) {
21532
22036
  if (!isGitRepo(projectRoot3)) return null;
21533
- return instrumentSync(join23(projectRoot3, ".hotsheet"), "git.getStatusFiles", () => {
22037
+ return instrumentAsync(join23(projectRoot3, ".hotsheet"), "git.getStatusFiles", async () => {
21534
22038
  const root2 = getGitRoot(projectRoot3) ?? projectRoot3;
21535
- const res = invoker(["status", "--porcelain=v1", "--no-renames", "-z"], root2);
22039
+ const res = await invoker(["status", "--porcelain=v1", "--no-renames", "-z"], root2);
21536
22040
  if (res.status !== 0) return null;
21537
22041
  return bucketPorcelainFiles(res.stdout);
21538
22042
  });
@@ -21579,13 +22083,15 @@ function pushCapped(arr, item, onTruncate) {
21579
22083
  }
21580
22084
  arr.push(item);
21581
22085
  }
21582
- var SPAWN_TIMEOUT_MS, defaultInvoker, lastFetchedAt, FILES_PER_BUCKET_CAP;
22086
+ var execFileAsync2, SPAWN_TIMEOUT_MS, GIT_MAX_BUFFER, defaultInvoker, lastFetchedAt, FILES_PER_BUCKET_CAP;
21583
22087
  var init_status = __esm({
21584
22088
  "src/git/status.ts"() {
21585
22089
  "use strict";
21586
22090
  init_freezeLogger();
21587
22091
  init_gitignore();
22092
+ execFileAsync2 = promisify2(execFile3);
21588
22093
  SPAWN_TIMEOUT_MS = 2e3;
22094
+ GIT_MAX_BUFFER = 32 * 1024 * 1024;
21589
22095
  defaultInvoker = makeGitInvoker({ timeoutMs: SPAWN_TIMEOUT_MS });
21590
22096
  lastFetchedAt = /* @__PURE__ */ new Map();
21591
22097
  FILES_PER_BUCKET_CAP = 200;
@@ -21606,21 +22112,33 @@ __export(watcher_exports, {
21606
22112
  });
21607
22113
  import { existsSync as existsSync17, watch as fsWatch } from "fs";
21608
22114
  import { join as join24 } from "path";
21609
- function getCachedGitStatus(projectRoot3) {
22115
+ async function getCachedGitStatus(projectRoot3) {
21610
22116
  const entry = cache.get(projectRoot3);
21611
22117
  const now = Date.now();
21612
22118
  if (entry !== void 0 && now - entry.resolvedAt < CACHE_TTL_MS) {
21613
22119
  return entry.status;
21614
22120
  }
21615
- const status = getGitStatus(projectRoot3);
21616
- cache.set(projectRoot3, { status, resolvedAt: now });
21617
- return status;
22121
+ const pending2 = inFlight.get(projectRoot3);
22122
+ if (pending2 !== void 0) return pending2;
22123
+ const p = (async () => {
22124
+ try {
22125
+ const status = await getGitStatus(projectRoot3);
22126
+ cache.set(projectRoot3, { status, resolvedAt: Date.now() });
22127
+ return status;
22128
+ } finally {
22129
+ inFlight.delete(projectRoot3);
22130
+ }
22131
+ })();
22132
+ inFlight.set(projectRoot3, p);
22133
+ return p;
21618
22134
  }
21619
22135
  function _resetGitStatusCacheForTests() {
21620
22136
  cache.clear();
22137
+ inFlight.clear();
21621
22138
  }
21622
22139
  function dropGitStatusCache(projectRoot3) {
21623
22140
  cache.delete(projectRoot3);
22141
+ inFlight.delete(projectRoot3);
21624
22142
  }
21625
22143
  function subscribeToGitChanges(handler) {
21626
22144
  subscribers.add(handler);
@@ -21649,13 +22167,22 @@ function ensureGitWatcher(projectRoot3) {
21649
22167
  if (e2 === void 0) return;
21650
22168
  e2.debounce = null;
21651
22169
  cache.delete(projectRoot3);
22170
+ inFlight.delete(projectRoot3);
21652
22171
  e2.version++;
22172
+ if (!isProjectActive(join24(projectRoot3, ".hotsheet"))) return;
21653
22173
  for (const sub of subscribers) {
21654
22174
  try {
21655
22175
  sub(projectRoot3);
21656
22176
  } catch {
21657
22177
  }
21658
22178
  }
22179
+ void getBackgroundScheduler().submit({
22180
+ key: `git-refresh:${projectRoot3}`,
22181
+ priority: PRIORITY.GIT_STATUS,
22182
+ projectKey: projectRoot3,
22183
+ deferUnderLag: true,
22184
+ run: () => getCachedGitStatus(projectRoot3).then(() => void 0)
22185
+ });
21659
22186
  }, WATCHER_DEBOUNCE_MS);
21660
22187
  };
21661
22188
  for (const file2 of filenames) {
@@ -21682,19 +22209,23 @@ function disposeGitWatcher(projectRoot3) {
21682
22209
  watchers.delete(projectRoot3);
21683
22210
  }
21684
22211
  cache.delete(projectRoot3);
22212
+ inFlight.delete(projectRoot3);
21685
22213
  }
21686
22214
  function disposeAllGitWatchers() {
21687
22215
  for (const root2 of [...watchers.keys()]) disposeGitWatcher(root2);
21688
22216
  subscribers.clear();
21689
22217
  }
21690
- var CACHE_TTL_MS, cache, WATCHER_DEBOUNCE_MS, watchers, subscribers;
22218
+ var CACHE_TTL_MS, cache, inFlight, WATCHER_DEBOUNCE_MS, watchers, subscribers;
21691
22219
  var init_watcher = __esm({
21692
22220
  "src/git/watcher.ts"() {
21693
22221
  "use strict";
22222
+ init_activeProjects();
21694
22223
  init_gitignore();
22224
+ init_backgroundScheduler();
21695
22225
  init_status();
21696
22226
  CACHE_TTL_MS = 500;
21697
22227
  cache = /* @__PURE__ */ new Map();
22228
+ inFlight = /* @__PURE__ */ new Map();
21698
22229
  WATCHER_DEBOUNCE_MS = 250;
21699
22230
  watchers = /* @__PURE__ */ new Map();
21700
22231
  subscribers = /* @__PURE__ */ new Set();
@@ -23775,6 +24306,7 @@ async function runShutdownPipeline(reason) {
23775
24306
  await killShellCommands();
23776
24307
  await destroyTerminals();
23777
24308
  await disposeGitWatchers();
24309
+ await terminateHashWorkerStep();
23778
24310
  await snapshotDatabases();
23779
24311
  await closeDatabases();
23780
24312
  stopFreezeHeartbeat();
@@ -23807,6 +24339,14 @@ async function disposeGitWatchers() {
23807
24339
  console.error("[lifecycle] disposeAllGitWatchers error:", err);
23808
24340
  }
23809
24341
  }
24342
+ async function terminateHashWorkerStep() {
24343
+ try {
24344
+ const { terminateHashWorker: terminateHashWorker2 } = await Promise.resolve().then(() => (init_hashWorker(), hashWorker_exports));
24345
+ await terminateHashWorker2();
24346
+ } catch (err) {
24347
+ console.error("[lifecycle] terminateHashWorker error:", err);
24348
+ }
24349
+ }
23810
24350
  async function closeHttpServer() {
23811
24351
  if (httpServer === null) return;
23812
24352
  await new Promise((resolve11) => {
@@ -23901,25 +24441,25 @@ var init_mime_types = __esm({
23901
24441
  // src/open-in-file-manager.ts
23902
24442
  import { dirname as dirname6 } from "path";
23903
24443
  async function openInFileManager(dirPath) {
23904
- const { execFile: execFile7 } = await import("child_process");
24444
+ const { execFile: execFile8 } = await import("child_process");
23905
24445
  const platform2 = process.platform;
23906
24446
  if (platform2 === "darwin") {
23907
- execFile7("open", [dirPath]);
24447
+ execFile8("open", [dirPath]);
23908
24448
  } else if (platform2 === "win32") {
23909
- execFile7("explorer", [dirPath]);
24449
+ execFile8("explorer", [dirPath]);
23910
24450
  } else {
23911
- execFile7("xdg-open", [dirPath]);
24451
+ execFile8("xdg-open", [dirPath]);
23912
24452
  }
23913
24453
  }
23914
24454
  async function revealInFileManager(filePath) {
23915
- const { execFile: execFile7 } = await import("child_process");
24455
+ const { execFile: execFile8 } = await import("child_process");
23916
24456
  const platform2 = process.platform;
23917
24457
  if (platform2 === "darwin") {
23918
- execFile7("open", ["-R", filePath]);
24458
+ execFile8("open", ["-R", filePath]);
23919
24459
  } else if (platform2 === "win32") {
23920
- execFile7("explorer", ["/select,", filePath]);
24460
+ execFile8("explorer", ["/select,", filePath]);
23921
24461
  } else {
23922
- execFile7("xdg-open", [dirname6(filePath)]);
24462
+ execFile8("xdg-open", [dirname6(filePath)]);
23923
24463
  }
23924
24464
  }
23925
24465
  var init_open_in_file_manager = __esm({
@@ -24217,14 +24757,14 @@ __export(keychain_exports, {
24217
24757
  keychainGet: () => keychainGet,
24218
24758
  keychainSet: () => keychainSet
24219
24759
  });
24220
- import { execFile as execFile3 } from "child_process";
24760
+ import { execFile as execFile4 } from "child_process";
24221
24761
  import { platform } from "os";
24222
24762
  function makeService(pluginId) {
24223
24763
  return `${SERVICE_PREFIX}.${pluginId}`;
24224
24764
  }
24225
24765
  function exec(cmd, args) {
24226
24766
  return new Promise((resolve11) => {
24227
- execFile3(cmd, args, { timeout: 5e3 }, (error51, stdout) => {
24767
+ execFile4(cmd, args, { timeout: 5e3 }, (error51, stdout) => {
24228
24768
  resolve11({ stdout: stdout.trim(), exitCode: error51 ? error51.status ?? 1 : 0 });
24229
24769
  });
24230
24770
  });
@@ -24276,7 +24816,7 @@ async function linuxGet(service, account) {
24276
24816
  }
24277
24817
  async function linuxSet(service, account, password) {
24278
24818
  return new Promise((resolve11) => {
24279
- const proc = execFile3("secret-tool", [
24819
+ const proc = execFile4("secret-tool", [
24280
24820
  "store",
24281
24821
  "--label",
24282
24822
  `Hot Sheet: ${account}`,
@@ -26014,13 +26554,13 @@ var require_aspromise = __commonJS({
26014
26554
  "use strict";
26015
26555
  module.exports = asPromise;
26016
26556
  function asPromise(fn, ctx) {
26017
- var params = new Array(arguments.length - 1), offset = 0, index = 2, pending = true;
26557
+ var params = new Array(arguments.length - 1), offset = 0, index = 2, pending2 = true;
26018
26558
  while (index < arguments.length)
26019
26559
  params[offset++] = arguments[index++];
26020
26560
  return new Promise(function executor(resolve11, reject2) {
26021
26561
  params[offset] = function callback(err) {
26022
- if (pending) {
26023
- pending = false;
26562
+ if (pending2) {
26563
+ pending2 = false;
26024
26564
  if (err)
26025
26565
  reject2(err);
26026
26566
  else {
@@ -26034,8 +26574,8 @@ var require_aspromise = __commonJS({
26034
26574
  try {
26035
26575
  fn.apply(ctx || null, params);
26036
26576
  } catch (err) {
26037
- if (pending) {
26038
- pending = false;
26577
+ if (pending2) {
26578
+ pending2 = false;
26039
26579
  reject2(err);
26040
26580
  }
26041
26581
  }
@@ -32822,7 +33362,7 @@ __export(processPriority_exports, {
32822
33362
  bumpProcessPriorityBestEffort: () => bumpProcessPriorityBestEffort,
32823
33363
  shouldBumpProcessPriority: () => shouldBumpProcessPriority
32824
33364
  });
32825
- import { spawnSync as spawnSync2 } from "child_process";
33365
+ import { spawnSync } from "child_process";
32826
33366
  function shouldBumpProcessPriority(platform2) {
32827
33367
  return platform2 === "darwin";
32828
33368
  }
@@ -32834,7 +33374,7 @@ function bumpProcessPriorityBestEffort() {
32834
33374
  const args = buildTaskpolicyArgs(process.pid);
32835
33375
  let result;
32836
33376
  try {
32837
- result = spawnSync2("taskpolicy", args, { encoding: "utf8", timeout: 2e3 });
33377
+ result = spawnSync("taskpolicy", args, { encoding: "utf8", timeout: 2e3 });
32838
33378
  } catch (err) {
32839
33379
  console.warn(`[priority] taskpolicy spawn failed: ${err instanceof Error ? err.message : String(err)}`);
32840
33380
  return false;
@@ -32861,7 +33401,7 @@ var init_processPriority = __esm({
32861
33401
 
32862
33402
  // src/cli.ts
32863
33403
  init_backup();
32864
- import { execFile as execFile6 } from "child_process";
33404
+ import { execFile as execFile7 } from "child_process";
32865
33405
  import { existsSync as existsSync30, mkdirSync as mkdirSync18, realpathSync } from "fs";
32866
33406
  import { tmpdir as tmpdir3 } from "os";
32867
33407
  import { join as join36, resolve as resolve10 } from "path";
@@ -33365,7 +33905,7 @@ init_lifecycle2();
33365
33905
  init_mime_types();
33366
33906
  init_projects();
33367
33907
  import { serve } from "@hono/node-server";
33368
- import { execFile as execFile5 } from "child_process";
33908
+ import { execFile as execFile6 } from "child_process";
33369
33909
  import { existsSync as existsSync28, readFileSync as readFileSync22 } from "fs";
33370
33910
  import { Hono as Hono19 } from "hono";
33371
33911
  import { basename as basename5, dirname as dirname8, join as join34 } from "path";
@@ -33563,6 +34103,7 @@ function listAliveEntries(dataDir, isPidAlive3 = defaultIsPidAlive) {
33563
34103
  init_claude_hooks();
33564
34104
  init_commandLog();
33565
34105
  init_settings();
34106
+ init_ticketWorkIntervals();
33566
34107
  init_freezeLogger();
33567
34108
  init_file_settings();
33568
34109
  init_global_config();
@@ -33834,7 +34375,10 @@ channelRoutes.post("/channel/permission/dismiss", async (c) => {
33834
34375
  });
33835
34376
  channelRoutes.post("/channel/done", (_c) => {
33836
34377
  const secret = _c.get("projectSecret");
33837
- if (secret) channelDoneFlags.set(secret, true);
34378
+ if (secret) {
34379
+ channelDoneFlags.set(secret, true);
34380
+ void closeOpenTicketIntervalsForProject(secret);
34381
+ }
33838
34382
  addLogEntry("done", "incoming", "Claude finished", "").catch(() => {
33839
34383
  });
33840
34384
  notifyChange();
@@ -33932,6 +34476,7 @@ commandLogRoutes.get("/command-log/count", async (c) => {
33932
34476
  });
33933
34477
 
33934
34478
  // src/routes/dashboard.ts
34479
+ init_activeProjects();
33935
34480
  init_queries();
33936
34481
  init_stats();
33937
34482
  init_gitignore();
@@ -33947,6 +34492,7 @@ import { homedir as homedir6, tmpdir } from "os";
33947
34492
  import { join as join29, relative as relative2, resolve as resolve8 } from "path";
33948
34493
  var dashboardRoutes = new Hono5();
33949
34494
  dashboardRoutes.get("/poll", async (c) => {
34495
+ markProjectActive(c.get("dataDir"));
33950
34496
  const clientVersion = Math.max(0, parseInt(c.req.query("version") ?? "0", 10) || 0);
33951
34497
  const changeVersion2 = getChangeVersion();
33952
34498
  if (changeVersion2 > clientVersion) {
@@ -35014,12 +35560,12 @@ import { Hono as Hono13 } from "hono";
35014
35560
  // src/db/repair.ts
35015
35561
  init_backup();
35016
35562
  init_pglite();
35017
- import { execFile as execFile4 } from "child_process";
35563
+ import { execFile as execFile5 } from "child_process";
35018
35564
  import { cpSync as cpSync2, existsSync as existsSync26, mkdirSync as mkdirSync16, readFileSync as readFileSync21, rmSync as rmSync10, writeFileSync as writeFileSync18 } from "fs";
35019
35565
  import { tmpdir as tmpdir2 } from "os";
35020
35566
  import { join as join32 } from "path";
35021
- import { promisify as promisify2 } from "util";
35022
- var execFileP = promisify2(execFile4);
35567
+ import { promisify as promisify3 } from "util";
35568
+ var execFileP = promisify3(execFile5);
35023
35569
  async function findWorkingBackup(dataDir) {
35024
35570
  const backups = listBackups(dataDir);
35025
35571
  for (const backup of backups) {
@@ -35210,6 +35756,7 @@ dbRoutes.post("/repair/run-pg-resetwal", async (c) => {
35210
35756
  });
35211
35757
 
35212
35758
  // src/routes/git.ts
35759
+ init_activeProjects();
35213
35760
  import { Hono as Hono14 } from "hono";
35214
35761
  import { join as join33 } from "path";
35215
35762
 
@@ -35271,18 +35818,19 @@ init_watcher();
35271
35818
  init_gitignore();
35272
35819
  init_open_in_file_manager();
35273
35820
  var gitRoutes = new Hono14();
35274
- gitRoutes.get("/git/status", (c) => {
35821
+ gitRoutes.get("/git/status", async (c) => {
35275
35822
  const dataDir = c.get("dataDir");
35823
+ markProjectActive(dataDir);
35276
35824
  const projectRoot3 = projectRootFromDataDir(dataDir);
35277
35825
  const settings = readFileSettings(dataDir);
35278
35826
  if (settings.git_tracking_enabled === false) {
35279
35827
  return c.json(null);
35280
35828
  }
35281
35829
  ensureGitWatcher(projectRoot3);
35282
- const status = getCachedGitStatus(projectRoot3);
35830
+ const status = await getCachedGitStatus(projectRoot3);
35283
35831
  if (status === null) return c.json(null);
35284
35832
  if (c.req.query("files") === "true") {
35285
- const files = getGitStatusFiles(projectRoot3);
35833
+ const files = await getGitStatusFiles(projectRoot3);
35286
35834
  return c.json({ ...status, files });
35287
35835
  }
35288
35836
  return c.json(status);
@@ -35290,14 +35838,14 @@ gitRoutes.get("/git/status", (c) => {
35290
35838
  function projectRootFromDataDir(dataDir) {
35291
35839
  return dataDir.replace(/[\\/]\.hotsheet\/?$/, "");
35292
35840
  }
35293
- gitRoutes.post("/git/fetch", (c) => {
35841
+ gitRoutes.post("/git/fetch", async (c) => {
35294
35842
  const dataDir = c.get("dataDir");
35295
35843
  const projectRoot3 = projectRootFromDataDir(dataDir);
35296
35844
  const settings = readFileSettings(dataDir);
35297
35845
  if (settings.git_tracking_enabled === false) {
35298
35846
  return c.json({ ok: false, lastFetchedAt: null, error: "git tracking disabled in settings" });
35299
35847
  }
35300
- const result = runGitFetch(projectRoot3);
35848
+ const result = await runGitFetch(projectRoot3);
35301
35849
  if (result.ok) dropGitStatusCache(projectRoot3);
35302
35850
  return c.json(result);
35303
35851
  });
@@ -38246,54 +38794,62 @@ async function getPromptTimeline(promptId) {
38246
38794
  spans
38247
38795
  };
38248
38796
  }
38249
- async function getPerTicketRollup(ticketNumber) {
38797
+ async function getPerTicketRollup(ticketNumber, secret) {
38250
38798
  const db = await getTelemetryDb();
38251
38799
  const marker = `hotsheet:ticket=${ticketNumber}`;
38252
- const tagged = await db.query(
38253
- `SELECT DISTINCT prompt_id FROM otel_events
38254
- WHERE ${eventNameMatchSql("event_name", "user_prompt")}
38255
- AND prompt_id IS NOT NULL
38256
- AND body_json::text LIKE $1`,
38257
- [`%${marker}%`]
38258
- );
38259
- if (tagged.rows.length === 0) {
38260
- return { ticketNumber, promptCount: 0, totalCost: 0, totalTokens: 0, totalDurationSeconds: 0 };
38261
- }
38262
- const promptIds = tagged.rows.map((r) => r.prompt_id);
38263
- const sumsResult = await db.query(
38264
- `SELECT
38265
- SUM(COALESCE(
38266
- (attributes_json->>'cost')::numeric,
38267
- (attributes_json->>'cost_usd')::numeric,
38268
- 0
38269
- )) AS total_cost,
38270
- SUM(COALESCE(
38271
- (attributes_json->>'tokens')::numeric,
38272
- (attributes_json->>'total_tokens')::numeric,
38273
- (attributes_json->>'input_tokens')::numeric + (attributes_json->>'output_tokens')::numeric,
38274
- 0
38275
- )) AS total_tokens
38276
- FROM otel_events
38277
- WHERE ${eventNameMatchSql("event_name", "api_request")}
38278
- AND prompt_id = ANY($1::text[])`,
38279
- [promptIds]
38280
- );
38281
- const durationsResult = await db.query(
38282
- `SELECT SUM(EXTRACT(EPOCH FROM (max_ts - min_ts))) AS total_seconds
38283
- FROM (
38284
- SELECT MIN(ts) AS min_ts, MAX(ts) AS max_ts
38285
- FROM otel_events
38286
- WHERE prompt_id = ANY($1::text[])
38287
- GROUP BY prompt_id
38288
- ) AS per_prompt`,
38289
- [promptIds]
38800
+ const secretParam = secret !== void 0 && secret !== "" ? secret : null;
38801
+ const result = await db.query(
38802
+ `WITH marker_prompts AS (
38803
+ SELECT DISTINCT prompt_id FROM otel_events
38804
+ WHERE ${eventNameMatchSql("event_name", "user_prompt")}
38805
+ AND prompt_id IS NOT NULL
38806
+ AND body_json::text LIKE $1
38807
+ ),
38808
+ matched AS (
38809
+ SELECT
38810
+ e.prompt_id,
38811
+ e.ts,
38812
+ COALESCE(
38813
+ (e.attributes_json->>'cost')::numeric,
38814
+ (e.attributes_json->>'cost_usd')::numeric,
38815
+ 0
38816
+ ) AS cost,
38817
+ COALESCE(
38818
+ (e.attributes_json->>'tokens')::numeric,
38819
+ (e.attributes_json->>'total_tokens')::numeric,
38820
+ (e.attributes_json->>'input_tokens')::numeric + (e.attributes_json->>'output_tokens')::numeric,
38821
+ 0
38822
+ ) AS tokens
38823
+ FROM otel_events e
38824
+ WHERE ${eventNameMatchSql("e.event_name", "api_request")}
38825
+ AND (
38826
+ e.prompt_id IN (SELECT prompt_id FROM marker_prompts)
38827
+ OR (
38828
+ $2::text IS NOT NULL AND e.project_secret = $2 AND EXISTS (
38829
+ SELECT 1 FROM ticket_work_intervals i
38830
+ WHERE i.project_secret = $2 AND i.ticket_number = $3
38831
+ AND e.ts >= i.started_at AND e.ts <= COALESCE(i.ended_at, NOW())
38832
+ )
38833
+ )
38834
+ )
38835
+ )
38836
+ SELECT
38837
+ (SELECT COUNT(DISTINCT prompt_id) FROM matched) AS prompt_count,
38838
+ (SELECT COALESCE(SUM(cost), 0) FROM matched) AS total_cost,
38839
+ (SELECT COALESCE(SUM(tokens), 0) FROM matched) AS total_tokens,
38840
+ (SELECT COALESCE(SUM(dur), 0) FROM (
38841
+ SELECT EXTRACT(EPOCH FROM (MAX(ts) - MIN(ts))) AS dur
38842
+ FROM matched WHERE prompt_id IS NOT NULL GROUP BY prompt_id
38843
+ ) per_prompt) AS total_seconds`,
38844
+ [`%${marker}%`, secretParam, ticketNumber]
38290
38845
  );
38846
+ const row = result.rows[0];
38291
38847
  return {
38292
38848
  ticketNumber,
38293
- promptCount: promptIds.length,
38294
- totalCost: Number(sumsResult.rows[0]?.total_cost ?? 0),
38295
- totalTokens: Number(sumsResult.rows[0]?.total_tokens ?? 0),
38296
- totalDurationSeconds: Number(durationsResult.rows[0]?.total_seconds ?? 0)
38849
+ promptCount: Number(row.prompt_count ?? 0),
38850
+ totalCost: Number(row.total_cost ?? 0),
38851
+ totalTokens: Number(row.total_tokens ?? 0),
38852
+ totalDurationSeconds: Number(row.total_seconds ?? 0)
38297
38853
  };
38298
38854
  }
38299
38855
  async function getDashboardPayload(window2, timezone = "UTC", allowedSecrets = null, now = /* @__PURE__ */ new Date()) {
@@ -38375,7 +38931,8 @@ telemetryRoutes.get("/telemetry/prompt/:id", async (c) => {
38375
38931
  });
38376
38932
  telemetryRoutes.get("/telemetry/ticket/:number", async (c) => {
38377
38933
  const ticketNumber = c.req.param("number");
38378
- const rollup = await getPerTicketRollup(ticketNumber);
38934
+ const secret = c.get("projectSecret");
38935
+ const rollup = await getPerTicketRollup(ticketNumber, secret);
38379
38936
  return c.json(rollup);
38380
38937
  });
38381
38938
  telemetryRoutes.get("/telemetry/enabled-anywhere", (c) => {
@@ -38717,7 +39274,7 @@ async function startServer(port, dataDir, options) {
38717
39274
  `);
38718
39275
  if (options?.noOpen !== true) {
38719
39276
  const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
38720
- execFile5(openCmd, [url2]);
39277
+ execFile6(openCmd, [url2]);
38721
39278
  }
38722
39279
  return actualPort;
38723
39280
  }
@@ -38895,8 +39452,12 @@ async function startAndConfigure(port, dataDir, strictPort) {
38895
39452
  const secret = ensureSecret(dataDir, actualPort);
38896
39453
  const { bumpProcessPriorityBestEffort: bumpProcessPriorityBestEffort2 } = await Promise.resolve().then(() => (init_processPriority(), processPriority_exports));
38897
39454
  bumpProcessPriorityBestEffort2();
38898
- const { startServerEventLoopHeartbeat: startServerEventLoopHeartbeat2 } = await Promise.resolve().then(() => (init_freezeLogger(), freezeLogger_exports));
39455
+ const { startServerEventLoopHeartbeat: startServerEventLoopHeartbeat2, onServerWake: onServerWake2 } = await Promise.resolve().then(() => (init_freezeLogger(), freezeLogger_exports));
38899
39456
  startServerEventLoopHeartbeat2(dataDir);
39457
+ const { getBackgroundScheduler: getBackgroundScheduler2 } = await Promise.resolve().then(() => (init_backgroundScheduler(), backgroundScheduler_exports));
39458
+ onServerWake2(() => {
39459
+ getBackgroundScheduler2().noteWake();
39460
+ });
38900
39461
  initMarkdownSync(dataDir, actualPort);
38901
39462
  scheduleAllSync(dataDir);
38902
39463
  const { runWithDataDir: runWith } = await Promise.resolve().then(() => (init_connection(), connection_exports));
@@ -38927,8 +39488,11 @@ async function startAndConfigure(port, dataDir, strictPort) {
38927
39488
  }
38928
39489
  async function postStartup(dataDir, actualPort, demo, noOpen) {
38929
39490
  if (demo === null) {
39491
+ startupMark("post-startup: init backup scheduler");
38930
39492
  initBackupScheduler(dataDir);
39493
+ startupMark("post-startup: init snapshot scheduler");
38931
39494
  initSnapshotScheduler(dataDir);
39495
+ startupMark("post-startup: add to project list");
38932
39496
  addToProjectList(dataDir);
38933
39497
  startupMark("post-startup: restoring previous projects");
38934
39498
  await restorePreviousProjects(dataDir, actualPort);
@@ -38945,28 +39509,47 @@ async function postStartup(dataDir, actualPort, demo, noOpen) {
38945
39509
  if (!noOpen) {
38946
39510
  const url2 = `http://localhost:${actualPort}`;
38947
39511
  const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
38948
- execFile6(openCmd, [url2]);
39512
+ execFile7(openCmd, [url2]);
38949
39513
  }
38950
39514
  }
38951
39515
  async function restorePreviousProjects(dataDir, actualPort) {
38952
39516
  const previousProjects = readProjectList();
38953
39517
  const absDataDir = resolve10(dataDir);
38954
- const validProjects = [];
38955
39518
  const { eagerSpawnTerminals: eagerSpawnTerminals2 } = await Promise.resolve().then(() => (init_eagerSpawn(), eagerSpawn_exports));
38956
- for (const prevDir of previousProjects) {
38957
- if (prevDir === absDataDir) {
38958
- validProjects.push(prevDir);
38959
- continue;
38960
- }
38961
- if (!existsSync30(prevDir)) continue;
38962
- try {
38963
- const ctx = await registerProject(prevDir, actualPort);
38964
- validProjects.push(prevDir);
38965
- eagerSpawnTerminals2(ctx.secret, prevDir);
38966
- } catch (e) {
38967
- console.warn(`[startup] Failed to restore project ${prevDir}: ${getErrorMessage(e)}`);
38968
- }
38969
- }
39519
+ const { getBackgroundScheduler: getBackgroundScheduler2, PRIORITY: PRIORITY2 } = await Promise.resolve().then(() => (init_backgroundScheduler(), backgroundScheduler_exports));
39520
+ const { notifyChange: notifyChange2 } = await Promise.resolve().then(() => (init_notify(), notify_exports));
39521
+ const scheduler = getBackgroundScheduler2();
39522
+ const registeredOk = /* @__PURE__ */ new Set();
39523
+ await Promise.all(previousProjects.map((prevDir) => {
39524
+ if (prevDir === absDataDir) return Promise.resolve();
39525
+ if (!existsSync30(prevDir)) return Promise.resolve();
39526
+ return scheduler.submit({
39527
+ key: `project-restore:${prevDir}`,
39528
+ projectKey: prevDir,
39529
+ priority: PRIORITY2.PROJECT_RESTORE,
39530
+ // deferUnderLag MUST be false: restore is user-visible work that has to
39531
+ // make progress. Deferring it under lag starves it — and each restore job
39532
+ // itself spikes lag (PGLite WASM init), so a true `deferUnderLag` makes
39533
+ // the whole restore crawl (observed: 403s for 8 projects).
39534
+ deferUnderLag: false,
39535
+ run: async () => {
39536
+ const t0 = Date.now();
39537
+ startupLog(`[restore-timing] START ${prevDir}`);
39538
+ try {
39539
+ const ctx = await registerProject(prevDir, actualPort);
39540
+ registeredOk.add(prevDir);
39541
+ eagerSpawnTerminals2(ctx.secret, prevDir);
39542
+ notifyChange2();
39543
+ startupLog(`[restore-timing] ${prevDir} registered in ${String(Date.now() - t0)}ms`);
39544
+ } catch (e) {
39545
+ console.warn(`[startup] Failed to restore project ${prevDir}: ${getErrorMessage(e)}`);
39546
+ }
39547
+ }
39548
+ });
39549
+ }));
39550
+ const validProjects = previousProjects.filter(
39551
+ (prevDir) => prevDir === absDataDir || registeredOk.has(prevDir)
39552
+ );
38970
39553
  if (validProjects.length !== previousProjects.length) {
38971
39554
  const { reorderProjectList: reorderProjectList2 } = await Promise.resolve().then(() => (init_project_list(), project_list_exports));
38972
39555
  reorderProjectList2(validProjects);
@@ -38975,7 +39558,6 @@ async function restorePreviousProjects(dataDir, actualPort) {
38975
39558
  const { getProjectByDataDir: getByDir, reorderProjects: reorder } = await Promise.resolve().then(() => (init_projects(), projects_exports));
38976
39559
  const secrets = validProjects.map((dir) => getByDir(dir)?.secret).filter((s) => s !== void 0);
38977
39560
  if (secrets.length > 1) reorder(secrets);
38978
- const { notifyChange: notifyChange2 } = await Promise.resolve().then(() => (init_notify(), notify_exports));
38979
39561
  notifyChange2();
38980
39562
  }
38981
39563
  }
@@ -39208,7 +39790,8 @@ if (isEntryPoint) {
39208
39790
  export {
39209
39791
  computeIsEntryPoint,
39210
39792
  createSignalHandler,
39211
- migrateGlobalConfig
39793
+ migrateGlobalConfig,
39794
+ restorePreviousProjects
39212
39795
  };
39213
39796
  /*! Bundled license information:
39214
39797