hanoman 0.1.39 → 0.1.40

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.
package/dist/server.js CHANGED
@@ -6195,6 +6195,8 @@ var init_api = __esm({
6195
6195
  syncNow: `${API}/sync/now`,
6196
6196
  // SPEC-270 · ADR-0067 · antrean konflik rekonsil (cookie-authed)
6197
6197
  syncConflicts: `${API}/sync/conflicts`,
6198
+ // SPEC-799 · ADR-0119 · penghapusan yang menunggu jendela online (cookie-only, cermin syncNow).
6199
+ syncPending: `${API}/sync/pending`,
6198
6200
  syncConflictResolve: (entity, recordId) => `${API}/sync/conflicts/${encodeURIComponent(entity)}/${encodeURIComponent(recordId)}/resolve`,
6199
6201
  // SPEC-257 · ADR-0065 · agent token (kelola cookie-only) + katalog capability
6200
6202
  agentTokens: `${API}/agent-tokens`,
@@ -10487,16 +10489,54 @@ var init_rename_project = __esm({
10487
10489
  }
10488
10490
  });
10489
10491
 
10492
+ // src/services/tombstone.ts
10493
+ async function findTombstone(entity, recordId) {
10494
+ const row = await prisma.syncTombstone.findUnique({ where: { entity_recordId: { entity, recordId } } });
10495
+ return row ? view(row) : null;
10496
+ }
10497
+ async function writeTombstone(entity, recordId, version2, data, deviceId) {
10498
+ const prev = await findTombstone(entity, recordId);
10499
+ if (prev && prev.version >= version2) return prev;
10500
+ const row = await prisma.syncTombstone.upsert({
10501
+ where: { entity_recordId: { entity, recordId } },
10502
+ create: { entity, recordId, version: version2, data, deviceId: deviceId ?? null },
10503
+ update: { version: version2, data, deviceId: deviceId ?? null, deletedAt: /* @__PURE__ */ new Date() }
10504
+ });
10505
+ return view(row);
10506
+ }
10507
+ async function clearTombstone(entity, recordId) {
10508
+ await prisma.syncTombstone.deleteMany({ where: { entity, recordId } });
10509
+ }
10510
+ var view;
10511
+ var init_tombstone = __esm({
10512
+ "src/services/tombstone.ts"() {
10513
+ "use strict";
10514
+ init_db();
10515
+ view = (r) => ({
10516
+ entity: r.entity,
10517
+ recordId: r.recordId,
10518
+ version: r.version,
10519
+ data: r.data ?? {},
10520
+ deletedAt: r.deletedAt,
10521
+ deviceId: r.deviceId
10522
+ });
10523
+ }
10524
+ });
10525
+
10490
10526
  // src/services/sync.ts
10491
10527
  var sync_exports = {};
10492
10528
  __export(sync_exports, {
10529
+ PARENTS: () => PARENTS,
10493
10530
  SYNCED: () => SYNCED,
10494
10531
  __DATE_FIELDS: () => __DATE_FIELDS,
10495
10532
  __FIELDS: () => __FIELDS,
10496
10533
  __FIELDS_FOR_TEST: () => __FIELDS_FOR_TEST,
10497
10534
  applyPush: () => applyPush,
10498
10535
  backfillFeed: () => backfillFeed,
10536
+ consumeTombstoneOnRecreate: () => consumeTombstoneOnRecreate,
10537
+ deleteRow: () => deleteRow,
10499
10538
  isEntity: () => isEntity,
10539
+ publishDelete: () => publishDelete,
10500
10540
  publishLocal: () => publishLocal,
10501
10541
  pull: () => pull,
10502
10542
  setAcceptedHook: () => setAcceptedHook,
@@ -10561,10 +10601,24 @@ async function snapshot(entity, id) {
10561
10601
  for (const f of FIELDS[entity]) data[f] = jsonSafe(row[f]);
10562
10602
  return { version: Number(row.version), data };
10563
10603
  }
10564
- async function applyPush(entity, id, baseVersion, data, deviceId) {
10604
+ async function applyPush(entity, id, baseVersion, data, deviceId, op = "upsert") {
10565
10605
  validateSyncData(entity, data, { allowProjectRename: true });
10606
+ if (op === "delete") {
10607
+ const already = await findTombstone(entity, id);
10608
+ if (already) return { ok: true, version: already.version };
10609
+ const snap2 = await snapshot(entity, id);
10610
+ const version2 = (snap2?.version ?? baseVersion) + 1;
10611
+ if (snap2) await DELEGATE[entity].delete({ where: { id } });
10612
+ await writeTombstone(entity, id, version2, snap2?.data ?? data, deviceId);
10613
+ await publishDelete(entity, id);
10614
+ return { ok: true, version: version2 };
10615
+ }
10566
10616
  if (entity === "project" && typeof data.renamedFrom === "string" && data.renamedFrom && data.renamedFrom !== id) {
10567
10617
  const oldId = data.renamedFrom;
10618
+ const destTomb = await findTombstone("project", id);
10619
+ if (destTomb) {
10620
+ return { ok: false, conflict: true, deleted: true, deletedVersion: destTomb.version, server: null };
10621
+ }
10568
10622
  const already = await DELEGATE.project.findUnique({ where: { id }, select: { version: true } });
10569
10623
  if (already) return { ok: true, version: Number(already.version) };
10570
10624
  const old = await DELEGATE.project.findUnique({ where: { id: oldId }, select: { version: true } });
@@ -10578,17 +10632,24 @@ async function applyPush(entity, id, baseVersion, data, deviceId) {
10578
10632
  const snap2 = await snapshot("project", id);
10579
10633
  const logData = { ...snap2?.data ?? {}, renamedFrom: oldId };
10580
10634
  const log3 = await prisma.syncLog.create({
10581
- data: { entity: "project", recordId: id, version: newVersion2, data: logData, deviceId: deviceId ?? null }
10635
+ data: { entity: "project", recordId: id, version: newVersion2, op: "upsert", data: logData, deviceId: deviceId ?? null }
10582
10636
  });
10583
- onAccepted?.({ entity: "project", recordId: id, version: newVersion2, data: logData, seq: String(log3.seq) });
10637
+ onAccepted?.({ entity: "project", recordId: id, version: newVersion2, op: "upsert", data: logData, seq: String(log3.seq) });
10584
10638
  return { ok: true, version: newVersion2 };
10585
10639
  }
10586
10640
  }
10641
+ const tomb = await findTombstone(entity, id);
10587
10642
  const existing = await DELEGATE[entity].findUnique({ where: { id }, select: { version: true } });
10588
- if (existing && Number(existing.version) !== baseVersion) {
10589
- return { ok: false, conflict: true, server: await snapshot(entity, id) };
10643
+ const currentVersion = existing ? Number(existing.version) : tomb ? tomb.version : null;
10644
+ if (currentVersion !== null && currentVersion !== baseVersion) {
10645
+ return {
10646
+ ok: false,
10647
+ conflict: true,
10648
+ server: await snapshot(entity, id),
10649
+ ...tomb && !existing ? { deleted: true, deletedVersion: tomb.version } : {}
10650
+ };
10590
10651
  }
10591
- const newVersion = existing ? Number(existing.version) + 1 : 1;
10652
+ const newVersion = (currentVersion ?? 0) + 1;
10592
10653
  const writeData = coerce2(entity, data);
10593
10654
  const stamp2 = writeData.updatedAt ?? /* @__PURE__ */ new Date();
10594
10655
  await DELEGATE[entity].upsert({
@@ -10596,11 +10657,12 @@ async function applyPush(entity, id, baseVersion, data, deviceId) {
10596
10657
  create: { id, ...writeData, version: newVersion, updatedAt: stamp2 },
10597
10658
  update: { ...writeData, version: newVersion, updatedAt: stamp2 }
10598
10659
  });
10660
+ if (tomb) await clearTombstone(entity, id);
10599
10661
  const snap = await snapshot(entity, id);
10600
10662
  const log2 = await prisma.syncLog.create({
10601
- data: { entity, recordId: id, version: newVersion, data: snap?.data ?? {}, deviceId: deviceId ?? null }
10663
+ data: { entity, recordId: id, version: newVersion, op: "upsert", data: snap?.data ?? {}, deviceId: deviceId ?? null }
10602
10664
  });
10603
- onAccepted?.({ entity, recordId: id, version: newVersion, data: snap?.data ?? {}, seq: String(log2.seq) });
10665
+ onAccepted?.({ entity, recordId: id, version: newVersion, op: "upsert", data: snap?.data ?? {}, seq: String(log2.seq) });
10604
10666
  return { ok: true, version: newVersion };
10605
10667
  }
10606
10668
  async function pull(sinceCursor, limit = 500) {
@@ -10613,7 +10675,13 @@ async function pull(sinceCursor, limit = 500) {
10613
10675
  const cursor = rows.length ? String(rows[rows.length - 1].seq) : sinceCursor || "0";
10614
10676
  return {
10615
10677
  cursor,
10616
- records: rows.map((r) => ({ entity: r.entity, recordId: r.recordId, version: r.version, data: r.data }))
10678
+ records: rows.map((r) => ({
10679
+ entity: r.entity,
10680
+ recordId: r.recordId,
10681
+ version: r.version,
10682
+ op: r.op === "delete" ? "delete" : "upsert",
10683
+ data: r.data
10684
+ }))
10617
10685
  };
10618
10686
  }
10619
10687
  async function publishLocal(entity, id) {
@@ -10622,9 +10690,38 @@ async function publishLocal(entity, id) {
10622
10690
  const newVersion = snap.version + 1;
10623
10691
  await DELEGATE[entity].update({ where: { id }, data: { version: newVersion } });
10624
10692
  const log2 = await prisma.syncLog.create({
10625
- data: { entity, recordId: id, version: newVersion, data: snap.data ?? {}, deviceId: null }
10693
+ data: { entity, recordId: id, version: newVersion, op: "upsert", data: snap.data ?? {}, deviceId: null }
10694
+ });
10695
+ onAccepted?.({ entity, recordId: id, version: newVersion, op: "upsert", data: snap.data ?? {}, seq: String(log2.seq) });
10696
+ }
10697
+ async function deleteRow(entity, id) {
10698
+ await DELEGATE[entity].delete({ where: { id } });
10699
+ }
10700
+ async function publishDelete(entity, id) {
10701
+ const tomb = await findTombstone(entity, id);
10702
+ if (!tomb) return;
10703
+ const log2 = await prisma.syncLog.create({
10704
+ data: {
10705
+ entity,
10706
+ recordId: id,
10707
+ version: tomb.version,
10708
+ op: "delete",
10709
+ data: tomb.data,
10710
+ deviceId: tomb.deviceId
10711
+ }
10626
10712
  });
10627
- onAccepted?.({ entity, recordId: id, version: newVersion, data: snap.data ?? {}, seq: String(log2.seq) });
10713
+ onAccepted?.({ entity, recordId: id, version: tomb.version, op: "delete", data: tomb.data, seq: String(log2.seq) });
10714
+ }
10715
+ async function consumeTombstoneOnRecreate(entity, id) {
10716
+ const tomb = await findTombstone(entity, id);
10717
+ if (!tomb) return false;
10718
+ const row = await DELEGATE[entity].findUnique({ where: { id }, select: { version: true } });
10719
+ if (!row) return false;
10720
+ await clearTombstone(entity, id);
10721
+ if (Number(row.version) < tomb.version) {
10722
+ await DELEGATE[entity].update({ where: { id }, data: { version: tomb.version } });
10723
+ }
10724
+ return true;
10628
10725
  }
10629
10726
  async function backfillFeed() {
10630
10727
  let published = 0;
@@ -10640,6 +10737,16 @@ async function backfillFeed() {
10640
10737
  published++;
10641
10738
  }
10642
10739
  }
10740
+ for (const t of await prisma.syncTombstone.findMany({ select: { entity: true, recordId: true, version: true } })) {
10741
+ if (!isEntity(t.entity)) continue;
10742
+ const has = await prisma.syncLog.findFirst({
10743
+ where: { entity: t.entity, recordId: t.recordId, version: t.version, op: "delete" },
10744
+ select: { seq: true }
10745
+ });
10746
+ if (has) continue;
10747
+ await publishDelete(t.entity, t.recordId);
10748
+ published++;
10749
+ }
10643
10750
  return published;
10644
10751
  }
10645
10752
  async function upsertLocal(entity, id, version2, data) {
@@ -10667,12 +10774,13 @@ async function upsertLocal(entity, id, version2, data) {
10667
10774
  function setAcceptedHook(hook) {
10668
10775
  onAccepted = hook;
10669
10776
  }
10670
- var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, __FIELDS, __DATE_FIELDS, NUMBER_FIELDS, BOOLEAN_FIELDS, JSON_FIELDS, onAccepted, __FIELDS_FOR_TEST;
10777
+ var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, PARENTS, __FIELDS, __DATE_FIELDS, NUMBER_FIELDS, BOOLEAN_FIELDS, JSON_FIELDS, onAccepted, __FIELDS_FOR_TEST;
10671
10778
  var init_sync = __esm({
10672
10779
  "src/services/sync.ts"() {
10673
10780
  "use strict";
10674
10781
  init_db();
10675
10782
  init_rename_project();
10783
+ init_tombstone();
10676
10784
  SYNCED = ["project", "spec", "vps", "sessionResult", "ticket", "ticketAttachment", "customAgent", "githubIssue"];
10677
10785
  DELEGATE = {
10678
10786
  project: prisma.project,
@@ -10746,6 +10854,13 @@ var init_sync = __esm({
10746
10854
  customAgent: ["createdAt", "updatedAt"],
10747
10855
  githubIssue: ["issueCreatedAt", "issueUpdatedAt", "pulledAt", "createdAt", "updatedAt"]
10748
10856
  };
10857
+ PARENTS = {
10858
+ spec: [{ field: "projectId", entity: "project" }],
10859
+ ticket: [{ field: "projectId", entity: "project" }],
10860
+ ticketAttachment: [{ field: "ticketId", entity: "ticket" }],
10861
+ customAgent: [{ field: "projectId", entity: "project" }],
10862
+ githubIssue: [{ field: "projectId", entity: "project" }]
10863
+ };
10749
10864
  __FIELDS = FIELDS;
10750
10865
  __DATE_FIELDS = DATE_FIELDS;
10751
10866
  NUMBER_FIELDS = /* @__PURE__ */ new Set([
@@ -10888,6 +11003,134 @@ var init_codex_trust = __esm({
10888
11003
  }
10889
11004
  });
10890
11005
 
11006
+ // src/services/notifications.ts
11007
+ async function recordDrift(vpsId, vpsName, drift, snapshotId) {
11008
+ if (drift.length === 0) return;
11009
+ const ids = drift.map((d) => d.itemId);
11010
+ const shown = ids.slice(0, 5).join(", ") + (ids.length > 5 ? `, +${ids.length - 5} lagi` : "");
11011
+ const title = `Drift di "${vpsName}": ${drift.length} item regresi (${shown})`;
11012
+ await prisma.notification.create({
11013
+ data: { type: "drift", key: `drift:${vpsId}:${snapshotId}`, title, projectId: null }
11014
+ }).catch(() => {
11015
+ });
11016
+ }
11017
+ async function recordCompletion(specId, title, projectId) {
11018
+ await prisma.spec.updateMany({ where: { id: specId, doneAt: null }, data: { doneAt: /* @__PURE__ */ new Date() } }).catch(() => {
11019
+ });
11020
+ const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
11021
+ await prisma.notification.create({
11022
+ data: { type: "done", key: `done:${specId}`, specId, sessionId: sessionId2, title, projectId }
11023
+ }).catch(() => {
11024
+ });
11025
+ }
11026
+ async function recordSyncDelete(entity, recordId, version2, title) {
11027
+ await prisma.notification.create({
11028
+ data: { type: "sync", key: `sync-delete:${entity}:${recordId}:${version2}`, title, projectId: null }
11029
+ }).catch(() => {
11030
+ });
11031
+ }
11032
+ async function recordFailure(specId, title, projectId, reason) {
11033
+ const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
11034
+ await prisma.notification.create({
11035
+ data: { type: "fail", key: `fail:${specId}`, specId, sessionId: sessionId2, title: `Gagal: ${title} \u2014 ${reason}`, projectId }
11036
+ }).catch(() => {
11037
+ });
11038
+ }
11039
+ async function recordAutoMerge(specId, projectId, title) {
11040
+ const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
11041
+ await prisma.notification.create({
11042
+ data: { type: "automerge", key: `automerge:${specId}`, specId, sessionId: sessionId2, title, projectId }
11043
+ }).catch(() => {
11044
+ });
11045
+ }
11046
+ async function recordCleanupFailure(sessionId2, projectId, entry, reason) {
11047
+ await prisma.notification.create({
11048
+ data: {
11049
+ type: "cleanup",
11050
+ key: `cleanup:${entry}`,
11051
+ sessionId: sessionId2,
11052
+ projectId,
11053
+ title: `Worktree sesi ${sessionId2} gagal dibersihkan \u2014 ${reason}`
11054
+ }
11055
+ }).catch(() => {
11056
+ });
11057
+ }
11058
+ async function recordSourceChange(specId, projectId, title, from, to, seq) {
11059
+ const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
11060
+ await prisma.notification.create({
11061
+ data: {
11062
+ type: "spec-source",
11063
+ key: `source:${specId}:${seq}`,
11064
+ specId,
11065
+ sessionId: sessionId2,
11066
+ title: `${specId} \xB7 type ${from} \u2192 ${to} \u2014 ${title}`,
11067
+ projectId
11068
+ }
11069
+ }).catch(() => {
11070
+ });
11071
+ }
11072
+ async function recordNewTicket(ticketId, projectId, projectName, category, title) {
11073
+ const short = title.length > 80 ? title.slice(0, 77) + "\u2026" : title;
11074
+ const t = `Keluhan baru di "${projectName}": ${category}: ${short}`;
11075
+ await prisma.notification.create({
11076
+ data: { type: "ticket", key: `ticket:${ticketId}`, projectId, title: t }
11077
+ }).catch(() => {
11078
+ });
11079
+ }
11080
+ async function recordLeadDecision(decisionId, title, projectId, specId, sessionId2) {
11081
+ await prisma.notification.create({
11082
+ data: { type: "lead", key: `lead:${decisionId}`, specId, sessionId: sessionId2, projectId, title }
11083
+ }).catch(() => {
11084
+ });
11085
+ }
11086
+ async function recordCronRun(cronId, cronName, projectId, dueAt, status, note) {
11087
+ const verb = status === "launched" ? "berjalan" : status === "skipped" ? "dilewati" : "gagal";
11088
+ const title = `Cron "${cronName}" ${verb}${note ? ` \u2014 ${note}` : ""}`;
11089
+ await prisma.notification.create({
11090
+ data: { type: "cron", key: `cron:${cronId}:${dueAt.toISOString()}`, title, projectId }
11091
+ }).catch(() => {
11092
+ });
11093
+ }
11094
+ async function scanDecisions(read2 = liveDecisions) {
11095
+ const next = /* @__PURE__ */ new Set();
11096
+ const fresh = [];
11097
+ for (const s2 of read2()) {
11098
+ if (!markerFilled(s2.decisionFile)) continue;
11099
+ next.add(s2.id);
11100
+ if (!awaiting.has(s2.id)) fresh.push(s2);
11101
+ }
11102
+ awaiting = next;
11103
+ for (const s2 of fresh) {
11104
+ const title = s2.specId ? (await prisma.spec.findUnique({ where: { id: s2.specId }, select: { title: true } }))?.title ?? s2.specId : s2.id;
11105
+ await prisma.notification.create({
11106
+ data: { type: "decision", specId: s2.specId ?? null, sessionId: s2.id, projectId: s2.projectId || null, title }
11107
+ });
11108
+ }
11109
+ }
11110
+ async function notificationsFeed(p = {}) {
11111
+ await scanDecisions();
11112
+ const pageSize = p.limit ? Math.max(1, Math.floor(+p.limit) || 1) : DEFAULT_FEED_TAKE;
11113
+ const page = p.page ? Math.max(1, Math.floor(+p.page) || 1) : 1;
11114
+ const total = await prisma.notification.count();
11115
+ const items = await prisma.notification.findMany({
11116
+ orderBy: { createdAt: "desc" },
11117
+ skip: (page - 1) * pageSize,
11118
+ take: pageSize
11119
+ });
11120
+ const unread = await prisma.notification.count({ where: { readAt: null } });
11121
+ return { items, unread, total, page, pageSize };
11122
+ }
11123
+ var awaiting, DEFAULT_FEED_TAKE;
11124
+ var init_notifications = __esm({
11125
+ "src/services/notifications.ts"() {
11126
+ "use strict";
11127
+ init_db();
11128
+ init_pty();
11129
+ awaiting = /* @__PURE__ */ new Set();
11130
+ DEFAULT_FEED_TAKE = 50;
11131
+ }
11132
+ });
11133
+
10891
11134
  // src/services/agent-token.ts
10892
11135
  import { randomBytes as randomBytes2, createHash, timingSafeEqual } from "node:crypto";
10893
11136
  function toAgentTokenView(t) {
@@ -16171,7 +16414,14 @@ function validateIncomingRecord(input) {
16171
16414
  if (!row.data || typeof row.data !== "object" || Array.isArray(row.data)) throw new Error("sync data invalid");
16172
16415
  if (Buffer.byteLength(JSON.stringify(input)) > MAX_SYNC_RECORD_BYTES) throw new Error("sync record terlalu besar");
16173
16416
  validateSyncData(row.entity, row.data, { allowProjectRename: true });
16174
- return { entity: row.entity, recordId: row.recordId, version: Number(row.version), data: row.data };
16417
+ const op = row.op === void 0 || row.op === null || row.op === "upsert" ? "upsert" : row.op === "delete" ? "delete" : null;
16418
+ return {
16419
+ entity: row.entity,
16420
+ recordId: row.recordId,
16421
+ version: Number(row.version),
16422
+ data: row.data,
16423
+ op
16424
+ };
16175
16425
  }
16176
16426
  async function getCursor() {
16177
16427
  const s2 = await prisma.syncState.findUnique({ where: { id: 1 } });
@@ -16180,15 +16430,50 @@ async function getCursor() {
16180
16430
  async function setCursor(cursor) {
16181
16431
  await prisma.syncState.upsert({ where: { id: 1 }, create: { id: 1, cursor }, update: { cursor } });
16182
16432
  }
16183
- async function applyRemote(entity, recordId, version2, data) {
16184
- if (!isEntity(entity)) return;
16433
+ async function applyRemote(entity, recordId, version2, data, op = "upsert") {
16434
+ if (!isEntity(entity)) return "dropped";
16435
+ if (op === "delete") {
16436
+ await applyRemoteDelete(entity, recordId, version2, data);
16437
+ return "applied";
16438
+ }
16439
+ const tomb = await findTombstone(entity, recordId);
16440
+ if (tomb) {
16441
+ if (version2 <= tomb.version) return "dropped";
16442
+ await clearTombstone(entity, recordId);
16443
+ }
16444
+ if (await parentTombstoned(entity, data)) return "dropped";
16185
16445
  await upsertLocal(entity, recordId, version2, data);
16446
+ return "applied";
16447
+ }
16448
+ async function applyRemoteDelete(entity, recordId, version2, data) {
16449
+ const existing = await snapshot(entity, recordId);
16450
+ await writeTombstone(entity, recordId, version2, existing?.data ?? data);
16451
+ if (existing) await deleteRow(entity, recordId);
16452
+ const pending2 = await prisma.syncOutbox.findFirst({ where: { entity, recordId } });
16453
+ if (!pending2) return;
16454
+ await clearOutbox(entity, recordId);
16455
+ if (existing) {
16456
+ await recordSyncDelete(
16457
+ entity,
16458
+ recordId,
16459
+ version2,
16460
+ `Dihapus di peer: ${entity} ${recordId} \u2014 suntingan lokal yang belum tersinkron dibuang`
16461
+ );
16462
+ }
16463
+ }
16464
+ async function parentTombstoned(entity, data) {
16465
+ for (const p of PARENTS[entity] ?? []) {
16466
+ const v = data[p.field];
16467
+ if (typeof v !== "string" || !v) continue;
16468
+ if (await findTombstone(p.entity, v)) return true;
16469
+ }
16470
+ return false;
16186
16471
  }
16187
16472
  async function applyFeedFrame(msg) {
16188
16473
  if (!msg.entity || !msg.recordId) return true;
16189
16474
  try {
16190
16475
  const record2 = validateIncomingRecord({ ...msg, version: Number(msg.version ?? 0), data: msg.data ?? {} });
16191
- await applyRemote(record2.entity, record2.recordId, record2.version, record2.data);
16476
+ if (record2.op) await applyRemote(record2.entity, record2.recordId, record2.version, record2.data, record2.op);
16192
16477
  } catch {
16193
16478
  feedHole = true;
16194
16479
  return false;
@@ -16197,7 +16482,7 @@ async function applyFeedFrame(msg) {
16197
16482
  return true;
16198
16483
  }
16199
16484
  async function syncOnce(transport) {
16200
- let pulled = 0, pushed = 0, conflicts = 0;
16485
+ let pulled = 0, pushed = 0, conflicts = 0, deleted = 0, dropped = 0;
16201
16486
  const cursor = await getCursor();
16202
16487
  const outbox = await listOutbox();
16203
16488
  const pending2 = new Set(outbox.map((o) => `${o.entity}:${o.recordId}`));
@@ -16216,7 +16501,11 @@ async function syncOnce(transport) {
16216
16501
  const deferred = [];
16217
16502
  for (const rec of records) {
16218
16503
  if (!isEntity(rec.entity)) continue;
16219
- if (pending2.has(`${rec.entity}:${rec.recordId}`)) {
16504
+ if (!rec.op) {
16505
+ dropped++;
16506
+ continue;
16507
+ }
16508
+ if (rec.op === "upsert" && pending2.has(`${rec.entity}:${rec.recordId}`)) {
16220
16509
  const local = await snapshot(rec.entity, rec.recordId);
16221
16510
  if (local && JSON.stringify(local.data) !== JSON.stringify(rec.data)) {
16222
16511
  await markConflict(
@@ -16229,8 +16518,10 @@ async function syncOnce(transport) {
16229
16518
  continue;
16230
16519
  }
16231
16520
  try {
16232
- await applyRemote(rec.entity, rec.recordId, rec.version, rec.data);
16233
- pulled++;
16521
+ const r = await applyRemote(rec.entity, rec.recordId, rec.version, rec.data, rec.op);
16522
+ if (r === "dropped") dropped++;
16523
+ else if (rec.op === "delete") deleted++;
16524
+ else pulled++;
16234
16525
  } catch {
16235
16526
  deferred.push(rec);
16236
16527
  }
@@ -16240,15 +16531,18 @@ async function syncOnce(transport) {
16240
16531
  const still = [];
16241
16532
  for (const rec of rest) {
16242
16533
  try {
16243
- await applyRemote(rec.entity, rec.recordId, rec.version, rec.data);
16244
- pulled++;
16534
+ const r = await applyRemote(rec.entity, rec.recordId, rec.version, rec.data, rec.op ?? "upsert");
16535
+ if (r === "dropped") dropped++;
16536
+ else if (rec.op === "delete") deleted++;
16537
+ else pulled++;
16245
16538
  } catch {
16246
16539
  still.push(rec);
16247
16540
  }
16248
16541
  }
16249
16542
  if (still.length === rest.length) {
16250
16543
  for (const rec of still) {
16251
- console.warn(`sync: record ${rec.entity}:${rec.recordId} tak bisa diterapkan (induk absen?) \u2014 dilewati`);
16544
+ console.warn(`sync: record ${rec.entity}:${rec.recordId} tak bisa diterapkan \u2014 dilewati`);
16545
+ dropped++;
16252
16546
  }
16253
16547
  break;
16254
16548
  }
@@ -16282,7 +16576,24 @@ async function syncOnce(transport) {
16282
16576
  }
16283
16577
  const snap = await snapshot(item.entity, item.recordId);
16284
16578
  if (!snap) {
16285
- await clearOutbox(item.entity, item.recordId);
16579
+ const tomb = await findTombstone(item.entity, item.recordId);
16580
+ if (!tomb) {
16581
+ await clearOutbox(item.entity, item.recordId);
16582
+ continue;
16583
+ }
16584
+ const res2 = await transport("POST", "/api/sync/push", {
16585
+ records: [{
16586
+ entity: item.entity,
16587
+ id: item.recordId,
16588
+ baseVersion: Math.max(tomb.version - 1, 0),
16589
+ op: "delete",
16590
+ data: tomb.data
16591
+ }]
16592
+ });
16593
+ if (res2.body?.results?.[0]?.ok) {
16594
+ await clearOutbox(item.entity, item.recordId);
16595
+ pushed++;
16596
+ }
16286
16597
  continue;
16287
16598
  }
16288
16599
  const res = await transport("POST", "/api/sync/push", {
@@ -16298,6 +16609,18 @@ async function syncOnce(transport) {
16298
16609
  await clearOutbox(item.entity, item.recordId);
16299
16610
  pushed++;
16300
16611
  } else if (r?.conflict) {
16612
+ if (r.deleted) {
16613
+ await applyRemote(
16614
+ item.entity,
16615
+ item.recordId,
16616
+ Number(r.deletedVersion ?? snap.version + 1),
16617
+ snap.data,
16618
+ "delete"
16619
+ );
16620
+ await clearOutbox(item.entity, item.recordId);
16621
+ deleted++;
16622
+ continue;
16623
+ }
16301
16624
  const server = r.server;
16302
16625
  if (server && JSON.stringify(server.data) !== JSON.stringify(snap.data)) {
16303
16626
  await markConflict(
@@ -16312,7 +16635,7 @@ async function syncOnce(transport) {
16312
16635
  }
16313
16636
  }
16314
16637
  }
16315
- return { pulled, pushed, conflicts };
16638
+ return { pulled, pushed, conflicts, deleted, dropped };
16316
16639
  }
16317
16640
  function fetchTransport(base2, token) {
16318
16641
  return async (method, path2, body) => {
@@ -16343,13 +16666,15 @@ async function syncNow(opts) {
16343
16666
  const transport = fetchTransport(base2, token);
16344
16667
  if (!opts?.full) return syncOnce(transport);
16345
16668
  await setCursor("0");
16346
- const total = { pulled: 0, pushed: 0, conflicts: 0 };
16669
+ const total = { pulled: 0, pushed: 0, conflicts: 0, deleted: 0, dropped: 0 };
16347
16670
  let seen = "0";
16348
16671
  for (let page = 0; page < FULL_PULL_MAX_PAGES; page++) {
16349
16672
  const s2 = await syncOnce(transport);
16350
16673
  total.pulled += s2.pulled;
16351
16674
  total.pushed += s2.pushed;
16352
16675
  total.conflicts += s2.conflicts;
16676
+ total.deleted += s2.deleted;
16677
+ total.dropped += s2.dropped;
16353
16678
  const now = await getCursor();
16354
16679
  if (now === seen) break;
16355
16680
  seen = now;
@@ -16432,6 +16757,8 @@ var init_sync_client = __esm({
16432
16757
  "use strict";
16433
16758
  init_db();
16434
16759
  init_sync();
16760
+ init_tombstone();
16761
+ init_notifications();
16435
16762
  init_conflicts();
16436
16763
  init_outbox();
16437
16764
  init_rename_project();
@@ -24804,11 +25131,42 @@ init_sync();
24804
25131
  async function notifySynced(entity, id) {
24805
25132
  try {
24806
25133
  if (!isEntity(entity)) return;
25134
+ await consumeTombstoneOnRecreate(entity, id);
24807
25135
  if (effectiveStr("SYNC_SERVER_URL")) await enqueueOutbox(entity, id);
24808
25136
  else await publishLocal(entity, id);
24809
25137
  } catch {
24810
25138
  }
24811
25139
  }
25140
+ async function notifyDeleted(entity, id) {
25141
+ try {
25142
+ if (!isEntity(entity)) return;
25143
+ if (effectiveStr("SYNC_SERVER_URL")) await enqueueOutbox(entity, id);
25144
+ else await publishDelete(entity, id);
25145
+ } catch {
25146
+ }
25147
+ }
25148
+
25149
+ // src/services/sync-delete.ts
25150
+ init_sync();
25151
+ init_tombstone();
25152
+ init_outbox();
25153
+ async function deleteSynced(entity, id, deviceId) {
25154
+ const snap = await snapshot(entity, id);
25155
+ if (!snap) return false;
25156
+ await deleteRow(entity, id);
25157
+ await writeTombstone(entity, id, snap.version + 1, snap.data, deviceId);
25158
+ await notifyDeleted(entity, id);
25159
+ return true;
25160
+ }
25161
+ async function listPendingDeletes() {
25162
+ const out3 = [];
25163
+ for (const item of await listOutbox()) {
25164
+ const tomb = await findTombstone(item.entity, item.recordId);
25165
+ if (!tomb) continue;
25166
+ out3.push({ entity: item.entity, recordId: item.recordId, deletedAt: tomb.deletedAt.toISOString() });
25167
+ }
25168
+ return out3;
25169
+ }
24812
25170
 
24813
25171
  // src/services/branches.ts
24814
25172
  import { execFile as execFile2 } from "node:child_process";
@@ -24962,7 +25320,7 @@ async function projects_default(app2) {
24962
25320
  if (!await prisma.project.findUnique({ where: { id } })) return reply.code(404).send({ error: "not found" });
24963
25321
  const active = listSessions().filter((s2) => s2.projectId === id && !s2.exited).length;
24964
25322
  if (active) return reply.code(409).send({ error: `project "${id}" masih punya ${active} sesi aktif` });
24965
- await prisma.project.delete({ where: { id } });
25323
+ await deleteSynced("project", id);
24966
25324
  return reply.code(204).send();
24967
25325
  });
24968
25326
  app2.get("/projects/:id/branches", async (req, reply) => {
@@ -25413,123 +25771,8 @@ function appendSourceHistory(current, entry) {
25413
25771
  return [...Array.isArray(current) ? current : [], entry];
25414
25772
  }
25415
25773
 
25416
- // src/services/notifications.ts
25417
- init_db();
25418
- init_pty();
25419
- async function recordDrift(vpsId, vpsName, drift, snapshotId) {
25420
- if (drift.length === 0) return;
25421
- const ids = drift.map((d) => d.itemId);
25422
- const shown = ids.slice(0, 5).join(", ") + (ids.length > 5 ? `, +${ids.length - 5} lagi` : "");
25423
- const title = `Drift di "${vpsName}": ${drift.length} item regresi (${shown})`;
25424
- await prisma.notification.create({
25425
- data: { type: "drift", key: `drift:${vpsId}:${snapshotId}`, title, projectId: null }
25426
- }).catch(() => {
25427
- });
25428
- }
25429
- async function recordCompletion(specId, title, projectId) {
25430
- await prisma.spec.updateMany({ where: { id: specId, doneAt: null }, data: { doneAt: /* @__PURE__ */ new Date() } }).catch(() => {
25431
- });
25432
- const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
25433
- await prisma.notification.create({
25434
- data: { type: "done", key: `done:${specId}`, specId, sessionId: sessionId2, title, projectId }
25435
- }).catch(() => {
25436
- });
25437
- }
25438
- async function recordFailure(specId, title, projectId, reason) {
25439
- const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
25440
- await prisma.notification.create({
25441
- data: { type: "fail", key: `fail:${specId}`, specId, sessionId: sessionId2, title: `Gagal: ${title} \u2014 ${reason}`, projectId }
25442
- }).catch(() => {
25443
- });
25444
- }
25445
- async function recordAutoMerge(specId, projectId, title) {
25446
- const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
25447
- await prisma.notification.create({
25448
- data: { type: "automerge", key: `automerge:${specId}`, specId, sessionId: sessionId2, title, projectId }
25449
- }).catch(() => {
25450
- });
25451
- }
25452
- async function recordCleanupFailure(sessionId2, projectId, entry, reason) {
25453
- await prisma.notification.create({
25454
- data: {
25455
- type: "cleanup",
25456
- key: `cleanup:${entry}`,
25457
- sessionId: sessionId2,
25458
- projectId,
25459
- title: `Worktree sesi ${sessionId2} gagal dibersihkan \u2014 ${reason}`
25460
- }
25461
- }).catch(() => {
25462
- });
25463
- }
25464
- async function recordSourceChange(specId, projectId, title, from, to, seq) {
25465
- const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
25466
- await prisma.notification.create({
25467
- data: {
25468
- type: "spec-source",
25469
- key: `source:${specId}:${seq}`,
25470
- specId,
25471
- sessionId: sessionId2,
25472
- title: `${specId} \xB7 type ${from} \u2192 ${to} \u2014 ${title}`,
25473
- projectId
25474
- }
25475
- }).catch(() => {
25476
- });
25477
- }
25478
- async function recordNewTicket(ticketId, projectId, projectName, category, title) {
25479
- const short = title.length > 80 ? title.slice(0, 77) + "\u2026" : title;
25480
- const t = `Keluhan baru di "${projectName}": ${category}: ${short}`;
25481
- await prisma.notification.create({
25482
- data: { type: "ticket", key: `ticket:${ticketId}`, projectId, title: t }
25483
- }).catch(() => {
25484
- });
25485
- }
25486
- async function recordLeadDecision(decisionId, title, projectId, specId, sessionId2) {
25487
- await prisma.notification.create({
25488
- data: { type: "lead", key: `lead:${decisionId}`, specId, sessionId: sessionId2, projectId, title }
25489
- }).catch(() => {
25490
- });
25491
- }
25492
- async function recordCronRun(cronId, cronName, projectId, dueAt, status, note) {
25493
- const verb = status === "launched" ? "berjalan" : status === "skipped" ? "dilewati" : "gagal";
25494
- const title = `Cron "${cronName}" ${verb}${note ? ` \u2014 ${note}` : ""}`;
25495
- await prisma.notification.create({
25496
- data: { type: "cron", key: `cron:${cronId}:${dueAt.toISOString()}`, title, projectId }
25497
- }).catch(() => {
25498
- });
25499
- }
25500
- var awaiting = /* @__PURE__ */ new Set();
25501
- async function scanDecisions(read2 = liveDecisions) {
25502
- const next = /* @__PURE__ */ new Set();
25503
- const fresh = [];
25504
- for (const s2 of read2()) {
25505
- if (!markerFilled(s2.decisionFile)) continue;
25506
- next.add(s2.id);
25507
- if (!awaiting.has(s2.id)) fresh.push(s2);
25508
- }
25509
- awaiting = next;
25510
- for (const s2 of fresh) {
25511
- const title = s2.specId ? (await prisma.spec.findUnique({ where: { id: s2.specId }, select: { title: true } }))?.title ?? s2.specId : s2.id;
25512
- await prisma.notification.create({
25513
- data: { type: "decision", specId: s2.specId ?? null, sessionId: s2.id, projectId: s2.projectId || null, title }
25514
- });
25515
- }
25516
- }
25517
- var DEFAULT_FEED_TAKE = 50;
25518
- async function notificationsFeed(p = {}) {
25519
- await scanDecisions();
25520
- const pageSize = p.limit ? Math.max(1, Math.floor(+p.limit) || 1) : DEFAULT_FEED_TAKE;
25521
- const page = p.page ? Math.max(1, Math.floor(+p.page) || 1) : 1;
25522
- const total = await prisma.notification.count();
25523
- const items = await prisma.notification.findMany({
25524
- orderBy: { createdAt: "desc" },
25525
- skip: (page - 1) * pageSize,
25526
- take: pageSize
25527
- });
25528
- const unread = await prisma.notification.count({ where: { readAt: null } });
25529
- return { items, unread, total, page, pageSize };
25530
- }
25531
-
25532
25774
  // src/routes/specs.ts
25775
+ init_notifications();
25533
25776
  init_stage_machine();
25534
25777
 
25535
25778
  // src/services/stage-artifacts.ts
@@ -28187,6 +28430,7 @@ init_db();
28187
28430
  init_pty();
28188
28431
  init_session_phases();
28189
28432
  init_stage_machine();
28433
+ init_notifications();
28190
28434
 
28191
28435
  // src/services/spec-head.ts
28192
28436
  init_db();
@@ -28469,7 +28713,7 @@ ${item.context}` : item.context;
28469
28713
  app2.delete("/specs/:id", async (req, reply) => {
28470
28714
  const { id } = req.params;
28471
28715
  const gone = await prisma.spec.findUnique({ where: { id }, select: { projectId: true } });
28472
- await prisma.spec.delete({ where: { id } }).catch(() => {
28716
+ await deleteSynced("spec", id).catch(() => {
28473
28717
  });
28474
28718
  if (gone) {
28475
28719
  const rows = await prisma.spec.findMany({
@@ -28555,6 +28799,7 @@ ${item.context}` : item.context;
28555
28799
 
28556
28800
  // src/routes/notifications.ts
28557
28801
  init_db();
28802
+ init_notifications();
28558
28803
  async function notifications_default(app2) {
28559
28804
  app2.get("/notifications", async (req) => notificationsFeed(req.query));
28560
28805
  app2.post("/notifications/read", async (_req, reply) => {
@@ -30176,6 +30421,7 @@ import { execFile as execFile8 } from "node:child_process";
30176
30421
  import { readdir as readdir2, rm as rm2 } from "node:fs/promises";
30177
30422
  import { promisify as promisify9 } from "node:util";
30178
30423
  import { join as join9, resolve as resolve14 } from "node:path";
30424
+ init_notifications();
30179
30425
  var TICK_MS = 6e4;
30180
30426
  var trashDirOf = (repoDir) => resolve14(repoDir, ".worktrees", ".trash");
30181
30427
  var exec8 = promisify9(execFile8);
@@ -30301,6 +30547,7 @@ async function recordSessionResult(input) {
30301
30547
  }
30302
30548
 
30303
30549
  // src/routes/terminal.ts
30550
+ init_notifications();
30304
30551
  init_stage_machine();
30305
30552
  init_pty();
30306
30553
  var TERMINAL_WS_MESSAGES_PER_MINUTE = 6e3;
@@ -31011,6 +31258,7 @@ function computeDrift(prev, curr) {
31011
31258
  }
31012
31259
 
31013
31260
  // src/services/vps-audit.ts
31261
+ init_notifications();
31014
31262
  var CRITICAL = [
31015
31263
  "sudo_ok",
31016
31264
  "os_supported",
@@ -31274,12 +31522,9 @@ async function vps_default(app2) {
31274
31522
  return updated;
31275
31523
  });
31276
31524
  app2.delete("/vps/:id", async (req, reply) => {
31277
- try {
31278
- await prisma.vps.delete({ where: { id: req.params.id } });
31279
- return reply.code(204).send();
31280
- } catch {
31281
- return reply.code(404).send({ error: "not found" });
31282
- }
31525
+ const { id } = req.params;
31526
+ if (!await deleteSynced("vps", id)) return reply.code(404).send({ error: "not found" });
31527
+ return reply.code(204).send();
31283
31528
  });
31284
31529
  app2.post("/vps/:id/audit", async (req, reply) => {
31285
31530
  const v = await prisma.vps.findUnique({ where: { id: req.params.id } });
@@ -31807,6 +32052,7 @@ async function update(app2) {
31807
32052
 
31808
32053
  // src/services/events.ts
31809
32054
  init_pty();
32055
+ init_notifications();
31810
32056
 
31811
32057
  // src/services/lead/deciding.ts
31812
32058
  var deciding = /* @__PURE__ */ new Set();
@@ -31975,7 +32221,7 @@ async function events_default(app2, opts) {
31975
32221
  // src/routes/device-tokens.ts
31976
32222
  init_src();
31977
32223
  init_db();
31978
- var view = (t) => ({
32224
+ var view2 = (t) => ({
31979
32225
  id: t.id,
31980
32226
  name: t.name,
31981
32227
  createdAt: t.createdAt.toISOString(),
@@ -31983,7 +32229,7 @@ var view = (t) => ({
31983
32229
  revokedAt: t.revokedAt?.toISOString() ?? null
31984
32230
  });
31985
32231
  async function device_tokens_default(app2) {
31986
- app2.get("/device-tokens", async (req) => (await prisma.deviceToken.findMany({ where: { userId: req.user.id }, orderBy: { createdAt: "desc" } })).map(view));
32232
+ app2.get("/device-tokens", async (req) => (await prisma.deviceToken.findMany({ where: { userId: req.user.id }, orderBy: { createdAt: "desc" } })).map(view2));
31987
32233
  app2.post("/device-tokens", async (req, reply) => {
31988
32234
  const p = zIssueDeviceToken.safeParse(req.body);
31989
32235
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
@@ -32133,7 +32379,10 @@ var zPush = external_exports.object({
32133
32379
  entity: external_exports.string(),
32134
32380
  id: external_exports.string(),
32135
32381
  baseVersion: external_exports.number().int().nonnegative(),
32136
- data: external_exports.record(external_exports.unknown())
32382
+ data: external_exports.record(external_exports.unknown()),
32383
+ // SPEC-799 · ADR-0119 · absen = "upsert" (client versi lama). Hub versi lama membuang field ini
32384
+ // dan sekadar memperlakukan push delete sebagai update — status quo, bukan galat.
32385
+ op: external_exports.enum(["upsert", "delete"]).optional()
32137
32386
  }))
32138
32387
  });
32139
32388
  var AUTHORED = ["spec", "sessionResult"];
@@ -32163,7 +32412,7 @@ async function sync_default(app2) {
32163
32412
  }
32164
32413
  const data = { ...rec.data };
32165
32414
  if (AUTHORED.includes(rec.entity) && !data.author && user) data.author = user.email;
32166
- const r = await applyPush(rec.entity, rec.id, rec.baseVersion, data, req.device.id);
32415
+ const r = await applyPush(rec.entity, rec.id, rec.baseVersion, data, req.device.id, rec.op ?? "upsert");
32167
32416
  results.push({ id: rec.id, ...r });
32168
32417
  }
32169
32418
  return { results };
@@ -32175,6 +32424,10 @@ async function sync_default(app2) {
32175
32424
  return { ok: true, full, ...stats2 };
32176
32425
  });
32177
32426
  app2.get("/sync/conflicts", async () => ({ conflicts: await listConflicts() }));
32427
+ app2.get("/sync/pending", async () => {
32428
+ const deletes = await listPendingDeletes();
32429
+ return { deletes, total: deletes.length };
32430
+ });
32178
32431
  const zResolve = external_exports.object({ choice: external_exports.enum(["local", "server"]) });
32179
32432
  app2.post("/sync/conflicts/:entity/:recordId/resolve", async (req, reply) => {
32180
32433
  const { entity, recordId } = req.params;
@@ -32225,7 +32478,7 @@ async function sync_default(app2) {
32225
32478
 
32226
32479
  // src/routes/session-results.ts
32227
32480
  init_db();
32228
- var view2 = (r) => ({
32481
+ var view3 = (r) => ({
32229
32482
  id: r.id,
32230
32483
  projectId: r.projectId,
32231
32484
  specId: r.specId,
@@ -32248,7 +32501,7 @@ async function session_results_default(app2) {
32248
32501
  orderBy: { createdAt: "desc" },
32249
32502
  take: take2
32250
32503
  });
32251
- return rows.map(view2);
32504
+ return rows.map(view3);
32252
32505
  });
32253
32506
  app2.delete("/session-results", async (req, reply) => {
32254
32507
  const { projectId, before: before2 } = req.query;
@@ -32260,8 +32513,10 @@ async function session_results_default(app2) {
32260
32513
  if (Number.isNaN(d.getTime())) return reply.code(400).send({ error: "before bukan tanggal valid" });
32261
32514
  where.createdAt = { lt: d };
32262
32515
  }
32263
- const { count } = await prisma.sessionResult.deleteMany({ where });
32264
- return { purged: count };
32516
+ const rows = await prisma.sessionResult.findMany({ where, select: { id: true } });
32517
+ let purged = 0;
32518
+ for (const r of rows) if (await deleteSynced("sessionResult", r.id)) purged++;
32519
+ return { purged };
32265
32520
  });
32266
32521
  }
32267
32522
 
@@ -32314,7 +32569,7 @@ async function deleteTranscript(key) {
32314
32569
  }
32315
32570
 
32316
32571
  // src/services/session-history.ts
32317
- var view3 = (r) => ({
32572
+ var view4 = (r) => ({
32318
32573
  id: r.id,
32319
32574
  sessionId: r.sessionId,
32320
32575
  projectId: r.projectId,
@@ -32398,11 +32653,11 @@ async function listHistory(q) {
32398
32653
  skip: (page - 1) * pageSize,
32399
32654
  take: pageSize
32400
32655
  });
32401
- return { items: rows.map(view3), total, page, pageSize };
32656
+ return { items: rows.map(view4), total, page, pageSize };
32402
32657
  }
32403
32658
  async function getHistory(id) {
32404
32659
  const r = await prisma.sessionHistory.findUnique({ where: { id } });
32405
- return r ? { ...view3(r), hasTranscript: !!r.transcriptKey } : null;
32660
+ return r ? { ...view4(r), hasTranscript: !!r.transcriptKey } : null;
32406
32661
  }
32407
32662
  async function transcriptOf(id) {
32408
32663
  const r = await prisma.sessionHistory.findUnique({ where: { id }, select: { transcriptKey: true } });
@@ -32478,7 +32733,7 @@ init_config_apply();
32478
32733
  init_sync_client();
32479
32734
  var isSecret2 = (e) => e.kind === "secret";
32480
32735
  var agentBlocked = (req, e) => Boolean(req.agent) && e.category === "credential";
32481
- function view4(e) {
32736
+ function view5(e) {
32482
32737
  const eff = effectiveStr(e.key);
32483
32738
  const base2 = {
32484
32739
  key: e.key,
@@ -32498,7 +32753,7 @@ function view4(e) {
32498
32753
  }
32499
32754
  async function config_default(app2) {
32500
32755
  app2.get("/config", async () => ({
32501
- entries: CONFIG_REGISTRY.map(view4),
32756
+ entries: CONFIG_REGISTRY.map(view5),
32502
32757
  sync: syncStatus()
32503
32758
  }));
32504
32759
  app2.put("/config", async (req, reply) => {
@@ -32510,7 +32765,7 @@ async function config_default(app2) {
32510
32765
  const raw2 = b.value ?? "";
32511
32766
  if (isSecret2(entry) && raw2.trim() === "") {
32512
32767
  if (rawDbValue(entry.key) === void 0) return reply.code(400).send({ error: "tak boleh kosong" });
32513
- return view4(entry);
32768
+ return view5(entry);
32514
32769
  }
32515
32770
  const parsed = parseConfigValue(entry, raw2);
32516
32771
  if (!parsed.ok) return reply.code(400).send({ error: parsed.error });
@@ -32520,11 +32775,11 @@ async function config_default(app2) {
32520
32775
  } catch (error) {
32521
32776
  return reply.code(400).send({ error: error.message });
32522
32777
  }
32523
- return view4(entry);
32778
+ return view5(entry);
32524
32779
  }
32525
32780
  await setConfig(entry.key, parsed.value);
32526
32781
  await applyConfigSideEffect(entry.key);
32527
- return view4(entry);
32782
+ return view5(entry);
32528
32783
  });
32529
32784
  app2.delete("/config/:key", async (req, reply) => {
32530
32785
  const { key } = req.params;
@@ -32590,6 +32845,7 @@ async function pruneOldTickets(now = Date.now()) {
32590
32845
 
32591
32846
  // src/services/ticket-intake.ts
32592
32847
  init_db();
32848
+ init_notifications();
32593
32849
 
32594
32850
  // src/services/upload-pipeline.ts
32595
32851
  import { randomUUID as randomUUID6 } from "node:crypto";
@@ -33823,25 +34079,25 @@ var cachedDecoders = {
33823
34079
  };
33824
34080
  var cachedEncoder = new globalThis.TextEncoder();
33825
34081
  var byteToHexLookupTable = Array.from({ length: 256 }, (_, index) => index.toString(16).padStart(2, "0"));
33826
- function getUintBE(view11) {
33827
- const { byteLength } = view11;
34082
+ function getUintBE(view12) {
34083
+ const { byteLength } = view12;
33828
34084
  if (byteLength === 6) {
33829
- return view11.getUint16(0) * 2 ** 32 + view11.getUint32(2);
34085
+ return view12.getUint16(0) * 2 ** 32 + view12.getUint32(2);
33830
34086
  }
33831
34087
  if (byteLength === 5) {
33832
- return view11.getUint8(0) * 2 ** 32 + view11.getUint32(1);
34088
+ return view12.getUint8(0) * 2 ** 32 + view12.getUint32(1);
33833
34089
  }
33834
34090
  if (byteLength === 4) {
33835
- return view11.getUint32(0);
34091
+ return view12.getUint32(0);
33836
34092
  }
33837
34093
  if (byteLength === 3) {
33838
- return view11.getUint8(0) * 2 ** 16 + view11.getUint16(1);
34094
+ return view12.getUint8(0) * 2 ** 16 + view12.getUint16(1);
33839
34095
  }
33840
34096
  if (byteLength === 2) {
33841
- return view11.getUint16(0);
34097
+ return view12.getUint16(0);
33842
34098
  }
33843
34099
  if (byteLength === 1) {
33844
- return view11.getUint8(0);
34100
+ return view12.getUint8(0);
33845
34101
  }
33846
34102
  }
33847
34103
 
@@ -40326,7 +40582,7 @@ async function acceptTicket(t, opts) {
40326
40582
 
40327
40583
  // src/routes/tickets.ts
40328
40584
  init_src();
40329
- var view5 = (t) => ({
40585
+ var view6 = (t) => ({
40330
40586
  id: t.id,
40331
40587
  projectId: t.projectId,
40332
40588
  number: t.number,
@@ -40354,7 +40610,7 @@ async function tickets_default(app2) {
40354
40610
  rows = rows.filter((t) => `${t.title} ${t.reporterEmail}`.toLowerCase().includes(n2));
40355
40611
  }
40356
40612
  const unreviewed = rows.filter((t) => t.status === "new").length;
40357
- return { ...paginate(rows.map(view5), page, limit), unreviewed };
40613
+ return { ...paginate(rows.map(view6), page, limit), unreviewed };
40358
40614
  });
40359
40615
  app2.get("/tickets/:id", async (req, reply) => {
40360
40616
  const { id } = req.params;
@@ -40372,7 +40628,7 @@ async function tickets_default(app2) {
40372
40628
  const base2 = `${req.protocol}://${req.headers.host ?? "localhost"}`;
40373
40629
  const publicStatusUrl = `${base2}/help/${encodeURIComponent(t.projectId)}/status/${shareToken}`;
40374
40630
  return {
40375
- ...view5(t),
40631
+ ...view6(t),
40376
40632
  detail: t.detail,
40377
40633
  attachments: t.attachments.map((a) => ({ id: a.id, filename: a.filename, mimeType: a.mimeType, size: a.size })),
40378
40634
  spec,
@@ -40432,7 +40688,7 @@ async function tickets_default(app2) {
40432
40688
  });
40433
40689
  const spec = updated.specId ? await prisma.spec.findUnique({ where: { id: updated.specId } }) : null;
40434
40690
  return {
40435
- ...view5(updated),
40691
+ ...view6(updated),
40436
40692
  detail: updated.detail,
40437
40693
  attachments: updated.attachments.map((a) => ({ id: a.id, filename: a.filename, mimeType: a.mimeType, size: a.size })),
40438
40694
  spec
@@ -40443,7 +40699,7 @@ async function tickets_default(app2) {
40443
40699
  const t = await prisma.ticket.findUnique({ where: { id }, include: { attachments: true } });
40444
40700
  if (!t) return reply.code(404).send({ error: "not found" });
40445
40701
  for (const a of t.attachments) await deleteUpload(a.storageKey);
40446
- await prisma.ticket.delete({ where: { id } });
40702
+ await deleteSynced("ticket", id);
40447
40703
  return { ok: true };
40448
40704
  });
40449
40705
  }
@@ -40550,6 +40806,7 @@ async function isQueued(id) {
40550
40806
  // src/services/scheduler/cron.ts
40551
40807
  init_db();
40552
40808
  init_src();
40809
+ init_notifications();
40553
40810
  var GRACE_MS = 30 * 6e4;
40554
40811
  function computeNextRun(expr, after) {
40555
40812
  const spec = parseCron(expr);
@@ -40900,6 +41157,7 @@ async function leadProjects() {
40900
41157
  init_src();
40901
41158
  init_db();
40902
41159
  init_pty();
41160
+ init_notifications();
40903
41161
 
40904
41162
  // src/services/lead/prompt.ts
40905
41163
  init_src();
@@ -41671,6 +41929,7 @@ init_src();
41671
41929
  init_db();
41672
41930
  init_pty();
41673
41931
  init_session_phases();
41932
+ init_notifications();
41674
41933
 
41675
41934
  // src/services/webhooks/actor.ts
41676
41935
  init_src();
@@ -41823,6 +42082,7 @@ Bukti integrasi: ${evidence.join("; ")} \u2192 ${verdict}.` }
41823
42082
  init_src();
41824
42083
  init_pty();
41825
42084
  init_tui_dialog();
42085
+ init_notifications();
41826
42086
  import { writeFileSync as writeFileSync6 } from "node:fs";
41827
42087
 
41828
42088
  // src/services/lead/pane.ts
@@ -42143,6 +42403,7 @@ function sweep(liveIds) {
42143
42403
  init_db();
42144
42404
  init_pty();
42145
42405
  init_session_phases();
42406
+ init_notifications();
42146
42407
  var moduleOf = (p) => p.split("/").slice(0, 2).join("/");
42147
42408
  function findCollisions(areas) {
42148
42409
  const out3 = [];
@@ -42410,6 +42671,7 @@ async function orderProject(projectId, deps) {
42410
42671
  }
42411
42672
 
42412
42673
  // src/services/lead/engine.ts
42674
+ init_notifications();
42413
42675
  var TICK_MS2 = 5e3;
42414
42676
  var busyDetect = false;
42415
42677
  var busyPulse = false;
@@ -42924,7 +43186,7 @@ async function generateChangelog(projectId, req, deps = {}) {
42924
43186
  }
42925
43187
 
42926
43188
  // src/routes/changelog.ts
42927
- var view6 = (c) => ({
43189
+ var view7 = (c) => ({
42928
43190
  id: c.id,
42929
43191
  projectId: c.projectId,
42930
43192
  mode: c.mode,
@@ -42964,7 +43226,7 @@ async function changelog_default(app2) {
42964
43226
  if (!await prisma.project.findUnique({ where: { id } })) return reply.code(404).send({ error: "not found" });
42965
43227
  const { page, limit, q } = req.query;
42966
43228
  const rows = await prisma.changelog.findMany({ where: { projectId: id }, orderBy: { createdAt: "desc" } });
42967
- return paginate(rows.filter((r) => changelogMatches(r, q ?? "")).map(view6), page, limit);
43229
+ return paginate(rows.filter((r) => changelogMatches(r, q ?? "")).map(view7), page, limit);
42968
43230
  });
42969
43231
  app2.get("/projects/:id/changelog/:cid", async (req, reply) => {
42970
43232
  const { id, cid } = req.params;
@@ -42978,7 +43240,7 @@ async function changelog_default(app2) {
42978
43240
  eyebrow: `hanoman \xB7 ${id} \xB7 changelog`,
42979
43241
  path: row.title
42980
43242
  });
42981
- return view6(row);
43243
+ return view7(row);
42982
43244
  });
42983
43245
  app2.post("/projects/:id/changelog", async (req, reply) => {
42984
43246
  const { id } = req.params;
@@ -42987,7 +43249,7 @@ async function changelog_default(app2) {
42987
43249
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
42988
43250
  const r = await generateChangelog(id, parsed.data);
42989
43251
  if (!r.ok) return reply.code(422).send({ error: r.reason });
42990
- return reply.code(201).send(view6(r.row));
43252
+ return reply.code(201).send(view7(r.row));
42991
43253
  });
42992
43254
  app2.delete("/projects/:id/changelog/:cid", async (req, reply) => {
42993
43255
  const { id, cid } = req.params;
@@ -43126,7 +43388,7 @@ async function installCustomAgents() {
43126
43388
 
43127
43389
  // src/routes/custom-agents.ts
43128
43390
  var rowsOf = async () => await prisma.customAgent.findMany();
43129
- var view7 = (r, projectId) => ({
43391
+ var view8 = (r, projectId) => ({
43130
43392
  id: r.id,
43131
43393
  projectId: r.projectId,
43132
43394
  name: r.name,
@@ -43171,7 +43433,7 @@ async function custom_agents_default(app2) {
43171
43433
  const byName = /* @__PURE__ */ new Map();
43172
43434
  for (const r of rows) if (r.projectId === null) byName.set(r.name, r);
43173
43435
  for (const r of rows) if (r.projectId !== null) byName.set(r.name, r);
43174
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)).map((r) => view7(r, projectId));
43436
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)).map((r) => view8(r, projectId));
43175
43437
  });
43176
43438
  app2.post("/custom-agents", async (req, reply) => {
43177
43439
  const parsed = zCreateCustomAgent.safeParse(req.body);
@@ -43218,7 +43480,7 @@ async function custom_agents_default(app2) {
43218
43480
  } });
43219
43481
  await loadCustomAgents();
43220
43482
  await notifySynced("customAgent", id);
43221
- return reply.code(201).send(view7(row));
43483
+ return reply.code(201).send(view8(row));
43222
43484
  });
43223
43485
  app2.patch("/custom-agents/:id", async (req, reply) => {
43224
43486
  const { id } = req.params;
@@ -43265,13 +43527,13 @@ async function custom_agents_default(app2) {
43265
43527
  } });
43266
43528
  await loadCustomAgents();
43267
43529
  await notifySynced("customAgent", id);
43268
- return view7(row);
43530
+ return view8(row);
43269
43531
  });
43270
43532
  app2.delete("/custom-agents/:id", async (req, reply) => {
43271
43533
  const { id } = req.params;
43272
43534
  const existing = await prisma.customAgent.findUnique({ where: { id } });
43273
43535
  if (!existing) return reply.code(404).send({ error: "not found" });
43274
- await prisma.customAgent.delete({ where: { id } });
43536
+ await deleteSynced("customAgent", id);
43275
43537
  const name2 = existing.name;
43276
43538
  for (const r of await rowsOf()) {
43277
43539
  const m = mentionsOf(r.mentions);
@@ -43606,7 +43868,7 @@ var STATUS = {
43606
43868
  unauthorized: 401,
43607
43869
  other: 502
43608
43870
  };
43609
- var view8 = (i) => ({
43871
+ var view9 = (i) => ({
43610
43872
  id: i.id,
43611
43873
  projectId: i.projectId,
43612
43874
  repoSlug: i.repoSlug,
@@ -43641,7 +43903,7 @@ async function githubIssues(app2) {
43641
43903
  where: { projectId: id, ...status ? { status } : {} },
43642
43904
  orderBy: [{ number: "desc" }]
43643
43905
  });
43644
- return reply.send(paginate(items.map(view8), page, limit));
43906
+ return reply.send(paginate(items.map(view9), page, limit));
43645
43907
  });
43646
43908
  app2.post("/github-issues/accept", async (req, reply) => {
43647
43909
  const parsed = zAcceptMany.safeParse(req.body ?? {});
@@ -44401,7 +44663,7 @@ async function portal_default(app2) {
44401
44663
  // src/routes/client-accounts.ts
44402
44664
  init_src();
44403
44665
  init_db();
44404
- var view9 = (u) => ({
44666
+ var view10 = (u) => ({
44405
44667
  id: u.id,
44406
44668
  email: u.email,
44407
44669
  disabled: u.disabled,
@@ -44432,7 +44694,7 @@ async function client_accounts_default(app2) {
44432
44694
  orderBy: { createdAt: "asc" },
44433
44695
  include: { projectAccess: { select: { projectId: true } } }
44434
44696
  });
44435
- return { items: rows.map(view9) };
44697
+ return { items: rows.map(view10) };
44436
44698
  });
44437
44699
  app2.post("/client-accounts", async (req, reply) => {
44438
44700
  const p = zCreateClientAccount.safeParse(req.body);
@@ -44446,7 +44708,7 @@ async function client_accounts_default(app2) {
44446
44708
  await prisma.user.delete({ where: { id: user.id } });
44447
44709
  return reply.code(400).send({ error: "ada project yang tidak dikenal" });
44448
44710
  }
44449
- return reply.code(201).send(view9(await load(user.id)));
44711
+ return reply.code(201).send(view10(await load(user.id)));
44450
44712
  });
44451
44713
  app2.patch("/client-accounts/:id", async (req, reply) => {
44452
44714
  const { id } = req.params;
@@ -44460,7 +44722,7 @@ async function client_accounts_default(app2) {
44460
44722
  if (p.data.password)
44461
44723
  await prisma.user.update({ where: { id }, data: { passwordHash: await hashPassword(p.data.password) } });
44462
44724
  if (p.data.disabled || p.data.password) await deleteUserSessions(id);
44463
- return view9(await load(id));
44725
+ return view10(await load(id));
44464
44726
  });
44465
44727
  app2.delete("/client-accounts/:id", async (req, reply) => {
44466
44728
  const { id } = req.params;
@@ -44556,7 +44818,7 @@ async function consumeSetupToken(home3) {
44556
44818
 
44557
44819
  // src/routes/auth.ts
44558
44820
  var BOOTSTRAP_USER_ID = "bootstrap-admin";
44559
- var view10 = (u) => ({ id: u.id, email: u.email, role: u.role, createdAt: u.createdAt.toISOString() });
44821
+ var view11 = (u) => ({ id: u.id, email: u.email, role: u.role, createdAt: u.createdAt.toISOString() });
44560
44822
  async function issue(reply, userId) {
44561
44823
  const token = await createSession2(userId);
44562
44824
  reply.setCookie(COOKIE_NAME, token, cookieOpts());
@@ -44596,7 +44858,7 @@ async function auth_default(app2, opts) {
44596
44858
  if (opts.bootstrapRequired) await consumeSetupToken(opts.home);
44597
44859
  setupAttempts.clear(req.ip);
44598
44860
  await issue(reply, user.id);
44599
- return { user: view10(user) };
44861
+ return { user: view11(user) };
44600
44862
  });
44601
44863
  app2.post("/auth/login", async (req, reply) => {
44602
44864
  if (loginThrottle(req.ip).blocked) return reply.code(429).send({ error: "too many attempts" });
@@ -44609,7 +44871,7 @@ async function auth_default(app2, opts) {
44609
44871
  }
44610
44872
  clearLoginFails(req.ip);
44611
44873
  await issue(reply, user.id);
44612
- return { user: view10(user) };
44874
+ return { user: view11(user) };
44613
44875
  });
44614
44876
  app2.post("/auth/logout", async (req, reply) => {
44615
44877
  const token = req.cookies?.[COOKIE_NAME];
@@ -44617,7 +44879,7 @@ async function auth_default(app2, opts) {
44617
44879
  reply.clearCookie(COOKIE_NAME, { path: "/" });
44618
44880
  return reply.code(204).send();
44619
44881
  });
44620
- app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view10));
44882
+ app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view11));
44621
44883
  app2.post("/auth/users", async (req, reply) => {
44622
44884
  const p = zSignup.safeParse(req.body);
44623
44885
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
@@ -44628,7 +44890,7 @@ async function auth_default(app2, opts) {
44628
44890
  // (/api/client-accounts) supaya "undang rekan" dan "beri akses klien" tak pernah tertukar.
44629
44891
  data: { email: p.data.email, passwordHash: await hashPassword(p.data.password), role: "admin" }
44630
44892
  });
44631
- return view10(user);
44893
+ return view11(user);
44632
44894
  });
44633
44895
  app2.delete("/auth/users/:id", async (req, reply) => {
44634
44896
  const target2 = await prisma.user.findUnique({ where: { id: req.params.id } });
@@ -44651,7 +44913,7 @@ async function auth_default(app2, opts) {
44651
44913
  });
44652
44914
  await deleteUserSessions(user.id);
44653
44915
  await issue(reply, user.id);
44654
- return { user: view10(user) };
44916
+ return { user: view11(user) };
44655
44917
  });
44656
44918
  }
44657
44919
 
@@ -44664,15 +44926,15 @@ async function agent_tokens_default(app2) {
44664
44926
  app2.post("/agent-tokens", async (req, reply) => {
44665
44927
  const p = zAgentTokenCreate.safeParse(req.body);
44666
44928
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
44667
- const { view: view11, token } = await issueAgentToken({ ...p.data, createdBy: req.user?.id });
44668
- return reply.code(201).send({ ...view11, token });
44929
+ const { view: view12, token } = await issueAgentToken({ ...p.data, createdBy: req.user?.id });
44930
+ return reply.code(201).send({ ...view12, token });
44669
44931
  });
44670
44932
  app2.patch("/agent-tokens/:id", async (req, reply) => {
44671
44933
  const p = zAgentTokenPatch.safeParse(req.body);
44672
44934
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
44673
44935
  const { id } = req.params;
44674
- const view11 = await patchAgentToken(id, p.data);
44675
- return view11 ? reply.send(view11) : reply.code(404).send({ error: "not found" });
44936
+ const view12 = await patchAgentToken(id, p.data);
44937
+ return view12 ? reply.send(view12) : reply.code(404).send({ error: "not found" });
44676
44938
  });
44677
44939
  app2.delete("/agent-tokens/:id", async (req, reply) => {
44678
44940
  const { id } = req.params;
@@ -44971,7 +45233,7 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
44971
45233
  if (PUBLIC.has(`${req.method} ${path2}`)) return;
44972
45234
  if (user?.role === "client" && !clientRouteAllowed(req.method, path2))
44973
45235
  return reply.code(403).send({ error: "portal klien: baca-saja" });
44974
- if (path2.startsWith("/api/sync") && path2 !== "/api/sync/now" && !path2.startsWith("/api/sync/conflicts")) return;
45236
+ if (path2.startsWith("/api/sync") && path2 !== "/api/sync/now" && path2 !== "/api/sync/pending" && !path2.startsWith("/api/sync/conflicts")) return;
44975
45237
  if (path2.startsWith("/api/help")) return;
44976
45238
  if (user) return;
44977
45239
  const agentTok = agentTokenFromReq(req);
@@ -45085,6 +45347,7 @@ init_src();
45085
45347
 
45086
45348
  // src/services/scheduler/governor.ts
45087
45349
  init_db();
45350
+ init_notifications();
45088
45351
  var ALREADY_DONE_NOTE = "spec sudah selesai \u2014 tak diluncurkan";
45089
45352
  var canceledRaceNote = (sessionId2) => `dibatalkan saat sesi ${sessionId2} sudah terlanjur lahir \u2014 sesi dibiarkan hidup, tutup dari Terminal bila tak diperlukan`;
45090
45353
  var CAP_FULL_NOTE = "cap penuh \u2014 tak ada slot sesi";
@@ -45219,6 +45482,7 @@ async function startCronSession(cron) {
45219
45482
  // src/services/scheduler/reconcile.ts
45220
45483
  init_db();
45221
45484
  init_src2();
45485
+ init_notifications();
45222
45486
  init_stage_machine();
45223
45487
  init_pty();
45224
45488
  init_session_phases();
@@ -45282,6 +45546,7 @@ var reconcileProdDeps = {
45282
45546
  };
45283
45547
 
45284
45548
  // src/services/scheduler/engine.ts
45549
+ init_notifications();
45285
45550
  init_pty();
45286
45551
  var prodEnd = {
45287
45552
  reconcile: () => reconcile(reconcileProdDeps),
@@ -45656,6 +45921,7 @@ init_src();
45656
45921
  init_db();
45657
45922
  import { execFile as execFile13 } from "node:child_process";
45658
45923
  import { promisify as promisify12 } from "node:util";
45924
+ init_notifications();
45659
45925
  var AUTO_MERGE_WINDOW_MS = 24 * 60 * 60 * 1e3;
45660
45926
  var AUTO_MERGE_GRACE_MS = 15 * 60 * 1e3;
45661
45927
  var TICK_MS5 = 6e4;