hotsheet 0.18.1-beta.1 → 0.19.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +839 -187
  2. package/package.json +2 -2
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(),
@@ -15681,10 +15793,16 @@ __export(fsyncWrap_exports, {
15681
15793
  fsyncDbDir: () => fsyncDbDir,
15682
15794
  fsyncDbDirAsync: () => fsyncDbDirAsync,
15683
15795
  fsyncDir: () => fsyncDir,
15684
- fsyncDirAsync: () => fsyncDirAsync
15796
+ fsyncDirAsync: () => fsyncDirAsync,
15797
+ isUnsupportedFsyncError: () => isUnsupportedFsyncError
15685
15798
  });
15686
15799
  import { closeSync, existsSync as existsSync2, fsyncSync, openSync, promises as fsp2, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
15687
15800
  import { join as join2 } from "path";
15801
+ function isUnsupportedFsyncError(err, platform2 = process.platform) {
15802
+ if (platform2 !== "win32") return false;
15803
+ const code = err?.code;
15804
+ return code !== void 0 && WIN32_UNFLUSHABLE_CODES.has(code);
15805
+ }
15688
15806
  function fsyncDir(path, fsyncFn = fsyncSync) {
15689
15807
  if (!existsSync2(path)) return { filesFlushed: 0, errors: 0 };
15690
15808
  const counters = { filesFlushed: 0, errors: 0 };
@@ -15719,8 +15837,10 @@ function walkAndFsync(dir, counters, fsyncFn) {
15719
15837
  fsyncFn(fd);
15720
15838
  counters.filesFlushed++;
15721
15839
  } catch (err) {
15722
- console.error(`[fsyncWrap] fsync ${p} failed:`, err);
15723
- counters.errors++;
15840
+ if (!isUnsupportedFsyncError(err)) {
15841
+ console.error(`[fsyncWrap] fsync ${p} failed:`, err);
15842
+ counters.errors++;
15843
+ }
15724
15844
  } finally {
15725
15845
  if (fd !== null) {
15726
15846
  try {
@@ -15769,8 +15889,10 @@ async function walkAndFsyncAsync(dir, counters, fsyncFn) {
15769
15889
  await fsyncFn(handle);
15770
15890
  counters.filesFlushed++;
15771
15891
  } catch (err) {
15772
- console.error(`[fsyncWrap] fsync ${p} failed:`, err);
15773
- counters.errors++;
15892
+ if (!isUnsupportedFsyncError(err)) {
15893
+ console.error(`[fsyncWrap] fsync ${p} failed:`, err);
15894
+ counters.errors++;
15895
+ }
15774
15896
  } finally {
15775
15897
  if (handle !== null) {
15776
15898
  try {
@@ -15785,10 +15907,11 @@ async function walkAndFsyncAsync(dir, counters, fsyncFn) {
15785
15907
  async function fsyncDbDirAsync(dataDir, fsyncFn = defaultAsyncFsyncFn) {
15786
15908
  return fsyncDirAsync(join2(dataDir, "db"), fsyncFn);
15787
15909
  }
15788
- var defaultAsyncFsyncFn;
15910
+ var WIN32_UNFLUSHABLE_CODES, defaultAsyncFsyncFn;
15789
15911
  var init_fsyncWrap = __esm({
15790
15912
  "src/db/fsyncWrap.ts"() {
15791
15913
  "use strict";
15914
+ WIN32_UNFLUSHABLE_CODES = /* @__PURE__ */ new Set(["EPERM", "EACCES", "ENOTSUP", "EINVAL"]);
15792
15915
  defaultAsyncFsyncFn = (handle) => handle.sync();
15793
15916
  }
15794
15917
  });
@@ -15938,10 +16061,14 @@ __export(freezeLogger_exports, {
15938
16061
  FREEZE_LOG_MAX_BYTES: () => FREEZE_LOG_MAX_BYTES,
15939
16062
  FREEZE_LOG_TARGET_BYTES_AFTER_TRUNCATE: () => FREEZE_LOG_TARGET_BYTES_AFTER_TRUNCATE,
15940
16063
  LONG_TASK_THRESHOLD_MS: () => LONG_TASK_THRESHOLD_MS,
16064
+ WAKE_GAP_THRESHOLD_MS: () => WAKE_GAP_THRESHOLD_MS,
15941
16065
  _resetForTesting: () => _resetForTesting,
16066
+ _simulateHeartbeatGapForTesting: () => _simulateHeartbeatGapForTesting,
15942
16067
  appendFreezeLog: () => appendFreezeLog,
16068
+ getRecentEventLoopLagMs: () => getRecentEventLoopLagMs,
15943
16069
  instrumentAsync: () => instrumentAsync,
15944
16070
  instrumentSync: () => instrumentSync,
16071
+ onServerWake: () => onServerWake,
15945
16072
  startServerEventLoopHeartbeat: () => startServerEventLoopHeartbeat,
15946
16073
  stopServerEventLoopHeartbeat: () => stopServerEventLoopHeartbeat
15947
16074
  });
@@ -16000,6 +16127,44 @@ async function rotateIfNeeded(path, pendingBytes) {
16000
16127
  console.warn("[hotsheet freeze.log] rotate writeFile failed:", err instanceof Error ? err.message : String(err));
16001
16128
  }
16002
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
+ }
16003
16168
  function startServerEventLoopHeartbeat(dataDir) {
16004
16169
  if (heartbeatTimer !== null) return;
16005
16170
  heartbeatDataDir = dataDir;
@@ -16008,18 +16173,13 @@ function startServerEventLoopHeartbeat(dataDir) {
16008
16173
  const now = process.hrtime.bigint();
16009
16174
  const elapsedMs = Number(now - lastHeartbeatNs) / 1e6;
16010
16175
  lastHeartbeatNs = now;
16011
- const blockMs = elapsedMs - HEARTBEAT_INTERVAL_MS;
16012
- if (blockMs >= LONG_TASK_THRESHOLD_MS && heartbeatDataDir !== null) {
16013
- void appendFreezeLog(heartbeatDataDir, {
16014
- ts: (/* @__PURE__ */ new Date()).toISOString(),
16015
- source: "server-heartbeat",
16016
- durationMs: Math.round(blockMs),
16017
- context: "event-loop blocked"
16018
- });
16019
- }
16176
+ handleHeartbeatGap(elapsedMs - HEARTBEAT_INTERVAL_MS);
16020
16177
  }, HEARTBEAT_INTERVAL_MS);
16021
16178
  heartbeatTimer.unref();
16022
16179
  }
16180
+ function getRecentEventLoopLagMs() {
16181
+ return lastEventLoopLagMs;
16182
+ }
16023
16183
  function stopServerEventLoopHeartbeat() {
16024
16184
  if (heartbeatTimer !== null) {
16025
16185
  clearInterval(heartbeatTimer);
@@ -16066,9 +16226,11 @@ function _resetForTesting() {
16066
16226
  }
16067
16227
  heartbeatDataDir = null;
16068
16228
  lastHeartbeatNs = 0n;
16229
+ lastEventLoopLagMs = 0;
16230
+ wakeListeners.clear();
16069
16231
  appendQueue.clear();
16070
16232
  }
16071
- 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;
16072
16234
  var init_freezeLogger = __esm({
16073
16235
  "src/diagnostics/freezeLogger.ts"() {
16074
16236
  "use strict";
@@ -16077,10 +16239,205 @@ var init_freezeLogger = __esm({
16077
16239
  FREEZE_LOG_MAX_BYTES = 1048576;
16078
16240
  FREEZE_LOG_TARGET_BYTES_AFTER_TRUNCATE = 524288;
16079
16241
  HEARTBEAT_INTERVAL_MS = 50;
16242
+ WAKE_GAP_THRESHOLD_MS = 1e4;
16080
16243
  appendQueue = /* @__PURE__ */ new Map();
16081
16244
  heartbeatTimer = null;
16082
16245
  lastHeartbeatNs = 0n;
16246
+ lastEventLoopLagMs = 0;
16083
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
+ GIT_STATUS: 10,
16435
+ MARKDOWN_SYNC: 20,
16436
+ SNAPSHOT: 30,
16437
+ BACKUP: 40,
16438
+ GC: 50
16439
+ };
16440
+ defaultScheduler = null;
16084
16441
  }
16085
16442
  });
16086
16443
 
@@ -16137,7 +16494,7 @@ function initSnapshotScheduler(dataDir) {
16137
16494
  const state = getOrCreateState(dataDir);
16138
16495
  if (state.safetyTimer !== null) return;
16139
16496
  state.safetyTimer = setInterval(() => {
16140
- if (state.dirty && !state.inProgress) void writeSnapshotNow(dataDir);
16497
+ if (state.dirty && !state.inProgress) void submitSnapshotJob(dataDir);
16141
16498
  }, numericSetting(dataDir, "db_snapshot_safety_interval_ms", DEFAULT_SAFETY_INTERVAL_MS));
16142
16499
  state.safetyTimer.unref();
16143
16500
  }
@@ -16150,9 +16507,20 @@ function scheduleSnapshot(dataDir) {
16150
16507
  if (state.debounceTimer) clearTimeout(state.debounceTimer);
16151
16508
  state.debounceTimer = setTimeout(() => {
16152
16509
  state.debounceTimer = null;
16153
- void writeSnapshotNow(dir);
16510
+ void submitSnapshotJob(dir);
16154
16511
  }, numericSetting(dir, "db_snapshot_debounce_ms", DEFAULT_DEBOUNCE_MS));
16155
16512
  }
16513
+ function submitSnapshotJob(dataDir) {
16514
+ return getBackgroundScheduler().submit({
16515
+ key: `snapshot:${dataDir}`,
16516
+ priority: PRIORITY.SNAPSHOT,
16517
+ projectKey: dataDir,
16518
+ deferUnderLag: false,
16519
+ run: async () => {
16520
+ await writeSnapshotNow(dataDir);
16521
+ }
16522
+ });
16523
+ }
16156
16524
  async function writeSnapshotNow(dataDir) {
16157
16525
  if (!isSnapshotProtectionEnabled(dataDir)) return null;
16158
16526
  if (!existsSync4(join5(dataDir, "db"))) return null;
@@ -16194,7 +16562,7 @@ async function snapshotAllForShutdown() {
16194
16562
  for (const dir of dirs) {
16195
16563
  if (!isSnapshotProtectionEnabled(dir)) continue;
16196
16564
  try {
16197
- await writeSnapshotNow(dir);
16565
+ await submitSnapshotJob(dir);
16198
16566
  } catch (err) {
16199
16567
  console.error(`[snapshot] shutdown snapshot failed for ${dir}:`, err);
16200
16568
  }
@@ -16246,6 +16614,7 @@ var init_snapshot = __esm({
16246
16614
  "use strict";
16247
16615
  init_freezeLogger();
16248
16616
  init_file_settings();
16617
+ init_backgroundScheduler();
16249
16618
  init_connection();
16250
16619
  DEFAULT_DEBOUNCE_MS = 2e3;
16251
16620
  DEFAULT_SAFETY_INTERVAL_MS = 12e4;
@@ -16361,13 +16730,40 @@ function clearRecoveryMarker(dataDir) {
16361
16730
  } catch {
16362
16731
  }
16363
16732
  }
16733
+ function pendingRecoveryPath(dataDir) {
16734
+ return join7(dataDir, PENDING_RECOVERY_FILENAME);
16735
+ }
16736
+ function readPendingRecovery(dataDir) {
16737
+ const path = pendingRecoveryPath(dataDir);
16738
+ if (!existsSync6(path)) return null;
16739
+ try {
16740
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
16741
+ const result = external_exports.object({ attempts: external_exports.number() }).loose().safeParse(parsed);
16742
+ return { attempts: result.success ? result.data.attempts : 1 };
16743
+ } catch {
16744
+ return { attempts: 1 };
16745
+ }
16746
+ }
16747
+ function writePendingRecovery(dataDir, attempts) {
16748
+ try {
16749
+ writeFileSync2(pendingRecoveryPath(dataDir), JSON.stringify({ attempts, requestedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
16750
+ } catch (writeErr) {
16751
+ console.error("Could not write pending-recovery marker:", getErrorMessage(writeErr));
16752
+ }
16753
+ }
16754
+ function clearPendingRecovery(dataDir) {
16755
+ try {
16756
+ rmSync2(pendingRecoveryPath(dataDir), { force: true });
16757
+ } catch {
16758
+ }
16759
+ }
16364
16760
  function runWithDataDir(dataDir, fn) {
16365
16761
  return requestDataDir.run(dataDir, fn);
16366
16762
  }
16367
16763
  function getDataDir() {
16368
16764
  const contextDataDir = requestDataDir.getStore();
16369
16765
  if (contextDataDir !== void 0) return contextDataDir;
16370
- if (defaultDbPath !== null) return defaultDbPath.replace(/\/db$/, "");
16766
+ if (defaultDbPath !== null) return defaultDbPath.replace(/[\\/]db$/, "");
16371
16767
  throw new Error("Data directory not available. Call setDataDir() or use runWithDataDir().");
16372
16768
  }
16373
16769
  function setDataDir(dataDir) {
@@ -16439,6 +16835,8 @@ async function getDbForDir(dataDir) {
16439
16835
  async function getDbByPath(dbPath) {
16440
16836
  const existing = databases.get(dbPath);
16441
16837
  if (existing) return existing;
16838
+ const recovered = await completeDeferredRecovery(dbPath);
16839
+ if (recovered !== null) return recovered;
16442
16840
  let db;
16443
16841
  try {
16444
16842
  db = await openAndCacheDb(dbPath);
@@ -16461,8 +16859,16 @@ async function getDbByPath(dbPath) {
16461
16859
  }
16462
16860
  async function openAndCacheDb(dbPath, loadDataDir) {
16463
16861
  const db = createPglite(dbPath, loadDataDir !== void 0 ? { loadDataDir } : {});
16464
- await db.waitReady;
16465
- await initSchema(db);
16862
+ try {
16863
+ await db.waitReady;
16864
+ await initSchema(db);
16865
+ } catch (err) {
16866
+ try {
16867
+ await db.close();
16868
+ } catch {
16869
+ }
16870
+ throw err;
16871
+ }
16466
16872
  databases.set(dbPath, db);
16467
16873
  return db;
16468
16874
  }
@@ -16506,6 +16912,57 @@ async function tryRestoreFromSources(dbPath, dataDir) {
16506
16912
  }
16507
16913
  return null;
16508
16914
  }
16915
+ async function renameDirWithRetry(from, to) {
16916
+ const maxAttempts = 5;
16917
+ for (let attempt = 1; ; attempt++) {
16918
+ try {
16919
+ renameSync(from, to);
16920
+ return;
16921
+ } catch (renameErr) {
16922
+ const code = renameErr.code;
16923
+ const retryable = code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "ENOTEMPTY";
16924
+ if (!retryable || attempt >= maxAttempts) throw renameErr;
16925
+ await new Promise((resolve11) => setTimeout(resolve11, 100 * attempt));
16926
+ }
16927
+ }
16928
+ }
16929
+ async function completeDeferredRecovery(dbPath) {
16930
+ const dataDir = dbPath.replace(/[\\/]db$/, "");
16931
+ const pending2 = readPendingRecovery(dataDir);
16932
+ if (pending2 === null) return null;
16933
+ if (pending2.attempts > MAX_DEFERRED_RECOVERY_ATTEMPTS) {
16934
+ console.error(`[db] deferred recovery gave up after ${String(pending2.attempts)} attempts; leaving the corrupt cluster for manual rescue.`);
16935
+ clearPendingRecovery(dataDir);
16936
+ return null;
16937
+ }
16938
+ if (!existsSync6(dbPath)) {
16939
+ clearPendingRecovery(dataDir);
16940
+ return null;
16941
+ }
16942
+ console.error("[db] completing deferred recovery \u2014 a prior launch could not move the corrupt database aside in-process (Windows handle lock)\u2026");
16943
+ const corruptPath = `${dbPath}-corrupt-${Date.now()}`;
16944
+ try {
16945
+ await renameDirWithRetry(dbPath, corruptPath);
16946
+ } catch (renameErr) {
16947
+ writePendingRecovery(dataDir, pending2.attempts + 1);
16948
+ console.error(`[db] deferred recovery could not move db/ yet: ${getErrorMessage(renameErr)}`);
16949
+ return null;
16950
+ }
16951
+ const restored = await tryRestoreFromSources(dbPath, dataDir);
16952
+ writeRecoveryMarker(dataDir, {
16953
+ corruptPath,
16954
+ recoveredAt: (/* @__PURE__ */ new Date()).toISOString(),
16955
+ errorMessage: "Database was corrupt and could not be preserved in-process (Windows handle lock); recovered on the next restart.",
16956
+ ...restored !== null ? { restoredFrom: restored.label, restoredTicketCount: restored.ticketCount } : {}
16957
+ });
16958
+ clearPendingRecovery(dataDir);
16959
+ if (restored !== null) {
16960
+ console.error(`[db] deferred recovery restored from ${restored.label} (${String(restored.ticketCount)} tickets).`);
16961
+ return restored.db;
16962
+ }
16963
+ console.error("[db] deferred recovery: no snapshot/backup could be loaded; starting with a fresh empty database.");
16964
+ return null;
16965
+ }
16509
16966
  async function recoverFromOpenFailure(dbPath, err, forceRecover) {
16510
16967
  const message = getErrorMessage(err);
16511
16968
  const stack = err instanceof Error ? err.stack : void 0;
@@ -16530,16 +16987,18 @@ async function recoverFromOpenFailure(dbPath, err, forceRecover) {
16530
16987
  }
16531
16988
  }
16532
16989
  }
16990
+ const dataDir = dbPath.replace(/[\\/]db$/, "");
16533
16991
  const corruptPath = `${dbPath}-corrupt-${Date.now()}`;
16534
16992
  console.error(`Database appears to be corrupt. Preserving as ${corruptPath} ...`);
16535
16993
  try {
16536
- renameSync(dbPath, corruptPath);
16994
+ await renameDirWithRetry(dbPath, corruptPath);
16537
16995
  } catch (renameErr) {
16538
16996
  const renameMessage = getErrorMessage(renameErr);
16539
- console.error(`Could not preserve corrupt database directory: ${renameMessage}. Aborting auto-recreate to avoid data loss.`);
16997
+ const prev = readPendingRecovery(dataDir);
16998
+ writePendingRecovery(dataDir, (prev?.attempts ?? 0) + 1);
16999
+ console.error(`Could not preserve corrupt database directory in-process: ${renameMessage}. Wrote a pending-recovery marker \u2014 Hot Sheet will auto-recover from the latest snapshot on the next restart.`);
16540
17000
  throw err;
16541
17001
  }
16542
- const dataDir = dbPath.replace(/[\\/]db$/, "");
16543
17002
  const restored = await tryRestoreFromSources(dbPath, dataDir);
16544
17003
  if (restored !== null) {
16545
17004
  writeRecoveryMarker(dataDir, {
@@ -16782,6 +17241,22 @@ async function initSchema(db) {
16782
17241
  CREATE INDEX IF NOT EXISTS idx_otel_spans_session_ts ON otel_spans(session_id, start_ts);
16783
17242
  CREATE INDEX IF NOT EXISTS idx_otel_spans_prompt ON otel_spans(prompt_id);
16784
17243
  CREATE INDEX IF NOT EXISTS idx_otel_spans_trace ON otel_spans(trace_id);
17244
+
17245
+ -- HS-8730 (per-ticket cost, time-window correlation) \u2014 records when each
17246
+ -- ticket was actively being worked (its status was 'started'), so the
17247
+ -- per-ticket rollup can attribute api_request cost by timestamp instead of
17248
+ -- only the channelUI prompt marker. Lives in the telemetry DB (this is the
17249
+ -- default/primary project's DB per getTelemetryDb) so the rollup join with
17250
+ -- otel_events is single-DB. Keyed by project_secret (matching otel_events).
17251
+ CREATE TABLE IF NOT EXISTS ticket_work_intervals (
17252
+ id SERIAL PRIMARY KEY,
17253
+ project_secret TEXT NOT NULL,
17254
+ ticket_number TEXT NOT NULL,
17255
+ started_at TIMESTAMPTZ NOT NULL,
17256
+ ended_at TIMESTAMPTZ
17257
+ );
17258
+ CREATE INDEX IF NOT EXISTS idx_twi_secret_ticket ON ticket_work_intervals(project_secret, ticket_number);
17259
+ CREATE INDEX IF NOT EXISTS idx_twi_open ON ticket_work_intervals(project_secret, ticket_number, ended_at);
16785
17260
  `);
16786
17261
  await migrateNoteIds(db);
16787
17262
  }
@@ -16808,15 +17283,17 @@ async function migrateNoteIds(db) {
16808
17283
  }
16809
17284
  }
16810
17285
  }
16811
- var SCHEMA_VERSION, RECOVERY_MARKER_FILENAME, databases, defaultDbPath, requestDataDir;
17286
+ var SCHEMA_VERSION, RECOVERY_MARKER_FILENAME, PENDING_RECOVERY_FILENAME, MAX_DEFERRED_RECOVERY_ATTEMPTS, databases, defaultDbPath, requestDataDir;
16812
17287
  var init_connection = __esm({
16813
17288
  "src/db/connection.ts"() {
16814
17289
  "use strict";
16815
17290
  init_zod();
16816
17291
  init_errorMessage();
16817
17292
  init_pglite();
16818
- SCHEMA_VERSION = 4;
17293
+ SCHEMA_VERSION = 5;
16819
17294
  RECOVERY_MARKER_FILENAME = ".db-recovery-marker.json";
17295
+ PENDING_RECOVERY_FILENAME = ".db-pending-recovery.json";
17296
+ MAX_DEFERRED_RECOVERY_ATTEMPTS = 3;
16820
17297
  databases = /* @__PURE__ */ new Map();
16821
17298
  defaultDbPath = null;
16822
17299
  requestDataDir = new AsyncLocalStorage();
@@ -16908,20 +17385,27 @@ function getOrCreateState2(dataDir) {
16908
17385
  }
16909
17386
  return state;
16910
17387
  }
16911
- async function withGlobalBackupLock(fn) {
16912
- while (activeBackup !== null) {
16913
- try {
16914
- await activeBackup;
16915
- } catch {
17388
+ function withGlobalBackupLock(fn) {
17389
+ let settle;
17390
+ let fail;
17391
+ const out = new Promise((resolve11, reject2) => {
17392
+ settle = resolve11;
17393
+ fail = reject2;
17394
+ });
17395
+ void getBackgroundScheduler().submit({
17396
+ key: `backup:${++backupLockSeq}`,
17397
+ priority: PRIORITY.BACKUP,
17398
+ exclusiveGroup: BACKUP_EXCLUSIVE_GROUP,
17399
+ deferUnderLag: false,
17400
+ run: async () => {
17401
+ try {
17402
+ settle(await fn());
17403
+ } catch (e) {
17404
+ fail(e);
17405
+ }
16916
17406
  }
16917
- }
16918
- const p = (async () => fn())();
16919
- activeBackup = p;
16920
- try {
16921
- return await p;
16922
- } finally {
16923
- if (activeBackup === p) activeBackup = null;
16924
- }
17407
+ });
17408
+ return out;
16925
17409
  }
16926
17410
  function backupsDir(dataDir) {
16927
17411
  return getBackupDir(dataDir);
@@ -17185,12 +17669,18 @@ function initBackupScheduler(dataDir) {
17185
17669
  });
17186
17670
  }, 3e4);
17187
17671
  state.attachmentGcInterval = setInterval(() => {
17188
- void instrumentAsync(dataDir, "attachmentBackup.orphanGc:daily", () => runAttachmentGc(backupsDir(dataDir))).then((stats) => {
17189
- if (stats.deleted > 0) {
17190
- console.log(`[attachmentBackup] GC: reclaimed ${stats.deleted} blob(s), ${(stats.bytesReclaimed / 1024 / 1024).toFixed(2)} MB`);
17191
- }
17192
- }).catch((err) => {
17193
- console.error("[attachmentBackup] GC daily run failed:", err);
17672
+ void getBackgroundScheduler().submit({
17673
+ key: `attachment-gc:${dataDir}`,
17674
+ priority: PRIORITY.GC,
17675
+ projectKey: dataDir,
17676
+ deferUnderLag: true,
17677
+ run: () => instrumentAsync(dataDir, "attachmentBackup.orphanGc:daily", () => runAttachmentGc(backupsDir(dataDir))).then((stats) => {
17678
+ if (stats.deleted > 0) {
17679
+ console.log(`[attachmentBackup] GC: reclaimed ${stats.deleted} blob(s), ${(stats.bytesReclaimed / 1024 / 1024).toFixed(2)} MB`);
17680
+ }
17681
+ }).catch((err) => {
17682
+ console.error("[attachmentBackup] GC daily run failed:", err);
17683
+ })
17194
17684
  });
17195
17685
  }, 24 * 60 * 60 * 1e3);
17196
17686
  }
@@ -17209,7 +17699,7 @@ async function triggerMissedBackups(dataDir) {
17209
17699
  await createBackup(dataDir, tier);
17210
17700
  }
17211
17701
  }
17212
- var TIERS, backupStates, activePreviews, activeBackup, VALID_TIERS;
17702
+ var TIERS, backupStates, activePreviews, backupLockSeq, BACKUP_EXCLUSIVE_GROUP, VALID_TIERS;
17213
17703
  var init_backup = __esm({
17214
17704
  "src/backup.ts"() {
17215
17705
  "use strict";
@@ -17220,6 +17710,7 @@ var init_backup = __esm({
17220
17710
  init_dbJsonExport();
17221
17711
  init_freezeLogger();
17222
17712
  init_file_settings();
17713
+ init_backgroundScheduler();
17223
17714
  TIERS = {
17224
17715
  "5min": { intervalMs: 5 * 60 * 1e3, maxAge: 60 * 60 * 1e3, maxCount: 12 },
17225
17716
  "hourly": { intervalMs: 60 * 60 * 1e3, maxAge: 12 * 60 * 60 * 1e3, maxCount: 12 },
@@ -17227,7 +17718,8 @@ var init_backup = __esm({
17227
17718
  };
17228
17719
  backupStates = /* @__PURE__ */ new Map();
17229
17720
  activePreviews = /* @__PURE__ */ new Map();
17230
- activeBackup = null;
17721
+ backupLockSeq = 0;
17722
+ BACKUP_EXCLUSIVE_GROUP = "backup";
17231
17723
  VALID_TIERS = /* @__PURE__ */ new Set(["5min", "hourly", "daily"]);
17232
17724
  }
17233
17725
  });
@@ -17802,6 +18294,47 @@ var init_ticketNumber = __esm({
17802
18294
  }
17803
18295
  });
17804
18296
 
18297
+ // src/db/ticketWorkIntervals.ts
18298
+ async function recordTicketWorkTransition(secret, ticketNumber, status) {
18299
+ if (secret === "" || ticketNumber === "") return;
18300
+ try {
18301
+ const db = await getTelemetryDb();
18302
+ await db.query(
18303
+ `UPDATE ticket_work_intervals SET ended_at = NOW()
18304
+ WHERE project_secret = $1 AND ticket_number = $2 AND ended_at IS NULL`,
18305
+ [secret, ticketNumber]
18306
+ );
18307
+ if (status === "started") {
18308
+ await db.query(
18309
+ `INSERT INTO ticket_work_intervals (project_secret, ticket_number, started_at)
18310
+ VALUES ($1, $2, NOW())`,
18311
+ [secret, ticketNumber]
18312
+ );
18313
+ }
18314
+ } catch (err) {
18315
+ console.warn("[ticketWorkIntervals] failed to record transition:", err instanceof Error ? err.message : String(err));
18316
+ }
18317
+ }
18318
+ async function closeOpenTicketIntervalsForProject(secret) {
18319
+ if (secret === "") return;
18320
+ try {
18321
+ const db = await getTelemetryDb();
18322
+ await db.query(
18323
+ `UPDATE ticket_work_intervals SET ended_at = NOW()
18324
+ WHERE project_secret = $1 AND ended_at IS NULL`,
18325
+ [secret]
18326
+ );
18327
+ } catch (err) {
18328
+ console.warn("[ticketWorkIntervals] failed to close open intervals:", err instanceof Error ? err.message : String(err));
18329
+ }
18330
+ }
18331
+ var init_ticketWorkIntervals = __esm({
18332
+ "src/db/ticketWorkIntervals.ts"() {
18333
+ "use strict";
18334
+ init_connection();
18335
+ }
18336
+ });
18337
+
17805
18338
  // src/db/tickets.ts
17806
18339
  function escapeIlike(value) {
17807
18340
  return value.replace(/[%_\\]/g, "\\$&");
@@ -17934,7 +18467,17 @@ async function updateTicket(id, updates, options) {
17934
18467
  `UPDATE tickets SET ${sets.join(", ")} WHERE id = $${paramIdx} RETURNING *`,
17935
18468
  values
17936
18469
  );
17937
- return result.rows[0] ?? null;
18470
+ const updated = result.rows[0] ?? null;
18471
+ if (updates.status !== void 0) {
18472
+ try {
18473
+ const secret = readFileSettings(getDataDir()).secret;
18474
+ if (secret !== void 0 && secret !== "") {
18475
+ void recordTicketWorkTransition(secret, updated.ticket_number, updates.status);
18476
+ }
18477
+ } catch {
18478
+ }
18479
+ }
18480
+ return updated;
17938
18481
  }
17939
18482
  async function deleteTicket(id) {
17940
18483
  await updateTicket(id, { status: "deleted" });
@@ -18273,9 +18816,11 @@ var QUERYABLE_FIELDS, PRIORITY_ORD, STATUS_ORD, PRIORITY_RANK, STATUS_RANK;
18273
18816
  var init_tickets = __esm({
18274
18817
  "src/db/tickets.ts"() {
18275
18818
  "use strict";
18819
+ init_file_settings();
18276
18820
  init_ticketNumber();
18277
18821
  init_connection();
18278
18822
  init_notes();
18823
+ init_ticketWorkIntervals();
18279
18824
  QUERYABLE_FIELDS = /* @__PURE__ */ new Set(["category", "priority", "status", "title", "details", "up_next", "tags"]);
18280
18825
  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`;
18281
18826
  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`;
@@ -19380,7 +19925,14 @@ function scheduleWorklistSync(dir) {
19380
19925
  if (!state) return;
19381
19926
  if (state.worklistTimeout) clearTimeout(state.worklistTimeout);
19382
19927
  state.worklistTimeout = setTimeout(() => {
19383
- void runWithDataDir(state.dataDir, () => instrumentAsync(state.dataDir, "markdown.syncWorklist", () => syncWorklist(state)));
19928
+ state.worklistTimeout = null;
19929
+ void getBackgroundScheduler().submit({
19930
+ key: `markdown-worklist:${state.dataDir}`,
19931
+ priority: PRIORITY.MARKDOWN_SYNC,
19932
+ projectKey: state.dataDir,
19933
+ deferUnderLag: true,
19934
+ run: () => runWithDataDir(state.dataDir, () => instrumentAsync(state.dataDir, "markdown.syncWorklist", () => syncWorklist(state)))
19935
+ });
19384
19936
  }, WORKLIST_SYNC_DEBOUNCE_MS);
19385
19937
  }
19386
19938
  function scheduleOpenTicketsSync(dir) {
@@ -19388,7 +19940,14 @@ function scheduleOpenTicketsSync(dir) {
19388
19940
  if (!state) return;
19389
19941
  if (state.openTicketsTimeout) clearTimeout(state.openTicketsTimeout);
19390
19942
  state.openTicketsTimeout = setTimeout(() => {
19391
- void runWithDataDir(state.dataDir, () => instrumentAsync(state.dataDir, "markdown.syncOpenTickets", () => syncOpenTickets(state)));
19943
+ state.openTicketsTimeout = null;
19944
+ void getBackgroundScheduler().submit({
19945
+ key: `markdown-opentickets:${state.dataDir}`,
19946
+ priority: PRIORITY.MARKDOWN_SYNC,
19947
+ projectKey: state.dataDir,
19948
+ deferUnderLag: true,
19949
+ run: () => runWithDataDir(state.dataDir, () => instrumentAsync(state.dataDir, "markdown.syncOpenTickets", () => syncOpenTickets(state)))
19950
+ });
19392
19951
  }, OPEN_TICKETS_SYNC_DEBOUNCE_MS);
19393
19952
  }
19394
19953
  function scheduleAllSync(dir) {
@@ -19706,6 +20265,7 @@ var init_markdown = __esm({
19706
20265
  init_freezeLogger();
19707
20266
  init_file_settings();
19708
20267
  init_limits();
20268
+ init_backgroundScheduler();
19709
20269
  init_schemas3();
19710
20270
  syncStates = /* @__PURE__ */ new Map();
19711
20271
  defaultDataDir2 = null;
@@ -20394,7 +20954,7 @@ function claudeAllowRulePattern(dataDir) {
20394
20954
  return `mcp__${getMcpServerKey(dataDir)}__*`;
20395
20955
  }
20396
20956
  function projectRoot(dataDir) {
20397
- return dataDir.replace(/\/\.hotsheet\/?$/, "");
20957
+ return dataDir.replace(/[\\/]\.hotsheet[\\/]?$/, "");
20398
20958
  }
20399
20959
  function claudeDir(dataDir) {
20400
20960
  return join18(projectRoot(dataDir), ".claude");
@@ -20514,7 +21074,7 @@ function getChannelServerPath() {
20514
21074
  return { command: process.execPath, args: [distPath] };
20515
21075
  }
20516
21076
  function projectRoot2(dataDir) {
20517
- return dataDir.replace(/\/.hotsheet\/?$/, "");
21077
+ return dataDir.replace(/[\\/]\.hotsheet[\\/]?$/, "");
20518
21078
  }
20519
21079
  function registerChannel(dataDir) {
20520
21080
  const root2 = projectRoot2(dataDir);
@@ -21262,6 +21822,25 @@ var init_sessionStore = __esm({
21262
21822
  }
21263
21823
  });
21264
21824
 
21825
+ // src/activeProjects.ts
21826
+ function markProjectActive(dataDir) {
21827
+ lastActiveAt.set(dataDir, Date.now());
21828
+ }
21829
+ function isProjectActive(dataDir) {
21830
+ if (lastActiveAt.size === 0) return true;
21831
+ const at = lastActiveAt.get(dataDir);
21832
+ if (at === void 0) return false;
21833
+ return Date.now() - at < ACTIVE_TTL_MS;
21834
+ }
21835
+ var ACTIVE_TTL_MS, lastActiveAt;
21836
+ var init_activeProjects = __esm({
21837
+ "src/activeProjects.ts"() {
21838
+ "use strict";
21839
+ ACTIVE_TTL_MS = 9e4;
21840
+ lastActiveAt = /* @__PURE__ */ new Map();
21841
+ }
21842
+ });
21843
+
21265
21844
  // src/gitignore.ts
21266
21845
  import { execFileSync as execFileSync3 } from "child_process";
21267
21846
  import { appendFileSync as appendFileSync3, existsSync as existsSync16, readFileSync as readFileSync13 } from "fs";
@@ -21317,56 +21896,72 @@ var init_gitignore = __esm({
21317
21896
  });
21318
21897
 
21319
21898
  // src/git/status.ts
21320
- import { spawnSync } from "child_process";
21899
+ import { execFile as execFile3 } from "child_process";
21321
21900
  import { join as join23 } from "path";
21901
+ import { promisify as promisify2 } from "util";
21902
+ function bufToStr(v) {
21903
+ if (typeof v === "string") return v;
21904
+ if (v !== void 0) return v.toString();
21905
+ return "";
21906
+ }
21322
21907
  function makeGitInvoker({ timeoutMs, includeStderr = false }) {
21323
- return (args, cwd) => {
21324
- const res = spawnSync("git", args, {
21908
+ return async (args, cwd) => {
21909
+ const opts = {
21325
21910
  cwd,
21326
21911
  encoding: "utf-8",
21327
21912
  timeout: timeoutMs,
21913
+ maxBuffer: GIT_MAX_BUFFER,
21328
21914
  env: {
21329
21915
  ...process.env,
21330
21916
  GIT_TERMINAL_PROMPT: "0",
21331
21917
  GIT_OPTIONAL_LOCKS: "0"
21332
21918
  }
21333
- });
21334
- const stdout = typeof res.stdout === "string" ? res.stdout : "";
21335
- const stderr = typeof res.stderr === "string" ? res.stderr : "";
21336
- return { stdout: includeStderr ? stdout + stderr : stdout, status: res.status };
21919
+ };
21920
+ try {
21921
+ const { stdout, stderr } = await execFileAsync2("git", args, opts);
21922
+ const out = bufToStr(stdout);
21923
+ const err = bufToStr(stderr);
21924
+ return { stdout: includeStderr ? out + err : out, status: 0 };
21925
+ } catch (e) {
21926
+ const errObj = e;
21927
+ const out = bufToStr(errObj.stdout);
21928
+ const err = bufToStr(errObj.stderr);
21929
+ const status = typeof errObj.code === "number" ? errObj.code : null;
21930
+ return { stdout: includeStderr ? out + err : out, status };
21931
+ }
21337
21932
  };
21338
21933
  }
21339
- function getGitStatus(projectRoot3, invoker = defaultInvoker) {
21934
+ async function getGitStatus(projectRoot3, invoker = defaultInvoker) {
21340
21935
  if (!isGitRepo(projectRoot3)) return null;
21341
- return instrumentSync(join23(projectRoot3, ".hotsheet"), "git.getStatus", () => getGitStatusUnwrapped(projectRoot3, invoker));
21936
+ return instrumentAsync(join23(projectRoot3, ".hotsheet"), "git.getStatus", () => getGitStatusUnwrapped(projectRoot3, invoker));
21342
21937
  }
21343
- function getGitStatusUnwrapped(projectRoot3, invoker) {
21938
+ async function getGitStatusUnwrapped(projectRoot3, invoker) {
21344
21939
  const root2 = getGitRoot(projectRoot3) ?? projectRoot3;
21345
- const branchRes = invoker(["symbolic-ref", "--short", "HEAD"], root2);
21940
+ const branchRes = await invoker(["symbolic-ref", "--short", "HEAD"], root2);
21346
21941
  let branch;
21347
21942
  let detached = false;
21348
21943
  if (branchRes.status === 0 && branchRes.stdout.trim() !== "") {
21349
21944
  branch = branchRes.stdout.trim();
21350
21945
  } else {
21351
21946
  detached = true;
21352
- const sha = invoker(["rev-parse", "--short", "HEAD"], root2);
21947
+ const sha = await invoker(["rev-parse", "--short", "HEAD"], root2);
21353
21948
  branch = sha.status === 0 && sha.stdout.trim() !== "" ? sha.stdout.trim() : "(detached)";
21354
21949
  }
21355
- const porcelain = invoker(["status", "--porcelain=v1", "--no-renames"], root2);
21950
+ const porcelain = await invoker(["status", "--porcelain=v1", "--no-renames"], root2);
21356
21951
  const counts = porcelain.status === 0 ? bucketPorcelain(porcelain.stdout) : { staged: 0, unstaged: 0, untracked: 0, conflicted: 0 };
21357
21952
  let upstream = null;
21358
21953
  let ahead = 0;
21359
21954
  let behind = 0;
21360
21955
  if (!detached) {
21361
- const upRes = invoker(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root2);
21956
+ const upRes = await invoker(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root2);
21362
21957
  if (upRes.status === 0 && upRes.stdout.trim() !== "") {
21363
21958
  upstream = upRes.stdout.trim();
21364
- const aheadRes = invoker(["rev-list", "--count", "@{u}..HEAD"], root2);
21959
+ const aheadRes = await invoker(["rev-list", "--count", "@{u}..HEAD"], root2);
21365
21960
  if (aheadRes.status === 0) {
21366
21961
  const n = Number.parseInt(aheadRes.stdout.trim(), 10);
21367
21962
  if (Number.isFinite(n)) ahead = n;
21368
21963
  }
21369
- const behindRes = invoker(["rev-list", "--count", "HEAD..@{u}"], root2);
21964
+ const behindRes = await invoker(["rev-list", "--count", "HEAD..@{u}"], root2);
21370
21965
  if (behindRes.status === 0) {
21371
21966
  const n = Number.parseInt(behindRes.stdout.trim(), 10);
21372
21967
  if (Number.isFinite(n)) behind = n;
@@ -21389,16 +21984,16 @@ function getGitStatusUnwrapped(projectRoot3, invoker) {
21389
21984
  function getLastFetchedAt(projectRoot3) {
21390
21985
  return lastFetchedAt.get(projectRoot3) ?? null;
21391
21986
  }
21392
- function runGitFetch(projectRoot3, invoker = makeGitInvoker({ timeoutMs: 3e4, includeStderr: true })) {
21987
+ async function runGitFetch(projectRoot3, invoker = makeGitInvoker({ timeoutMs: 3e4, includeStderr: true })) {
21393
21988
  if (!isGitRepo(projectRoot3)) {
21394
21989
  return { ok: false, lastFetchedAt: null, error: "Not a git repository" };
21395
21990
  }
21396
21991
  const root2 = getGitRoot(projectRoot3) ?? projectRoot3;
21397
- const upRes = invoker(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root2);
21992
+ const upRes = await invoker(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root2);
21398
21993
  if (upRes.status !== 0 || upRes.stdout.trim() === "") {
21399
21994
  return { ok: false, lastFetchedAt: null, error: "No upstream branch \u2014 set one with `git push -u <remote> <branch>`." };
21400
21995
  }
21401
- const fetchRes = invoker(["fetch", "--quiet", "--no-write-fetch-head"], root2);
21996
+ const fetchRes = await invoker(["fetch", "--quiet", "--no-write-fetch-head"], root2);
21402
21997
  if (fetchRes.status === 0) {
21403
21998
  const now = Date.now();
21404
21999
  lastFetchedAt.set(projectRoot3, now);
@@ -21425,11 +22020,11 @@ function bucketPorcelain(output) {
21425
22020
  }
21426
22021
  return out;
21427
22022
  }
21428
- function getGitStatusFiles(projectRoot3, invoker = defaultInvoker) {
22023
+ async function getGitStatusFiles(projectRoot3, invoker = defaultInvoker) {
21429
22024
  if (!isGitRepo(projectRoot3)) return null;
21430
- return instrumentSync(join23(projectRoot3, ".hotsheet"), "git.getStatusFiles", () => {
22025
+ return instrumentAsync(join23(projectRoot3, ".hotsheet"), "git.getStatusFiles", async () => {
21431
22026
  const root2 = getGitRoot(projectRoot3) ?? projectRoot3;
21432
- const res = invoker(["status", "--porcelain=v1", "--no-renames", "-z"], root2);
22027
+ const res = await invoker(["status", "--porcelain=v1", "--no-renames", "-z"], root2);
21433
22028
  if (res.status !== 0) return null;
21434
22029
  return bucketPorcelainFiles(res.stdout);
21435
22030
  });
@@ -21476,13 +22071,15 @@ function pushCapped(arr, item, onTruncate) {
21476
22071
  }
21477
22072
  arr.push(item);
21478
22073
  }
21479
- var SPAWN_TIMEOUT_MS, defaultInvoker, lastFetchedAt, FILES_PER_BUCKET_CAP;
22074
+ var execFileAsync2, SPAWN_TIMEOUT_MS, GIT_MAX_BUFFER, defaultInvoker, lastFetchedAt, FILES_PER_BUCKET_CAP;
21480
22075
  var init_status = __esm({
21481
22076
  "src/git/status.ts"() {
21482
22077
  "use strict";
21483
22078
  init_freezeLogger();
21484
22079
  init_gitignore();
22080
+ execFileAsync2 = promisify2(execFile3);
21485
22081
  SPAWN_TIMEOUT_MS = 2e3;
22082
+ GIT_MAX_BUFFER = 32 * 1024 * 1024;
21486
22083
  defaultInvoker = makeGitInvoker({ timeoutMs: SPAWN_TIMEOUT_MS });
21487
22084
  lastFetchedAt = /* @__PURE__ */ new Map();
21488
22085
  FILES_PER_BUCKET_CAP = 200;
@@ -21503,21 +22100,33 @@ __export(watcher_exports, {
21503
22100
  });
21504
22101
  import { existsSync as existsSync17, watch as fsWatch } from "fs";
21505
22102
  import { join as join24 } from "path";
21506
- function getCachedGitStatus(projectRoot3) {
22103
+ async function getCachedGitStatus(projectRoot3) {
21507
22104
  const entry = cache.get(projectRoot3);
21508
22105
  const now = Date.now();
21509
22106
  if (entry !== void 0 && now - entry.resolvedAt < CACHE_TTL_MS) {
21510
22107
  return entry.status;
21511
22108
  }
21512
- const status = getGitStatus(projectRoot3);
21513
- cache.set(projectRoot3, { status, resolvedAt: now });
21514
- return status;
22109
+ const pending2 = inFlight.get(projectRoot3);
22110
+ if (pending2 !== void 0) return pending2;
22111
+ const p = (async () => {
22112
+ try {
22113
+ const status = await getGitStatus(projectRoot3);
22114
+ cache.set(projectRoot3, { status, resolvedAt: Date.now() });
22115
+ return status;
22116
+ } finally {
22117
+ inFlight.delete(projectRoot3);
22118
+ }
22119
+ })();
22120
+ inFlight.set(projectRoot3, p);
22121
+ return p;
21515
22122
  }
21516
22123
  function _resetGitStatusCacheForTests() {
21517
22124
  cache.clear();
22125
+ inFlight.clear();
21518
22126
  }
21519
22127
  function dropGitStatusCache(projectRoot3) {
21520
22128
  cache.delete(projectRoot3);
22129
+ inFlight.delete(projectRoot3);
21521
22130
  }
21522
22131
  function subscribeToGitChanges(handler) {
21523
22132
  subscribers.add(handler);
@@ -21546,13 +22155,22 @@ function ensureGitWatcher(projectRoot3) {
21546
22155
  if (e2 === void 0) return;
21547
22156
  e2.debounce = null;
21548
22157
  cache.delete(projectRoot3);
22158
+ inFlight.delete(projectRoot3);
21549
22159
  e2.version++;
22160
+ if (!isProjectActive(join24(projectRoot3, ".hotsheet"))) return;
21550
22161
  for (const sub of subscribers) {
21551
22162
  try {
21552
22163
  sub(projectRoot3);
21553
22164
  } catch {
21554
22165
  }
21555
22166
  }
22167
+ void getBackgroundScheduler().submit({
22168
+ key: `git-refresh:${projectRoot3}`,
22169
+ priority: PRIORITY.GIT_STATUS,
22170
+ projectKey: projectRoot3,
22171
+ deferUnderLag: true,
22172
+ run: () => getCachedGitStatus(projectRoot3).then(() => void 0)
22173
+ });
21556
22174
  }, WATCHER_DEBOUNCE_MS);
21557
22175
  };
21558
22176
  for (const file2 of filenames) {
@@ -21579,19 +22197,23 @@ function disposeGitWatcher(projectRoot3) {
21579
22197
  watchers.delete(projectRoot3);
21580
22198
  }
21581
22199
  cache.delete(projectRoot3);
22200
+ inFlight.delete(projectRoot3);
21582
22201
  }
21583
22202
  function disposeAllGitWatchers() {
21584
22203
  for (const root2 of [...watchers.keys()]) disposeGitWatcher(root2);
21585
22204
  subscribers.clear();
21586
22205
  }
21587
- var CACHE_TTL_MS, cache, WATCHER_DEBOUNCE_MS, watchers, subscribers;
22206
+ var CACHE_TTL_MS, cache, inFlight, WATCHER_DEBOUNCE_MS, watchers, subscribers;
21588
22207
  var init_watcher = __esm({
21589
22208
  "src/git/watcher.ts"() {
21590
22209
  "use strict";
22210
+ init_activeProjects();
21591
22211
  init_gitignore();
22212
+ init_backgroundScheduler();
21592
22213
  init_status();
21593
22214
  CACHE_TTL_MS = 500;
21594
22215
  cache = /* @__PURE__ */ new Map();
22216
+ inFlight = /* @__PURE__ */ new Map();
21595
22217
  WATCHER_DEBOUNCE_MS = 250;
21596
22218
  watchers = /* @__PURE__ */ new Map();
21597
22219
  subscribers = /* @__PURE__ */ new Set();
@@ -23672,6 +24294,7 @@ async function runShutdownPipeline(reason) {
23672
24294
  await killShellCommands();
23673
24295
  await destroyTerminals();
23674
24296
  await disposeGitWatchers();
24297
+ await terminateHashWorkerStep();
23675
24298
  await snapshotDatabases();
23676
24299
  await closeDatabases();
23677
24300
  stopFreezeHeartbeat();
@@ -23704,6 +24327,14 @@ async function disposeGitWatchers() {
23704
24327
  console.error("[lifecycle] disposeAllGitWatchers error:", err);
23705
24328
  }
23706
24329
  }
24330
+ async function terminateHashWorkerStep() {
24331
+ try {
24332
+ const { terminateHashWorker: terminateHashWorker2 } = await Promise.resolve().then(() => (init_hashWorker(), hashWorker_exports));
24333
+ await terminateHashWorker2();
24334
+ } catch (err) {
24335
+ console.error("[lifecycle] terminateHashWorker error:", err);
24336
+ }
24337
+ }
23707
24338
  async function closeHttpServer() {
23708
24339
  if (httpServer === null) return;
23709
24340
  await new Promise((resolve11) => {
@@ -23798,25 +24429,25 @@ var init_mime_types = __esm({
23798
24429
  // src/open-in-file-manager.ts
23799
24430
  import { dirname as dirname6 } from "path";
23800
24431
  async function openInFileManager(dirPath) {
23801
- const { execFile: execFile7 } = await import("child_process");
24432
+ const { execFile: execFile8 } = await import("child_process");
23802
24433
  const platform2 = process.platform;
23803
24434
  if (platform2 === "darwin") {
23804
- execFile7("open", [dirPath]);
24435
+ execFile8("open", [dirPath]);
23805
24436
  } else if (platform2 === "win32") {
23806
- execFile7("explorer", [dirPath]);
24437
+ execFile8("explorer", [dirPath]);
23807
24438
  } else {
23808
- execFile7("xdg-open", [dirPath]);
24439
+ execFile8("xdg-open", [dirPath]);
23809
24440
  }
23810
24441
  }
23811
24442
  async function revealInFileManager(filePath) {
23812
- const { execFile: execFile7 } = await import("child_process");
24443
+ const { execFile: execFile8 } = await import("child_process");
23813
24444
  const platform2 = process.platform;
23814
24445
  if (platform2 === "darwin") {
23815
- execFile7("open", ["-R", filePath]);
24446
+ execFile8("open", ["-R", filePath]);
23816
24447
  } else if (platform2 === "win32") {
23817
- execFile7("explorer", ["/select,", filePath]);
24448
+ execFile8("explorer", ["/select,", filePath]);
23818
24449
  } else {
23819
- execFile7("xdg-open", [dirname6(filePath)]);
24450
+ execFile8("xdg-open", [dirname6(filePath)]);
23820
24451
  }
23821
24452
  }
23822
24453
  var init_open_in_file_manager = __esm({
@@ -24114,14 +24745,14 @@ __export(keychain_exports, {
24114
24745
  keychainGet: () => keychainGet,
24115
24746
  keychainSet: () => keychainSet
24116
24747
  });
24117
- import { execFile as execFile3 } from "child_process";
24748
+ import { execFile as execFile4 } from "child_process";
24118
24749
  import { platform } from "os";
24119
24750
  function makeService(pluginId) {
24120
24751
  return `${SERVICE_PREFIX}.${pluginId}`;
24121
24752
  }
24122
24753
  function exec(cmd, args) {
24123
24754
  return new Promise((resolve11) => {
24124
- execFile3(cmd, args, { timeout: 5e3 }, (error51, stdout) => {
24755
+ execFile4(cmd, args, { timeout: 5e3 }, (error51, stdout) => {
24125
24756
  resolve11({ stdout: stdout.trim(), exitCode: error51 ? error51.status ?? 1 : 0 });
24126
24757
  });
24127
24758
  });
@@ -24173,7 +24804,7 @@ async function linuxGet(service, account) {
24173
24804
  }
24174
24805
  async function linuxSet(service, account, password) {
24175
24806
  return new Promise((resolve11) => {
24176
- const proc = execFile3("secret-tool", [
24807
+ const proc = execFile4("secret-tool", [
24177
24808
  "store",
24178
24809
  "--label",
24179
24810
  `Hot Sheet: ${account}`,
@@ -25911,13 +26542,13 @@ var require_aspromise = __commonJS({
25911
26542
  "use strict";
25912
26543
  module.exports = asPromise;
25913
26544
  function asPromise(fn, ctx) {
25914
- var params = new Array(arguments.length - 1), offset = 0, index = 2, pending = true;
26545
+ var params = new Array(arguments.length - 1), offset = 0, index = 2, pending2 = true;
25915
26546
  while (index < arguments.length)
25916
26547
  params[offset++] = arguments[index++];
25917
26548
  return new Promise(function executor(resolve11, reject2) {
25918
26549
  params[offset] = function callback(err) {
25919
- if (pending) {
25920
- pending = false;
26550
+ if (pending2) {
26551
+ pending2 = false;
25921
26552
  if (err)
25922
26553
  reject2(err);
25923
26554
  else {
@@ -25931,8 +26562,8 @@ var require_aspromise = __commonJS({
25931
26562
  try {
25932
26563
  fn.apply(ctx || null, params);
25933
26564
  } catch (err) {
25934
- if (pending) {
25935
- pending = false;
26565
+ if (pending2) {
26566
+ pending2 = false;
25936
26567
  reject2(err);
25937
26568
  }
25938
26569
  }
@@ -32719,7 +33350,7 @@ __export(processPriority_exports, {
32719
33350
  bumpProcessPriorityBestEffort: () => bumpProcessPriorityBestEffort,
32720
33351
  shouldBumpProcessPriority: () => shouldBumpProcessPriority
32721
33352
  });
32722
- import { spawnSync as spawnSync2 } from "child_process";
33353
+ import { spawnSync } from "child_process";
32723
33354
  function shouldBumpProcessPriority(platform2) {
32724
33355
  return platform2 === "darwin";
32725
33356
  }
@@ -32731,7 +33362,7 @@ function bumpProcessPriorityBestEffort() {
32731
33362
  const args = buildTaskpolicyArgs(process.pid);
32732
33363
  let result;
32733
33364
  try {
32734
- result = spawnSync2("taskpolicy", args, { encoding: "utf8", timeout: 2e3 });
33365
+ result = spawnSync("taskpolicy", args, { encoding: "utf8", timeout: 2e3 });
32735
33366
  } catch (err) {
32736
33367
  console.warn(`[priority] taskpolicy spawn failed: ${err instanceof Error ? err.message : String(err)}`);
32737
33368
  return false;
@@ -32758,7 +33389,7 @@ var init_processPriority = __esm({
32758
33389
 
32759
33390
  // src/cli.ts
32760
33391
  init_backup();
32761
- import { execFile as execFile6 } from "child_process";
33392
+ import { execFile as execFile7 } from "child_process";
32762
33393
  import { existsSync as existsSync30, mkdirSync as mkdirSync18, realpathSync } from "fs";
32763
33394
  import { tmpdir as tmpdir3 } from "os";
32764
33395
  import { join as join36, resolve as resolve10 } from "path";
@@ -33197,18 +33828,18 @@ function isDemoMode() {
33197
33828
  import { execFileSync as execFileSync4 } from "child_process";
33198
33829
  var SHELL_PATH_TIMEOUT_MS = 2e3;
33199
33830
  function mergePaths(currentPath, shellPath) {
33200
- const sep = ":";
33201
- const existing = currentPath.split(sep).filter((s) => s !== "");
33831
+ const sep2 = ":";
33832
+ const existing = currentPath.split(sep2).filter((s) => s !== "");
33202
33833
  const existingSet = new Set(existing);
33203
33834
  const additions = [];
33204
- for (const dir of shellPath.split(sep)) {
33835
+ for (const dir of shellPath.split(sep2)) {
33205
33836
  const trimmed = dir.trim();
33206
33837
  if (trimmed === "" || existingSet.has(trimmed)) continue;
33207
33838
  existingSet.add(trimmed);
33208
33839
  additions.push(trimmed);
33209
33840
  }
33210
33841
  if (additions.length === 0) return currentPath;
33211
- return [...additions, ...existing].join(sep);
33842
+ return [...additions, ...existing].join(sep2);
33212
33843
  }
33213
33844
  function readLoginShellPath(execOverride) {
33214
33845
  const shell = process.env.SHELL;
@@ -33262,7 +33893,7 @@ init_lifecycle2();
33262
33893
  init_mime_types();
33263
33894
  init_projects();
33264
33895
  import { serve } from "@hono/node-server";
33265
- import { execFile as execFile5 } from "child_process";
33896
+ import { execFile as execFile6 } from "child_process";
33266
33897
  import { existsSync as existsSync28, readFileSync as readFileSync22 } from "fs";
33267
33898
  import { Hono as Hono19 } from "hono";
33268
33899
  import { basename as basename5, dirname as dirname8, join as join34 } from "path";
@@ -33280,7 +33911,7 @@ init_helpers();
33280
33911
  init_notify();
33281
33912
  import { existsSync as existsSync19, mkdirSync as mkdirSync11, readFileSync as readFileSync14, rmSync as rmSync7, writeFileSync as writeFileSync13 } from "fs";
33282
33913
  import { Hono as Hono2 } from "hono";
33283
- import { basename as basename3, extname, join as join26, resolve as resolve7 } from "path";
33914
+ import { basename as basename3, extname, join as join26, resolve as resolve7, sep } from "path";
33284
33915
  var attachmentRoutes = new Hono2();
33285
33916
  attachmentRoutes.post("/tickets/:id/attachments", async (c) => {
33286
33917
  const id = parseIntParam(c, "id");
@@ -33367,7 +33998,7 @@ attachmentRoutes.get("/attachments/file/*", (c) => {
33367
33998
  const dataDir = c.get("dataDir");
33368
33999
  const attachDir = resolve7(join26(dataDir, "attachments"));
33369
34000
  const fullPath = resolve7(join26(attachDir, filePath));
33370
- if (!fullPath.startsWith(attachDir + "/") && fullPath !== attachDir) {
34001
+ if (!fullPath.startsWith(attachDir + sep) && fullPath !== attachDir) {
33371
34002
  return c.json({ error: "Invalid path" }, 403);
33372
34003
  }
33373
34004
  if (!existsSync19(fullPath)) {
@@ -33460,6 +34091,7 @@ function listAliveEntries(dataDir, isPidAlive3 = defaultIsPidAlive) {
33460
34091
  init_claude_hooks();
33461
34092
  init_commandLog();
33462
34093
  init_settings();
34094
+ init_ticketWorkIntervals();
33463
34095
  init_freezeLogger();
33464
34096
  init_file_settings();
33465
34097
  init_global_config();
@@ -33731,7 +34363,10 @@ channelRoutes.post("/channel/permission/dismiss", async (c) => {
33731
34363
  });
33732
34364
  channelRoutes.post("/channel/done", (_c) => {
33733
34365
  const secret = _c.get("projectSecret");
33734
- if (secret) channelDoneFlags.set(secret, true);
34366
+ if (secret) {
34367
+ channelDoneFlags.set(secret, true);
34368
+ void closeOpenTicketIntervalsForProject(secret);
34369
+ }
33735
34370
  addLogEntry("done", "incoming", "Claude finished", "").catch(() => {
33736
34371
  });
33737
34372
  notifyChange();
@@ -33829,6 +34464,7 @@ commandLogRoutes.get("/command-log/count", async (c) => {
33829
34464
  });
33830
34465
 
33831
34466
  // src/routes/dashboard.ts
34467
+ init_activeProjects();
33832
34468
  init_queries();
33833
34469
  init_stats();
33834
34470
  init_gitignore();
@@ -33844,6 +34480,7 @@ import { homedir as homedir6, tmpdir } from "os";
33844
34480
  import { join as join29, relative as relative2, resolve as resolve8 } from "path";
33845
34481
  var dashboardRoutes = new Hono5();
33846
34482
  dashboardRoutes.get("/poll", async (c) => {
34483
+ markProjectActive(c.get("dataDir"));
33847
34484
  const clientVersion = Math.max(0, parseInt(c.req.query("version") ?? "0", 10) || 0);
33848
34485
  const changeVersion2 = getChangeVersion();
33849
34486
  if (changeVersion2 > clientVersion) {
@@ -34911,12 +35548,12 @@ import { Hono as Hono13 } from "hono";
34911
35548
  // src/db/repair.ts
34912
35549
  init_backup();
34913
35550
  init_pglite();
34914
- import { execFile as execFile4 } from "child_process";
35551
+ import { execFile as execFile5 } from "child_process";
34915
35552
  import { cpSync as cpSync2, existsSync as existsSync26, mkdirSync as mkdirSync16, readFileSync as readFileSync21, rmSync as rmSync10, writeFileSync as writeFileSync18 } from "fs";
34916
35553
  import { tmpdir as tmpdir2 } from "os";
34917
35554
  import { join as join32 } from "path";
34918
- import { promisify as promisify2 } from "util";
34919
- var execFileP = promisify2(execFile4);
35555
+ import { promisify as promisify3 } from "util";
35556
+ var execFileP = promisify3(execFile5);
34920
35557
  async function findWorkingBackup(dataDir) {
34921
35558
  const backups = listBackups(dataDir);
34922
35559
  for (const backup of backups) {
@@ -35107,6 +35744,7 @@ dbRoutes.post("/repair/run-pg-resetwal", async (c) => {
35107
35744
  });
35108
35745
 
35109
35746
  // src/routes/git.ts
35747
+ init_activeProjects();
35110
35748
  import { Hono as Hono14 } from "hono";
35111
35749
  import { join as join33 } from "path";
35112
35750
 
@@ -35168,18 +35806,19 @@ init_watcher();
35168
35806
  init_gitignore();
35169
35807
  init_open_in_file_manager();
35170
35808
  var gitRoutes = new Hono14();
35171
- gitRoutes.get("/git/status", (c) => {
35809
+ gitRoutes.get("/git/status", async (c) => {
35172
35810
  const dataDir = c.get("dataDir");
35811
+ markProjectActive(dataDir);
35173
35812
  const projectRoot3 = projectRootFromDataDir(dataDir);
35174
35813
  const settings = readFileSettings(dataDir);
35175
35814
  if (settings.git_tracking_enabled === false) {
35176
35815
  return c.json(null);
35177
35816
  }
35178
35817
  ensureGitWatcher(projectRoot3);
35179
- const status = getCachedGitStatus(projectRoot3);
35818
+ const status = await getCachedGitStatus(projectRoot3);
35180
35819
  if (status === null) return c.json(null);
35181
35820
  if (c.req.query("files") === "true") {
35182
- const files = getGitStatusFiles(projectRoot3);
35821
+ const files = await getGitStatusFiles(projectRoot3);
35183
35822
  return c.json({ ...status, files });
35184
35823
  }
35185
35824
  return c.json(status);
@@ -35187,14 +35826,14 @@ gitRoutes.get("/git/status", (c) => {
35187
35826
  function projectRootFromDataDir(dataDir) {
35188
35827
  return dataDir.replace(/[\\/]\.hotsheet\/?$/, "");
35189
35828
  }
35190
- gitRoutes.post("/git/fetch", (c) => {
35829
+ gitRoutes.post("/git/fetch", async (c) => {
35191
35830
  const dataDir = c.get("dataDir");
35192
35831
  const projectRoot3 = projectRootFromDataDir(dataDir);
35193
35832
  const settings = readFileSettings(dataDir);
35194
35833
  if (settings.git_tracking_enabled === false) {
35195
35834
  return c.json({ ok: false, lastFetchedAt: null, error: "git tracking disabled in settings" });
35196
35835
  }
35197
- const result = runGitFetch(projectRoot3);
35836
+ const result = await runGitFetch(projectRoot3);
35198
35837
  if (result.ok) dropGitStatusCache(projectRoot3);
35199
35838
  return c.json(result);
35200
35839
  });
@@ -38143,54 +38782,62 @@ async function getPromptTimeline(promptId) {
38143
38782
  spans
38144
38783
  };
38145
38784
  }
38146
- async function getPerTicketRollup(ticketNumber) {
38785
+ async function getPerTicketRollup(ticketNumber, secret) {
38147
38786
  const db = await getTelemetryDb();
38148
38787
  const marker = `hotsheet:ticket=${ticketNumber}`;
38149
- const tagged = await db.query(
38150
- `SELECT DISTINCT prompt_id FROM otel_events
38151
- WHERE ${eventNameMatchSql("event_name", "user_prompt")}
38152
- AND prompt_id IS NOT NULL
38153
- AND body_json::text LIKE $1`,
38154
- [`%${marker}%`]
38155
- );
38156
- if (tagged.rows.length === 0) {
38157
- return { ticketNumber, promptCount: 0, totalCost: 0, totalTokens: 0, totalDurationSeconds: 0 };
38158
- }
38159
- const promptIds = tagged.rows.map((r) => r.prompt_id);
38160
- const sumsResult = await db.query(
38161
- `SELECT
38162
- SUM(COALESCE(
38163
- (attributes_json->>'cost')::numeric,
38164
- (attributes_json->>'cost_usd')::numeric,
38165
- 0
38166
- )) AS total_cost,
38167
- SUM(COALESCE(
38168
- (attributes_json->>'tokens')::numeric,
38169
- (attributes_json->>'total_tokens')::numeric,
38170
- (attributes_json->>'input_tokens')::numeric + (attributes_json->>'output_tokens')::numeric,
38171
- 0
38172
- )) AS total_tokens
38173
- FROM otel_events
38174
- WHERE ${eventNameMatchSql("event_name", "api_request")}
38175
- AND prompt_id = ANY($1::text[])`,
38176
- [promptIds]
38177
- );
38178
- const durationsResult = await db.query(
38179
- `SELECT SUM(EXTRACT(EPOCH FROM (max_ts - min_ts))) AS total_seconds
38180
- FROM (
38181
- SELECT MIN(ts) AS min_ts, MAX(ts) AS max_ts
38182
- FROM otel_events
38183
- WHERE prompt_id = ANY($1::text[])
38184
- GROUP BY prompt_id
38185
- ) AS per_prompt`,
38186
- [promptIds]
38788
+ const secretParam = secret !== void 0 && secret !== "" ? secret : null;
38789
+ const result = await db.query(
38790
+ `WITH marker_prompts AS (
38791
+ SELECT DISTINCT prompt_id FROM otel_events
38792
+ WHERE ${eventNameMatchSql("event_name", "user_prompt")}
38793
+ AND prompt_id IS NOT NULL
38794
+ AND body_json::text LIKE $1
38795
+ ),
38796
+ matched AS (
38797
+ SELECT
38798
+ e.prompt_id,
38799
+ e.ts,
38800
+ COALESCE(
38801
+ (e.attributes_json->>'cost')::numeric,
38802
+ (e.attributes_json->>'cost_usd')::numeric,
38803
+ 0
38804
+ ) AS cost,
38805
+ COALESCE(
38806
+ (e.attributes_json->>'tokens')::numeric,
38807
+ (e.attributes_json->>'total_tokens')::numeric,
38808
+ (e.attributes_json->>'input_tokens')::numeric + (e.attributes_json->>'output_tokens')::numeric,
38809
+ 0
38810
+ ) AS tokens
38811
+ FROM otel_events e
38812
+ WHERE ${eventNameMatchSql("e.event_name", "api_request")}
38813
+ AND (
38814
+ e.prompt_id IN (SELECT prompt_id FROM marker_prompts)
38815
+ OR (
38816
+ $2::text IS NOT NULL AND e.project_secret = $2 AND EXISTS (
38817
+ SELECT 1 FROM ticket_work_intervals i
38818
+ WHERE i.project_secret = $2 AND i.ticket_number = $3
38819
+ AND e.ts >= i.started_at AND e.ts <= COALESCE(i.ended_at, NOW())
38820
+ )
38821
+ )
38822
+ )
38823
+ )
38824
+ SELECT
38825
+ (SELECT COUNT(DISTINCT prompt_id) FROM matched) AS prompt_count,
38826
+ (SELECT COALESCE(SUM(cost), 0) FROM matched) AS total_cost,
38827
+ (SELECT COALESCE(SUM(tokens), 0) FROM matched) AS total_tokens,
38828
+ (SELECT COALESCE(SUM(dur), 0) FROM (
38829
+ SELECT EXTRACT(EPOCH FROM (MAX(ts) - MIN(ts))) AS dur
38830
+ FROM matched WHERE prompt_id IS NOT NULL GROUP BY prompt_id
38831
+ ) per_prompt) AS total_seconds`,
38832
+ [`%${marker}%`, secretParam, ticketNumber]
38187
38833
  );
38834
+ const row = result.rows[0];
38188
38835
  return {
38189
38836
  ticketNumber,
38190
- promptCount: promptIds.length,
38191
- totalCost: Number(sumsResult.rows[0]?.total_cost ?? 0),
38192
- totalTokens: Number(sumsResult.rows[0]?.total_tokens ?? 0),
38193
- totalDurationSeconds: Number(durationsResult.rows[0]?.total_seconds ?? 0)
38837
+ promptCount: Number(row.prompt_count ?? 0),
38838
+ totalCost: Number(row.total_cost ?? 0),
38839
+ totalTokens: Number(row.total_tokens ?? 0),
38840
+ totalDurationSeconds: Number(row.total_seconds ?? 0)
38194
38841
  };
38195
38842
  }
38196
38843
  async function getDashboardPayload(window2, timezone = "UTC", allowedSecrets = null, now = /* @__PURE__ */ new Date()) {
@@ -38272,7 +38919,8 @@ telemetryRoutes.get("/telemetry/prompt/:id", async (c) => {
38272
38919
  });
38273
38920
  telemetryRoutes.get("/telemetry/ticket/:number", async (c) => {
38274
38921
  const ticketNumber = c.req.param("number");
38275
- const rollup = await getPerTicketRollup(ticketNumber);
38922
+ const secret = c.get("projectSecret");
38923
+ const rollup = await getPerTicketRollup(ticketNumber, secret);
38276
38924
  return c.json(rollup);
38277
38925
  });
38278
38926
  telemetryRoutes.get("/telemetry/enabled-anywhere", (c) => {
@@ -38614,7 +39262,7 @@ async function startServer(port, dataDir, options) {
38614
39262
  `);
38615
39263
  if (options?.noOpen !== true) {
38616
39264
  const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
38617
- execFile5(openCmd, [url2]);
39265
+ execFile6(openCmd, [url2]);
38618
39266
  }
38619
39267
  return actualPort;
38620
39268
  }
@@ -38792,8 +39440,12 @@ async function startAndConfigure(port, dataDir, strictPort) {
38792
39440
  const secret = ensureSecret(dataDir, actualPort);
38793
39441
  const { bumpProcessPriorityBestEffort: bumpProcessPriorityBestEffort2 } = await Promise.resolve().then(() => (init_processPriority(), processPriority_exports));
38794
39442
  bumpProcessPriorityBestEffort2();
38795
- const { startServerEventLoopHeartbeat: startServerEventLoopHeartbeat2 } = await Promise.resolve().then(() => (init_freezeLogger(), freezeLogger_exports));
39443
+ const { startServerEventLoopHeartbeat: startServerEventLoopHeartbeat2, onServerWake: onServerWake2 } = await Promise.resolve().then(() => (init_freezeLogger(), freezeLogger_exports));
38796
39444
  startServerEventLoopHeartbeat2(dataDir);
39445
+ const { getBackgroundScheduler: getBackgroundScheduler2 } = await Promise.resolve().then(() => (init_backgroundScheduler(), backgroundScheduler_exports));
39446
+ onServerWake2(() => {
39447
+ getBackgroundScheduler2().noteWake();
39448
+ });
38797
39449
  initMarkdownSync(dataDir, actualPort);
38798
39450
  scheduleAllSync(dataDir);
38799
39451
  const { runWithDataDir: runWith } = await Promise.resolve().then(() => (init_connection(), connection_exports));
@@ -38842,7 +39494,7 @@ async function postStartup(dataDir, actualPort, demo, noOpen) {
38842
39494
  if (!noOpen) {
38843
39495
  const url2 = `http://localhost:${actualPort}`;
38844
39496
  const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
38845
- execFile6(openCmd, [url2]);
39497
+ execFile7(openCmd, [url2]);
38846
39498
  }
38847
39499
  }
38848
39500
  async function restorePreviousProjects(dataDir, actualPort) {