hanoman 0.1.38 → 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
@@ -6092,6 +6092,7 @@ var init_api = __esm({
6092
6092
  // SPEC-233 · drop commit isolasi
6093
6093
  fsBrowse: (path2) => `${API}/fs/browse${path2 ? `?path=${encodeURIComponent(path2)}` : ""}`,
6094
6094
  terminalSessions: `${API}/terminal/sessions`,
6095
+ terminalWorkspace: `${API}/terminal/workspace`,
6095
6096
  // SPEC-742 · ADR-0116 · pembersihan worktree yang masih tertunda sesudah sesi ditutup. Di bawah
6096
6097
  // prefix /terminal supaya ikut capability `sessions` yang sudah ada (cermin sessionHistory).
6097
6098
  terminalCleanups: `${API}/terminal/cleanups`,
@@ -6194,6 +6195,8 @@ var init_api = __esm({
6194
6195
  syncNow: `${API}/sync/now`,
6195
6196
  // SPEC-270 · ADR-0067 · antrean konflik rekonsil (cookie-authed)
6196
6197
  syncConflicts: `${API}/sync/conflicts`,
6198
+ // SPEC-799 · ADR-0119 · penghapusan yang menunggu jendela online (cookie-only, cermin syncNow).
6199
+ syncPending: `${API}/sync/pending`,
6197
6200
  syncConflictResolve: (entity, recordId) => `${API}/sync/conflicts/${encodeURIComponent(entity)}/${encodeURIComponent(recordId)}/resolve`,
6198
6201
  // SPEC-257 · ADR-0065 · agent token (kelola cookie-only) + katalog capability
6199
6202
  agentTokens: `${API}/agent-tokens`,
@@ -7701,6 +7704,70 @@ var init_changelog = __esm({
7701
7704
  }
7702
7705
  });
7703
7706
 
7707
+ // ../shared/src/terminal-workspace.ts
7708
+ var zSessionId, zTerminalGroup, zTerminalWorkspaceV1, zTerminalWorkspaceWrite, zTerminalWorkspaceSnapshot;
7709
+ var init_terminal_workspace = __esm({
7710
+ "../shared/src/terminal-workspace.ts"() {
7711
+ "use strict";
7712
+ init_zod();
7713
+ zSessionId = external_exports.string().trim().min(1).max(256);
7714
+ zTerminalGroup = external_exports.object({
7715
+ id: external_exports.string().trim().min(1).max(128),
7716
+ name: external_exports.string().trim().min(1).max(80),
7717
+ layout: external_exports.object({
7718
+ rows: external_exports.number().int().min(1).max(12),
7719
+ cols: external_exports.number().int().min(1).max(12),
7720
+ cells: external_exports.array(zSessionId.nullable()).max(144)
7721
+ }).strict().superRefine((layout, ctx) => {
7722
+ if (layout.cells.length !== layout.rows * layout.cols) {
7723
+ ctx.addIssue({
7724
+ code: external_exports.ZodIssueCode.custom,
7725
+ path: ["cells"],
7726
+ message: "cells must match rows \xD7 cols"
7727
+ });
7728
+ }
7729
+ })
7730
+ }).strict();
7731
+ zTerminalWorkspaceV1 = external_exports.object({
7732
+ version: external_exports.literal(1),
7733
+ groups: external_exports.array(zTerminalGroup).min(1).max(24)
7734
+ }).strict().superRefine((workspace, ctx) => {
7735
+ const groupIds = /* @__PURE__ */ new Set();
7736
+ const sessionIds = /* @__PURE__ */ new Set();
7737
+ workspace.groups.forEach((group2, groupIndex) => {
7738
+ if (groupIds.has(group2.id)) {
7739
+ ctx.addIssue({
7740
+ code: external_exports.ZodIssueCode.custom,
7741
+ path: ["groups", groupIndex, "id"],
7742
+ message: "group id must be unique"
7743
+ });
7744
+ }
7745
+ groupIds.add(group2.id);
7746
+ group2.layout.cells.forEach((sessionId2, cellIndex) => {
7747
+ if (sessionId2 === null) return;
7748
+ if (sessionIds.has(sessionId2)) {
7749
+ ctx.addIssue({
7750
+ code: external_exports.ZodIssueCode.custom,
7751
+ path: ["groups", groupIndex, "layout", "cells", cellIndex],
7752
+ message: "sessionId must be unique across the workspace"
7753
+ });
7754
+ }
7755
+ sessionIds.add(sessionId2);
7756
+ });
7757
+ });
7758
+ });
7759
+ zTerminalWorkspaceWrite = external_exports.object({
7760
+ baseRevision: external_exports.number().int().min(0),
7761
+ workspace: zTerminalWorkspaceV1
7762
+ }).strict();
7763
+ zTerminalWorkspaceSnapshot = external_exports.object({
7764
+ workspace: zTerminalWorkspaceV1.nullable(),
7765
+ revision: external_exports.number().int().min(0),
7766
+ updatedAt: external_exports.string().datetime().nullable()
7767
+ }).strict();
7768
+ }
7769
+ });
7770
+
7704
7771
  // ../shared/src/index.ts
7705
7772
  var init_src = __esm({
7706
7773
  "../shared/src/index.ts"() {
@@ -7732,6 +7799,7 @@ var init_src = __esm({
7732
7799
  init_auto_merge();
7733
7800
  init_changelog();
7734
7801
  init_cron_expr();
7802
+ init_terminal_workspace();
7735
7803
  }
7736
7804
  });
7737
7805
 
@@ -10421,16 +10489,54 @@ var init_rename_project = __esm({
10421
10489
  }
10422
10490
  });
10423
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
+
10424
10526
  // src/services/sync.ts
10425
10527
  var sync_exports = {};
10426
10528
  __export(sync_exports, {
10529
+ PARENTS: () => PARENTS,
10427
10530
  SYNCED: () => SYNCED,
10428
10531
  __DATE_FIELDS: () => __DATE_FIELDS,
10429
10532
  __FIELDS: () => __FIELDS,
10430
10533
  __FIELDS_FOR_TEST: () => __FIELDS_FOR_TEST,
10431
10534
  applyPush: () => applyPush,
10432
10535
  backfillFeed: () => backfillFeed,
10536
+ consumeTombstoneOnRecreate: () => consumeTombstoneOnRecreate,
10537
+ deleteRow: () => deleteRow,
10433
10538
  isEntity: () => isEntity,
10539
+ publishDelete: () => publishDelete,
10434
10540
  publishLocal: () => publishLocal,
10435
10541
  pull: () => pull,
10436
10542
  setAcceptedHook: () => setAcceptedHook,
@@ -10495,10 +10601,24 @@ async function snapshot(entity, id) {
10495
10601
  for (const f of FIELDS[entity]) data[f] = jsonSafe(row[f]);
10496
10602
  return { version: Number(row.version), data };
10497
10603
  }
10498
- async function applyPush(entity, id, baseVersion, data, deviceId) {
10604
+ async function applyPush(entity, id, baseVersion, data, deviceId, op = "upsert") {
10499
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
+ }
10500
10616
  if (entity === "project" && typeof data.renamedFrom === "string" && data.renamedFrom && data.renamedFrom !== id) {
10501
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
+ }
10502
10622
  const already = await DELEGATE.project.findUnique({ where: { id }, select: { version: true } });
10503
10623
  if (already) return { ok: true, version: Number(already.version) };
10504
10624
  const old = await DELEGATE.project.findUnique({ where: { id: oldId }, select: { version: true } });
@@ -10512,17 +10632,24 @@ async function applyPush(entity, id, baseVersion, data, deviceId) {
10512
10632
  const snap2 = await snapshot("project", id);
10513
10633
  const logData = { ...snap2?.data ?? {}, renamedFrom: oldId };
10514
10634
  const log3 = await prisma.syncLog.create({
10515
- 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 }
10516
10636
  });
10517
- 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) });
10518
10638
  return { ok: true, version: newVersion2 };
10519
10639
  }
10520
10640
  }
10641
+ const tomb = await findTombstone(entity, id);
10521
10642
  const existing = await DELEGATE[entity].findUnique({ where: { id }, select: { version: true } });
10522
- if (existing && Number(existing.version) !== baseVersion) {
10523
- 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
+ };
10524
10651
  }
10525
- const newVersion = existing ? Number(existing.version) + 1 : 1;
10652
+ const newVersion = (currentVersion ?? 0) + 1;
10526
10653
  const writeData = coerce2(entity, data);
10527
10654
  const stamp2 = writeData.updatedAt ?? /* @__PURE__ */ new Date();
10528
10655
  await DELEGATE[entity].upsert({
@@ -10530,11 +10657,12 @@ async function applyPush(entity, id, baseVersion, data, deviceId) {
10530
10657
  create: { id, ...writeData, version: newVersion, updatedAt: stamp2 },
10531
10658
  update: { ...writeData, version: newVersion, updatedAt: stamp2 }
10532
10659
  });
10660
+ if (tomb) await clearTombstone(entity, id);
10533
10661
  const snap = await snapshot(entity, id);
10534
10662
  const log2 = await prisma.syncLog.create({
10535
- 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 }
10536
10664
  });
10537
- 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) });
10538
10666
  return { ok: true, version: newVersion };
10539
10667
  }
10540
10668
  async function pull(sinceCursor, limit = 500) {
@@ -10547,7 +10675,13 @@ async function pull(sinceCursor, limit = 500) {
10547
10675
  const cursor = rows.length ? String(rows[rows.length - 1].seq) : sinceCursor || "0";
10548
10676
  return {
10549
10677
  cursor,
10550
- 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
+ }))
10551
10685
  };
10552
10686
  }
10553
10687
  async function publishLocal(entity, id) {
@@ -10556,9 +10690,38 @@ async function publishLocal(entity, id) {
10556
10690
  const newVersion = snap.version + 1;
10557
10691
  await DELEGATE[entity].update({ where: { id }, data: { version: newVersion } });
10558
10692
  const log2 = await prisma.syncLog.create({
10559
- 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 }
10560
10694
  });
10561
- onAccepted?.({ entity, recordId: id, version: newVersion, data: snap.data ?? {}, seq: String(log2.seq) });
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
+ }
10712
+ });
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;
10562
10725
  }
10563
10726
  async function backfillFeed() {
10564
10727
  let published = 0;
@@ -10574,6 +10737,16 @@ async function backfillFeed() {
10574
10737
  published++;
10575
10738
  }
10576
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
+ }
10577
10750
  return published;
10578
10751
  }
10579
10752
  async function upsertLocal(entity, id, version2, data) {
@@ -10601,12 +10774,13 @@ async function upsertLocal(entity, id, version2, data) {
10601
10774
  function setAcceptedHook(hook) {
10602
10775
  onAccepted = hook;
10603
10776
  }
10604
- 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;
10605
10778
  var init_sync = __esm({
10606
10779
  "src/services/sync.ts"() {
10607
10780
  "use strict";
10608
10781
  init_db();
10609
10782
  init_rename_project();
10783
+ init_tombstone();
10610
10784
  SYNCED = ["project", "spec", "vps", "sessionResult", "ticket", "ticketAttachment", "customAgent", "githubIssue"];
10611
10785
  DELEGATE = {
10612
10786
  project: prisma.project,
@@ -10680,6 +10854,13 @@ var init_sync = __esm({
10680
10854
  customAgent: ["createdAt", "updatedAt"],
10681
10855
  githubIssue: ["issueCreatedAt", "issueUpdatedAt", "pulledAt", "createdAt", "updatedAt"]
10682
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
+ };
10683
10864
  __FIELDS = FIELDS;
10684
10865
  __DATE_FIELDS = DATE_FIELDS;
10685
10866
  NUMBER_FIELDS = /* @__PURE__ */ new Set([
@@ -10822,6 +11003,134 @@ var init_codex_trust = __esm({
10822
11003
  }
10823
11004
  });
10824
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
+
10825
11134
  // src/services/agent-token.ts
10826
11135
  import { randomBytes as randomBytes2, createHash, timingSafeEqual } from "node:crypto";
10827
11136
  function toAgentTokenView(t) {
@@ -16105,7 +16414,14 @@ function validateIncomingRecord(input) {
16105
16414
  if (!row.data || typeof row.data !== "object" || Array.isArray(row.data)) throw new Error("sync data invalid");
16106
16415
  if (Buffer.byteLength(JSON.stringify(input)) > MAX_SYNC_RECORD_BYTES) throw new Error("sync record terlalu besar");
16107
16416
  validateSyncData(row.entity, row.data, { allowProjectRename: true });
16108
- 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
+ };
16109
16425
  }
16110
16426
  async function getCursor() {
16111
16427
  const s2 = await prisma.syncState.findUnique({ where: { id: 1 } });
@@ -16114,15 +16430,50 @@ async function getCursor() {
16114
16430
  async function setCursor(cursor) {
16115
16431
  await prisma.syncState.upsert({ where: { id: 1 }, create: { id: 1, cursor }, update: { cursor } });
16116
16432
  }
16117
- async function applyRemote(entity, recordId, version2, data) {
16118
- 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";
16119
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;
16120
16471
  }
16121
16472
  async function applyFeedFrame(msg) {
16122
16473
  if (!msg.entity || !msg.recordId) return true;
16123
16474
  try {
16124
16475
  const record2 = validateIncomingRecord({ ...msg, version: Number(msg.version ?? 0), data: msg.data ?? {} });
16125
- 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);
16126
16477
  } catch {
16127
16478
  feedHole = true;
16128
16479
  return false;
@@ -16131,7 +16482,7 @@ async function applyFeedFrame(msg) {
16131
16482
  return true;
16132
16483
  }
16133
16484
  async function syncOnce(transport) {
16134
- let pulled = 0, pushed = 0, conflicts = 0;
16485
+ let pulled = 0, pushed = 0, conflicts = 0, deleted = 0, dropped = 0;
16135
16486
  const cursor = await getCursor();
16136
16487
  const outbox = await listOutbox();
16137
16488
  const pending2 = new Set(outbox.map((o) => `${o.entity}:${o.recordId}`));
@@ -16150,7 +16501,11 @@ async function syncOnce(transport) {
16150
16501
  const deferred = [];
16151
16502
  for (const rec of records) {
16152
16503
  if (!isEntity(rec.entity)) continue;
16153
- 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}`)) {
16154
16509
  const local = await snapshot(rec.entity, rec.recordId);
16155
16510
  if (local && JSON.stringify(local.data) !== JSON.stringify(rec.data)) {
16156
16511
  await markConflict(
@@ -16163,8 +16518,10 @@ async function syncOnce(transport) {
16163
16518
  continue;
16164
16519
  }
16165
16520
  try {
16166
- await applyRemote(rec.entity, rec.recordId, rec.version, rec.data);
16167
- 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++;
16168
16525
  } catch {
16169
16526
  deferred.push(rec);
16170
16527
  }
@@ -16174,15 +16531,18 @@ async function syncOnce(transport) {
16174
16531
  const still = [];
16175
16532
  for (const rec of rest) {
16176
16533
  try {
16177
- await applyRemote(rec.entity, rec.recordId, rec.version, rec.data);
16178
- 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++;
16179
16538
  } catch {
16180
16539
  still.push(rec);
16181
16540
  }
16182
16541
  }
16183
16542
  if (still.length === rest.length) {
16184
16543
  for (const rec of still) {
16185
- 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++;
16186
16546
  }
16187
16547
  break;
16188
16548
  }
@@ -16216,7 +16576,24 @@ async function syncOnce(transport) {
16216
16576
  }
16217
16577
  const snap = await snapshot(item.entity, item.recordId);
16218
16578
  if (!snap) {
16219
- 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
+ }
16220
16597
  continue;
16221
16598
  }
16222
16599
  const res = await transport("POST", "/api/sync/push", {
@@ -16232,6 +16609,18 @@ async function syncOnce(transport) {
16232
16609
  await clearOutbox(item.entity, item.recordId);
16233
16610
  pushed++;
16234
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
+ }
16235
16624
  const server = r.server;
16236
16625
  if (server && JSON.stringify(server.data) !== JSON.stringify(snap.data)) {
16237
16626
  await markConflict(
@@ -16246,7 +16635,7 @@ async function syncOnce(transport) {
16246
16635
  }
16247
16636
  }
16248
16637
  }
16249
- return { pulled, pushed, conflicts };
16638
+ return { pulled, pushed, conflicts, deleted, dropped };
16250
16639
  }
16251
16640
  function fetchTransport(base2, token) {
16252
16641
  return async (method, path2, body) => {
@@ -16277,13 +16666,15 @@ async function syncNow(opts) {
16277
16666
  const transport = fetchTransport(base2, token);
16278
16667
  if (!opts?.full) return syncOnce(transport);
16279
16668
  await setCursor("0");
16280
- const total = { pulled: 0, pushed: 0, conflicts: 0 };
16669
+ const total = { pulled: 0, pushed: 0, conflicts: 0, deleted: 0, dropped: 0 };
16281
16670
  let seen = "0";
16282
16671
  for (let page = 0; page < FULL_PULL_MAX_PAGES; page++) {
16283
16672
  const s2 = await syncOnce(transport);
16284
16673
  total.pulled += s2.pulled;
16285
16674
  total.pushed += s2.pushed;
16286
16675
  total.conflicts += s2.conflicts;
16676
+ total.deleted += s2.deleted;
16677
+ total.dropped += s2.dropped;
16287
16678
  const now = await getCursor();
16288
16679
  if (now === seen) break;
16289
16680
  seen = now;
@@ -16366,6 +16757,8 @@ var init_sync_client = __esm({
16366
16757
  "use strict";
16367
16758
  init_db();
16368
16759
  init_sync();
16760
+ init_tombstone();
16761
+ init_notifications();
16369
16762
  init_conflicts();
16370
16763
  init_outbox();
16371
16764
  init_rename_project();
@@ -24738,11 +25131,42 @@ init_sync();
24738
25131
  async function notifySynced(entity, id) {
24739
25132
  try {
24740
25133
  if (!isEntity(entity)) return;
25134
+ await consumeTombstoneOnRecreate(entity, id);
24741
25135
  if (effectiveStr("SYNC_SERVER_URL")) await enqueueOutbox(entity, id);
24742
25136
  else await publishLocal(entity, id);
24743
25137
  } catch {
24744
25138
  }
24745
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
+ }
24746
25170
 
24747
25171
  // src/services/branches.ts
24748
25172
  import { execFile as execFile2 } from "node:child_process";
@@ -24896,7 +25320,7 @@ async function projects_default(app2) {
24896
25320
  if (!await prisma.project.findUnique({ where: { id } })) return reply.code(404).send({ error: "not found" });
24897
25321
  const active = listSessions().filter((s2) => s2.projectId === id && !s2.exited).length;
24898
25322
  if (active) return reply.code(409).send({ error: `project "${id}" masih punya ${active} sesi aktif` });
24899
- await prisma.project.delete({ where: { id } });
25323
+ await deleteSynced("project", id);
24900
25324
  return reply.code(204).send();
24901
25325
  });
24902
25326
  app2.get("/projects/:id/branches", async (req, reply) => {
@@ -25347,123 +25771,8 @@ function appendSourceHistory(current, entry) {
25347
25771
  return [...Array.isArray(current) ? current : [], entry];
25348
25772
  }
25349
25773
 
25350
- // src/services/notifications.ts
25351
- init_db();
25352
- init_pty();
25353
- async function recordDrift(vpsId, vpsName, drift, snapshotId) {
25354
- if (drift.length === 0) return;
25355
- const ids = drift.map((d) => d.itemId);
25356
- const shown = ids.slice(0, 5).join(", ") + (ids.length > 5 ? `, +${ids.length - 5} lagi` : "");
25357
- const title = `Drift di "${vpsName}": ${drift.length} item regresi (${shown})`;
25358
- await prisma.notification.create({
25359
- data: { type: "drift", key: `drift:${vpsId}:${snapshotId}`, title, projectId: null }
25360
- }).catch(() => {
25361
- });
25362
- }
25363
- async function recordCompletion(specId, title, projectId) {
25364
- await prisma.spec.updateMany({ where: { id: specId, doneAt: null }, data: { doneAt: /* @__PURE__ */ new Date() } }).catch(() => {
25365
- });
25366
- const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
25367
- await prisma.notification.create({
25368
- data: { type: "done", key: `done:${specId}`, specId, sessionId: sessionId2, title, projectId }
25369
- }).catch(() => {
25370
- });
25371
- }
25372
- async function recordFailure(specId, title, projectId, reason) {
25373
- const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
25374
- await prisma.notification.create({
25375
- data: { type: "fail", key: `fail:${specId}`, specId, sessionId: sessionId2, title: `Gagal: ${title} \u2014 ${reason}`, projectId }
25376
- }).catch(() => {
25377
- });
25378
- }
25379
- async function recordAutoMerge(specId, projectId, title) {
25380
- const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
25381
- await prisma.notification.create({
25382
- data: { type: "automerge", key: `automerge:${specId}`, specId, sessionId: sessionId2, title, projectId }
25383
- }).catch(() => {
25384
- });
25385
- }
25386
- async function recordCleanupFailure(sessionId2, projectId, entry, reason) {
25387
- await prisma.notification.create({
25388
- data: {
25389
- type: "cleanup",
25390
- key: `cleanup:${entry}`,
25391
- sessionId: sessionId2,
25392
- projectId,
25393
- title: `Worktree sesi ${sessionId2} gagal dibersihkan \u2014 ${reason}`
25394
- }
25395
- }).catch(() => {
25396
- });
25397
- }
25398
- async function recordSourceChange(specId, projectId, title, from, to, seq) {
25399
- const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
25400
- await prisma.notification.create({
25401
- data: {
25402
- type: "spec-source",
25403
- key: `source:${specId}:${seq}`,
25404
- specId,
25405
- sessionId: sessionId2,
25406
- title: `${specId} \xB7 type ${from} \u2192 ${to} \u2014 ${title}`,
25407
- projectId
25408
- }
25409
- }).catch(() => {
25410
- });
25411
- }
25412
- async function recordNewTicket(ticketId, projectId, projectName, category, title) {
25413
- const short = title.length > 80 ? title.slice(0, 77) + "\u2026" : title;
25414
- const t = `Keluhan baru di "${projectName}": ${category}: ${short}`;
25415
- await prisma.notification.create({
25416
- data: { type: "ticket", key: `ticket:${ticketId}`, projectId, title: t }
25417
- }).catch(() => {
25418
- });
25419
- }
25420
- async function recordLeadDecision(decisionId, title, projectId, specId, sessionId2) {
25421
- await prisma.notification.create({
25422
- data: { type: "lead", key: `lead:${decisionId}`, specId, sessionId: sessionId2, projectId, title }
25423
- }).catch(() => {
25424
- });
25425
- }
25426
- async function recordCronRun(cronId, cronName, projectId, dueAt, status, note) {
25427
- const verb = status === "launched" ? "berjalan" : status === "skipped" ? "dilewati" : "gagal";
25428
- const title = `Cron "${cronName}" ${verb}${note ? ` \u2014 ${note}` : ""}`;
25429
- await prisma.notification.create({
25430
- data: { type: "cron", key: `cron:${cronId}:${dueAt.toISOString()}`, title, projectId }
25431
- }).catch(() => {
25432
- });
25433
- }
25434
- var awaiting = /* @__PURE__ */ new Set();
25435
- async function scanDecisions(read2 = liveDecisions) {
25436
- const next = /* @__PURE__ */ new Set();
25437
- const fresh = [];
25438
- for (const s2 of read2()) {
25439
- if (!markerFilled(s2.decisionFile)) continue;
25440
- next.add(s2.id);
25441
- if (!awaiting.has(s2.id)) fresh.push(s2);
25442
- }
25443
- awaiting = next;
25444
- for (const s2 of fresh) {
25445
- const title = s2.specId ? (await prisma.spec.findUnique({ where: { id: s2.specId }, select: { title: true } }))?.title ?? s2.specId : s2.id;
25446
- await prisma.notification.create({
25447
- data: { type: "decision", specId: s2.specId ?? null, sessionId: s2.id, projectId: s2.projectId || null, title }
25448
- });
25449
- }
25450
- }
25451
- var DEFAULT_FEED_TAKE = 50;
25452
- async function notificationsFeed(p = {}) {
25453
- await scanDecisions();
25454
- const pageSize = p.limit ? Math.max(1, Math.floor(+p.limit) || 1) : DEFAULT_FEED_TAKE;
25455
- const page = p.page ? Math.max(1, Math.floor(+p.page) || 1) : 1;
25456
- const total = await prisma.notification.count();
25457
- const items = await prisma.notification.findMany({
25458
- orderBy: { createdAt: "desc" },
25459
- skip: (page - 1) * pageSize,
25460
- take: pageSize
25461
- });
25462
- const unread = await prisma.notification.count({ where: { readAt: null } });
25463
- return { items, unread, total, page, pageSize };
25464
- }
25465
-
25466
25774
  // src/routes/specs.ts
25775
+ init_notifications();
25467
25776
  init_stage_machine();
25468
25777
 
25469
25778
  // src/services/stage-artifacts.ts
@@ -28121,6 +28430,7 @@ init_db();
28121
28430
  init_pty();
28122
28431
  init_session_phases();
28123
28432
  init_stage_machine();
28433
+ init_notifications();
28124
28434
 
28125
28435
  // src/services/spec-head.ts
28126
28436
  init_db();
@@ -28403,7 +28713,7 @@ ${item.context}` : item.context;
28403
28713
  app2.delete("/specs/:id", async (req, reply) => {
28404
28714
  const { id } = req.params;
28405
28715
  const gone = await prisma.spec.findUnique({ where: { id }, select: { projectId: true } });
28406
- await prisma.spec.delete({ where: { id } }).catch(() => {
28716
+ await deleteSynced("spec", id).catch(() => {
28407
28717
  });
28408
28718
  if (gone) {
28409
28719
  const rows = await prisma.spec.findMany({
@@ -28489,6 +28799,7 @@ ${item.context}` : item.context;
28489
28799
 
28490
28800
  // src/routes/notifications.ts
28491
28801
  init_db();
28802
+ init_notifications();
28492
28803
  async function notifications_default(app2) {
28493
28804
  app2.get("/notifications", async (req) => notificationsFeed(req.query));
28494
28805
  app2.post("/notifications/read", async (_req, reply) => {
@@ -30110,6 +30421,7 @@ import { execFile as execFile8 } from "node:child_process";
30110
30421
  import { readdir as readdir2, rm as rm2 } from "node:fs/promises";
30111
30422
  import { promisify as promisify9 } from "node:util";
30112
30423
  import { join as join9, resolve as resolve14 } from "node:path";
30424
+ init_notifications();
30113
30425
  var TICK_MS = 6e4;
30114
30426
  var trashDirOf = (repoDir) => resolve14(repoDir, ".worktrees", ".trash");
30115
30427
  var exec8 = promisify9(execFile8);
@@ -30235,6 +30547,7 @@ async function recordSessionResult(input) {
30235
30547
  }
30236
30548
 
30237
30549
  // src/routes/terminal.ts
30550
+ init_notifications();
30238
30551
  init_stage_machine();
30239
30552
  init_pty();
30240
30553
  var TERMINAL_WS_MESSAGES_PER_MINUTE = 6e3;
@@ -30618,6 +30931,93 @@ async function terminal_default(app2, opts) {
30618
30931
  });
30619
30932
  }
30620
30933
 
30934
+ // src/routes/terminal-workspace.ts
30935
+ init_src();
30936
+
30937
+ // src/services/terminal-workspace.ts
30938
+ init_src();
30939
+ init_db();
30940
+ import "@prisma/client";
30941
+ var InvalidStoredTerminalWorkspaceError = class extends Error {
30942
+ constructor() {
30943
+ super("stored terminal workspace is invalid");
30944
+ }
30945
+ };
30946
+ async function readTerminalWorkspace(userId) {
30947
+ const user = await prisma.user.findUniqueOrThrow({
30948
+ where: { id: userId },
30949
+ select: {
30950
+ terminalWorkspace: true,
30951
+ terminalWorkspaceRevision: true,
30952
+ terminalWorkspaceUpdatedAt: true
30953
+ }
30954
+ });
30955
+ if (user.terminalWorkspace === null) {
30956
+ return {
30957
+ workspace: null,
30958
+ revision: user.terminalWorkspaceRevision,
30959
+ updatedAt: user.terminalWorkspaceUpdatedAt?.toISOString() ?? null
30960
+ };
30961
+ }
30962
+ const parsed = zTerminalWorkspaceV1.safeParse(user.terminalWorkspace);
30963
+ if (!parsed.success) throw new InvalidStoredTerminalWorkspaceError();
30964
+ return {
30965
+ workspace: parsed.data,
30966
+ revision: user.terminalWorkspaceRevision,
30967
+ updatedAt: user.terminalWorkspaceUpdatedAt?.toISOString() ?? null
30968
+ };
30969
+ }
30970
+ async function writeTerminalWorkspace(userId, input) {
30971
+ await readTerminalWorkspace(userId);
30972
+ const updatedAt = /* @__PURE__ */ new Date();
30973
+ const changed = await prisma.user.updateMany({
30974
+ where: { id: userId, terminalWorkspaceRevision: input.baseRevision },
30975
+ data: {
30976
+ terminalWorkspace: input.workspace,
30977
+ terminalWorkspaceRevision: { increment: 1 },
30978
+ terminalWorkspaceUpdatedAt: updatedAt
30979
+ }
30980
+ });
30981
+ if (changed.count === 1) {
30982
+ return {
30983
+ ok: true,
30984
+ current: {
30985
+ workspace: input.workspace,
30986
+ revision: input.baseRevision + 1,
30987
+ updatedAt: updatedAt.toISOString()
30988
+ }
30989
+ };
30990
+ }
30991
+ return { ok: false, current: await readTerminalWorkspace(userId) };
30992
+ }
30993
+
30994
+ // src/routes/terminal-workspace.ts
30995
+ var storedInvalid = (reply) => reply.code(422).send({ error: "stored terminal workspace is invalid" });
30996
+ async function terminalWorkspaceRoutes(app2) {
30997
+ app2.get("/terminal/workspace", async (request, reply) => {
30998
+ try {
30999
+ return await readTerminalWorkspace(request.user.id);
31000
+ } catch (error) {
31001
+ if (error instanceof InvalidStoredTerminalWorkspaceError) return storedInvalid(reply);
31002
+ throw error;
31003
+ }
31004
+ });
31005
+ app2.put("/terminal/workspace", async (request, reply) => {
31006
+ const parsed = zTerminalWorkspaceWrite.safeParse(request.body);
31007
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
31008
+ try {
31009
+ const result = await writeTerminalWorkspace(request.user.id, parsed.data);
31010
+ if (!result.ok) {
31011
+ return reply.code(409).send({ code: "revision-conflict", current: result.current });
31012
+ }
31013
+ return result.current;
31014
+ } catch (error) {
31015
+ if (error instanceof InvalidStoredTerminalWorkspaceError) return storedInvalid(reply);
31016
+ throw error;
31017
+ }
31018
+ });
31019
+ }
31020
+
30621
31021
  // src/routes/vps.ts
30622
31022
  init_db();
30623
31023
  init_src();
@@ -30858,6 +31258,7 @@ function computeDrift(prev, curr) {
30858
31258
  }
30859
31259
 
30860
31260
  // src/services/vps-audit.ts
31261
+ init_notifications();
30861
31262
  var CRITICAL = [
30862
31263
  "sudo_ok",
30863
31264
  "os_supported",
@@ -31121,12 +31522,9 @@ async function vps_default(app2) {
31121
31522
  return updated;
31122
31523
  });
31123
31524
  app2.delete("/vps/:id", async (req, reply) => {
31124
- try {
31125
- await prisma.vps.delete({ where: { id: req.params.id } });
31126
- return reply.code(204).send();
31127
- } catch {
31128
- return reply.code(404).send({ error: "not found" });
31129
- }
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();
31130
31528
  });
31131
31529
  app2.post("/vps/:id/audit", async (req, reply) => {
31132
31530
  const v = await prisma.vps.findUnique({ where: { id: req.params.id } });
@@ -31654,6 +32052,7 @@ async function update(app2) {
31654
32052
 
31655
32053
  // src/services/events.ts
31656
32054
  init_pty();
32055
+ init_notifications();
31657
32056
 
31658
32057
  // src/services/lead/deciding.ts
31659
32058
  var deciding = /* @__PURE__ */ new Set();
@@ -31822,7 +32221,7 @@ async function events_default(app2, opts) {
31822
32221
  // src/routes/device-tokens.ts
31823
32222
  init_src();
31824
32223
  init_db();
31825
- var view = (t) => ({
32224
+ var view2 = (t) => ({
31826
32225
  id: t.id,
31827
32226
  name: t.name,
31828
32227
  createdAt: t.createdAt.toISOString(),
@@ -31830,7 +32229,7 @@ var view = (t) => ({
31830
32229
  revokedAt: t.revokedAt?.toISOString() ?? null
31831
32230
  });
31832
32231
  async function device_tokens_default(app2) {
31833
- 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));
31834
32233
  app2.post("/device-tokens", async (req, reply) => {
31835
32234
  const p = zIssueDeviceToken.safeParse(req.body);
31836
32235
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
@@ -31980,7 +32379,10 @@ var zPush = external_exports.object({
31980
32379
  entity: external_exports.string(),
31981
32380
  id: external_exports.string(),
31982
32381
  baseVersion: external_exports.number().int().nonnegative(),
31983
- 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()
31984
32386
  }))
31985
32387
  });
31986
32388
  var AUTHORED = ["spec", "sessionResult"];
@@ -32010,7 +32412,7 @@ async function sync_default(app2) {
32010
32412
  }
32011
32413
  const data = { ...rec.data };
32012
32414
  if (AUTHORED.includes(rec.entity) && !data.author && user) data.author = user.email;
32013
- 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");
32014
32416
  results.push({ id: rec.id, ...r });
32015
32417
  }
32016
32418
  return { results };
@@ -32022,6 +32424,10 @@ async function sync_default(app2) {
32022
32424
  return { ok: true, full, ...stats2 };
32023
32425
  });
32024
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
+ });
32025
32431
  const zResolve = external_exports.object({ choice: external_exports.enum(["local", "server"]) });
32026
32432
  app2.post("/sync/conflicts/:entity/:recordId/resolve", async (req, reply) => {
32027
32433
  const { entity, recordId } = req.params;
@@ -32072,7 +32478,7 @@ async function sync_default(app2) {
32072
32478
 
32073
32479
  // src/routes/session-results.ts
32074
32480
  init_db();
32075
- var view2 = (r) => ({
32481
+ var view3 = (r) => ({
32076
32482
  id: r.id,
32077
32483
  projectId: r.projectId,
32078
32484
  specId: r.specId,
@@ -32095,7 +32501,7 @@ async function session_results_default(app2) {
32095
32501
  orderBy: { createdAt: "desc" },
32096
32502
  take: take2
32097
32503
  });
32098
- return rows.map(view2);
32504
+ return rows.map(view3);
32099
32505
  });
32100
32506
  app2.delete("/session-results", async (req, reply) => {
32101
32507
  const { projectId, before: before2 } = req.query;
@@ -32107,8 +32513,10 @@ async function session_results_default(app2) {
32107
32513
  if (Number.isNaN(d.getTime())) return reply.code(400).send({ error: "before bukan tanggal valid" });
32108
32514
  where.createdAt = { lt: d };
32109
32515
  }
32110
- const { count } = await prisma.sessionResult.deleteMany({ where });
32111
- 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 };
32112
32520
  });
32113
32521
  }
32114
32522
 
@@ -32161,7 +32569,7 @@ async function deleteTranscript(key) {
32161
32569
  }
32162
32570
 
32163
32571
  // src/services/session-history.ts
32164
- var view3 = (r) => ({
32572
+ var view4 = (r) => ({
32165
32573
  id: r.id,
32166
32574
  sessionId: r.sessionId,
32167
32575
  projectId: r.projectId,
@@ -32245,11 +32653,11 @@ async function listHistory(q) {
32245
32653
  skip: (page - 1) * pageSize,
32246
32654
  take: pageSize
32247
32655
  });
32248
- return { items: rows.map(view3), total, page, pageSize };
32656
+ return { items: rows.map(view4), total, page, pageSize };
32249
32657
  }
32250
32658
  async function getHistory(id) {
32251
32659
  const r = await prisma.sessionHistory.findUnique({ where: { id } });
32252
- return r ? { ...view3(r), hasTranscript: !!r.transcriptKey } : null;
32660
+ return r ? { ...view4(r), hasTranscript: !!r.transcriptKey } : null;
32253
32661
  }
32254
32662
  async function transcriptOf(id) {
32255
32663
  const r = await prisma.sessionHistory.findUnique({ where: { id }, select: { transcriptKey: true } });
@@ -32325,7 +32733,7 @@ init_config_apply();
32325
32733
  init_sync_client();
32326
32734
  var isSecret2 = (e) => e.kind === "secret";
32327
32735
  var agentBlocked = (req, e) => Boolean(req.agent) && e.category === "credential";
32328
- function view4(e) {
32736
+ function view5(e) {
32329
32737
  const eff = effectiveStr(e.key);
32330
32738
  const base2 = {
32331
32739
  key: e.key,
@@ -32345,7 +32753,7 @@ function view4(e) {
32345
32753
  }
32346
32754
  async function config_default(app2) {
32347
32755
  app2.get("/config", async () => ({
32348
- entries: CONFIG_REGISTRY.map(view4),
32756
+ entries: CONFIG_REGISTRY.map(view5),
32349
32757
  sync: syncStatus()
32350
32758
  }));
32351
32759
  app2.put("/config", async (req, reply) => {
@@ -32357,7 +32765,7 @@ async function config_default(app2) {
32357
32765
  const raw2 = b.value ?? "";
32358
32766
  if (isSecret2(entry) && raw2.trim() === "") {
32359
32767
  if (rawDbValue(entry.key) === void 0) return reply.code(400).send({ error: "tak boleh kosong" });
32360
- return view4(entry);
32768
+ return view5(entry);
32361
32769
  }
32362
32770
  const parsed = parseConfigValue(entry, raw2);
32363
32771
  if (!parsed.ok) return reply.code(400).send({ error: parsed.error });
@@ -32367,11 +32775,11 @@ async function config_default(app2) {
32367
32775
  } catch (error) {
32368
32776
  return reply.code(400).send({ error: error.message });
32369
32777
  }
32370
- return view4(entry);
32778
+ return view5(entry);
32371
32779
  }
32372
32780
  await setConfig(entry.key, parsed.value);
32373
32781
  await applyConfigSideEffect(entry.key);
32374
- return view4(entry);
32782
+ return view5(entry);
32375
32783
  });
32376
32784
  app2.delete("/config/:key", async (req, reply) => {
32377
32785
  const { key } = req.params;
@@ -32437,6 +32845,7 @@ async function pruneOldTickets(now = Date.now()) {
32437
32845
 
32438
32846
  // src/services/ticket-intake.ts
32439
32847
  init_db();
32848
+ init_notifications();
32440
32849
 
32441
32850
  // src/services/upload-pipeline.ts
32442
32851
  import { randomUUID as randomUUID6 } from "node:crypto";
@@ -33670,25 +34079,25 @@ var cachedDecoders = {
33670
34079
  };
33671
34080
  var cachedEncoder = new globalThis.TextEncoder();
33672
34081
  var byteToHexLookupTable = Array.from({ length: 256 }, (_, index) => index.toString(16).padStart(2, "0"));
33673
- function getUintBE(view11) {
33674
- const { byteLength } = view11;
34082
+ function getUintBE(view12) {
34083
+ const { byteLength } = view12;
33675
34084
  if (byteLength === 6) {
33676
- return view11.getUint16(0) * 2 ** 32 + view11.getUint32(2);
34085
+ return view12.getUint16(0) * 2 ** 32 + view12.getUint32(2);
33677
34086
  }
33678
34087
  if (byteLength === 5) {
33679
- return view11.getUint8(0) * 2 ** 32 + view11.getUint32(1);
34088
+ return view12.getUint8(0) * 2 ** 32 + view12.getUint32(1);
33680
34089
  }
33681
34090
  if (byteLength === 4) {
33682
- return view11.getUint32(0);
34091
+ return view12.getUint32(0);
33683
34092
  }
33684
34093
  if (byteLength === 3) {
33685
- return view11.getUint8(0) * 2 ** 16 + view11.getUint16(1);
34094
+ return view12.getUint8(0) * 2 ** 16 + view12.getUint16(1);
33686
34095
  }
33687
34096
  if (byteLength === 2) {
33688
- return view11.getUint16(0);
34097
+ return view12.getUint16(0);
33689
34098
  }
33690
34099
  if (byteLength === 1) {
33691
- return view11.getUint8(0);
34100
+ return view12.getUint8(0);
33692
34101
  }
33693
34102
  }
33694
34103
 
@@ -40173,7 +40582,7 @@ async function acceptTicket(t, opts) {
40173
40582
 
40174
40583
  // src/routes/tickets.ts
40175
40584
  init_src();
40176
- var view5 = (t) => ({
40585
+ var view6 = (t) => ({
40177
40586
  id: t.id,
40178
40587
  projectId: t.projectId,
40179
40588
  number: t.number,
@@ -40201,7 +40610,7 @@ async function tickets_default(app2) {
40201
40610
  rows = rows.filter((t) => `${t.title} ${t.reporterEmail}`.toLowerCase().includes(n2));
40202
40611
  }
40203
40612
  const unreviewed = rows.filter((t) => t.status === "new").length;
40204
- return { ...paginate(rows.map(view5), page, limit), unreviewed };
40613
+ return { ...paginate(rows.map(view6), page, limit), unreviewed };
40205
40614
  });
40206
40615
  app2.get("/tickets/:id", async (req, reply) => {
40207
40616
  const { id } = req.params;
@@ -40219,7 +40628,7 @@ async function tickets_default(app2) {
40219
40628
  const base2 = `${req.protocol}://${req.headers.host ?? "localhost"}`;
40220
40629
  const publicStatusUrl = `${base2}/help/${encodeURIComponent(t.projectId)}/status/${shareToken}`;
40221
40630
  return {
40222
- ...view5(t),
40631
+ ...view6(t),
40223
40632
  detail: t.detail,
40224
40633
  attachments: t.attachments.map((a) => ({ id: a.id, filename: a.filename, mimeType: a.mimeType, size: a.size })),
40225
40634
  spec,
@@ -40279,7 +40688,7 @@ async function tickets_default(app2) {
40279
40688
  });
40280
40689
  const spec = updated.specId ? await prisma.spec.findUnique({ where: { id: updated.specId } }) : null;
40281
40690
  return {
40282
- ...view5(updated),
40691
+ ...view6(updated),
40283
40692
  detail: updated.detail,
40284
40693
  attachments: updated.attachments.map((a) => ({ id: a.id, filename: a.filename, mimeType: a.mimeType, size: a.size })),
40285
40694
  spec
@@ -40290,7 +40699,7 @@ async function tickets_default(app2) {
40290
40699
  const t = await prisma.ticket.findUnique({ where: { id }, include: { attachments: true } });
40291
40700
  if (!t) return reply.code(404).send({ error: "not found" });
40292
40701
  for (const a of t.attachments) await deleteUpload(a.storageKey);
40293
- await prisma.ticket.delete({ where: { id } });
40702
+ await deleteSynced("ticket", id);
40294
40703
  return { ok: true };
40295
40704
  });
40296
40705
  }
@@ -40397,6 +40806,7 @@ async function isQueued(id) {
40397
40806
  // src/services/scheduler/cron.ts
40398
40807
  init_db();
40399
40808
  init_src();
40809
+ init_notifications();
40400
40810
  var GRACE_MS = 30 * 6e4;
40401
40811
  function computeNextRun(expr, after) {
40402
40812
  const spec = parseCron(expr);
@@ -40747,6 +41157,7 @@ async function leadProjects() {
40747
41157
  init_src();
40748
41158
  init_db();
40749
41159
  init_pty();
41160
+ init_notifications();
40750
41161
 
40751
41162
  // src/services/lead/prompt.ts
40752
41163
  init_src();
@@ -41518,6 +41929,7 @@ init_src();
41518
41929
  init_db();
41519
41930
  init_pty();
41520
41931
  init_session_phases();
41932
+ init_notifications();
41521
41933
 
41522
41934
  // src/services/webhooks/actor.ts
41523
41935
  init_src();
@@ -41670,6 +42082,7 @@ Bukti integrasi: ${evidence.join("; ")} \u2192 ${verdict}.` }
41670
42082
  init_src();
41671
42083
  init_pty();
41672
42084
  init_tui_dialog();
42085
+ init_notifications();
41673
42086
  import { writeFileSync as writeFileSync6 } from "node:fs";
41674
42087
 
41675
42088
  // src/services/lead/pane.ts
@@ -41990,6 +42403,7 @@ function sweep(liveIds) {
41990
42403
  init_db();
41991
42404
  init_pty();
41992
42405
  init_session_phases();
42406
+ init_notifications();
41993
42407
  var moduleOf = (p) => p.split("/").slice(0, 2).join("/");
41994
42408
  function findCollisions(areas) {
41995
42409
  const out3 = [];
@@ -42257,6 +42671,7 @@ async function orderProject(projectId, deps) {
42257
42671
  }
42258
42672
 
42259
42673
  // src/services/lead/engine.ts
42674
+ init_notifications();
42260
42675
  var TICK_MS2 = 5e3;
42261
42676
  var busyDetect = false;
42262
42677
  var busyPulse = false;
@@ -42771,7 +43186,7 @@ async function generateChangelog(projectId, req, deps = {}) {
42771
43186
  }
42772
43187
 
42773
43188
  // src/routes/changelog.ts
42774
- var view6 = (c) => ({
43189
+ var view7 = (c) => ({
42775
43190
  id: c.id,
42776
43191
  projectId: c.projectId,
42777
43192
  mode: c.mode,
@@ -42811,7 +43226,7 @@ async function changelog_default(app2) {
42811
43226
  if (!await prisma.project.findUnique({ where: { id } })) return reply.code(404).send({ error: "not found" });
42812
43227
  const { page, limit, q } = req.query;
42813
43228
  const rows = await prisma.changelog.findMany({ where: { projectId: id }, orderBy: { createdAt: "desc" } });
42814
- 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);
42815
43230
  });
42816
43231
  app2.get("/projects/:id/changelog/:cid", async (req, reply) => {
42817
43232
  const { id, cid } = req.params;
@@ -42825,7 +43240,7 @@ async function changelog_default(app2) {
42825
43240
  eyebrow: `hanoman \xB7 ${id} \xB7 changelog`,
42826
43241
  path: row.title
42827
43242
  });
42828
- return view6(row);
43243
+ return view7(row);
42829
43244
  });
42830
43245
  app2.post("/projects/:id/changelog", async (req, reply) => {
42831
43246
  const { id } = req.params;
@@ -42834,7 +43249,7 @@ async function changelog_default(app2) {
42834
43249
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
42835
43250
  const r = await generateChangelog(id, parsed.data);
42836
43251
  if (!r.ok) return reply.code(422).send({ error: r.reason });
42837
- return reply.code(201).send(view6(r.row));
43252
+ return reply.code(201).send(view7(r.row));
42838
43253
  });
42839
43254
  app2.delete("/projects/:id/changelog/:cid", async (req, reply) => {
42840
43255
  const { id, cid } = req.params;
@@ -42973,7 +43388,7 @@ async function installCustomAgents() {
42973
43388
 
42974
43389
  // src/routes/custom-agents.ts
42975
43390
  var rowsOf = async () => await prisma.customAgent.findMany();
42976
- var view7 = (r, projectId) => ({
43391
+ var view8 = (r, projectId) => ({
42977
43392
  id: r.id,
42978
43393
  projectId: r.projectId,
42979
43394
  name: r.name,
@@ -43018,7 +43433,7 @@ async function custom_agents_default(app2) {
43018
43433
  const byName = /* @__PURE__ */ new Map();
43019
43434
  for (const r of rows) if (r.projectId === null) byName.set(r.name, r);
43020
43435
  for (const r of rows) if (r.projectId !== null) byName.set(r.name, r);
43021
- 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));
43022
43437
  });
43023
43438
  app2.post("/custom-agents", async (req, reply) => {
43024
43439
  const parsed = zCreateCustomAgent.safeParse(req.body);
@@ -43065,7 +43480,7 @@ async function custom_agents_default(app2) {
43065
43480
  } });
43066
43481
  await loadCustomAgents();
43067
43482
  await notifySynced("customAgent", id);
43068
- return reply.code(201).send(view7(row));
43483
+ return reply.code(201).send(view8(row));
43069
43484
  });
43070
43485
  app2.patch("/custom-agents/:id", async (req, reply) => {
43071
43486
  const { id } = req.params;
@@ -43112,13 +43527,13 @@ async function custom_agents_default(app2) {
43112
43527
  } });
43113
43528
  await loadCustomAgents();
43114
43529
  await notifySynced("customAgent", id);
43115
- return view7(row);
43530
+ return view8(row);
43116
43531
  });
43117
43532
  app2.delete("/custom-agents/:id", async (req, reply) => {
43118
43533
  const { id } = req.params;
43119
43534
  const existing = await prisma.customAgent.findUnique({ where: { id } });
43120
43535
  if (!existing) return reply.code(404).send({ error: "not found" });
43121
- await prisma.customAgent.delete({ where: { id } });
43536
+ await deleteSynced("customAgent", id);
43122
43537
  const name2 = existing.name;
43123
43538
  for (const r of await rowsOf()) {
43124
43539
  const m = mentionsOf(r.mentions);
@@ -43453,7 +43868,7 @@ var STATUS = {
43453
43868
  unauthorized: 401,
43454
43869
  other: 502
43455
43870
  };
43456
- var view8 = (i) => ({
43871
+ var view9 = (i) => ({
43457
43872
  id: i.id,
43458
43873
  projectId: i.projectId,
43459
43874
  repoSlug: i.repoSlug,
@@ -43488,7 +43903,7 @@ async function githubIssues(app2) {
43488
43903
  where: { projectId: id, ...status ? { status } : {} },
43489
43904
  orderBy: [{ number: "desc" }]
43490
43905
  });
43491
- return reply.send(paginate(items.map(view8), page, limit));
43906
+ return reply.send(paginate(items.map(view9), page, limit));
43492
43907
  });
43493
43908
  app2.post("/github-issues/accept", async (req, reply) => {
43494
43909
  const parsed = zAcceptMany.safeParse(req.body ?? {});
@@ -44248,7 +44663,7 @@ async function portal_default(app2) {
44248
44663
  // src/routes/client-accounts.ts
44249
44664
  init_src();
44250
44665
  init_db();
44251
- var view9 = (u) => ({
44666
+ var view10 = (u) => ({
44252
44667
  id: u.id,
44253
44668
  email: u.email,
44254
44669
  disabled: u.disabled,
@@ -44279,7 +44694,7 @@ async function client_accounts_default(app2) {
44279
44694
  orderBy: { createdAt: "asc" },
44280
44695
  include: { projectAccess: { select: { projectId: true } } }
44281
44696
  });
44282
- return { items: rows.map(view9) };
44697
+ return { items: rows.map(view10) };
44283
44698
  });
44284
44699
  app2.post("/client-accounts", async (req, reply) => {
44285
44700
  const p = zCreateClientAccount.safeParse(req.body);
@@ -44293,7 +44708,7 @@ async function client_accounts_default(app2) {
44293
44708
  await prisma.user.delete({ where: { id: user.id } });
44294
44709
  return reply.code(400).send({ error: "ada project yang tidak dikenal" });
44295
44710
  }
44296
- return reply.code(201).send(view9(await load(user.id)));
44711
+ return reply.code(201).send(view10(await load(user.id)));
44297
44712
  });
44298
44713
  app2.patch("/client-accounts/:id", async (req, reply) => {
44299
44714
  const { id } = req.params;
@@ -44307,7 +44722,7 @@ async function client_accounts_default(app2) {
44307
44722
  if (p.data.password)
44308
44723
  await prisma.user.update({ where: { id }, data: { passwordHash: await hashPassword(p.data.password) } });
44309
44724
  if (p.data.disabled || p.data.password) await deleteUserSessions(id);
44310
- return view9(await load(id));
44725
+ return view10(await load(id));
44311
44726
  });
44312
44727
  app2.delete("/client-accounts/:id", async (req, reply) => {
44313
44728
  const { id } = req.params;
@@ -44403,7 +44818,7 @@ async function consumeSetupToken(home3) {
44403
44818
 
44404
44819
  // src/routes/auth.ts
44405
44820
  var BOOTSTRAP_USER_ID = "bootstrap-admin";
44406
- 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() });
44407
44822
  async function issue(reply, userId) {
44408
44823
  const token = await createSession2(userId);
44409
44824
  reply.setCookie(COOKIE_NAME, token, cookieOpts());
@@ -44443,7 +44858,7 @@ async function auth_default(app2, opts) {
44443
44858
  if (opts.bootstrapRequired) await consumeSetupToken(opts.home);
44444
44859
  setupAttempts.clear(req.ip);
44445
44860
  await issue(reply, user.id);
44446
- return { user: view10(user) };
44861
+ return { user: view11(user) };
44447
44862
  });
44448
44863
  app2.post("/auth/login", async (req, reply) => {
44449
44864
  if (loginThrottle(req.ip).blocked) return reply.code(429).send({ error: "too many attempts" });
@@ -44456,7 +44871,7 @@ async function auth_default(app2, opts) {
44456
44871
  }
44457
44872
  clearLoginFails(req.ip);
44458
44873
  await issue(reply, user.id);
44459
- return { user: view10(user) };
44874
+ return { user: view11(user) };
44460
44875
  });
44461
44876
  app2.post("/auth/logout", async (req, reply) => {
44462
44877
  const token = req.cookies?.[COOKIE_NAME];
@@ -44464,7 +44879,7 @@ async function auth_default(app2, opts) {
44464
44879
  reply.clearCookie(COOKIE_NAME, { path: "/" });
44465
44880
  return reply.code(204).send();
44466
44881
  });
44467
- 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));
44468
44883
  app2.post("/auth/users", async (req, reply) => {
44469
44884
  const p = zSignup.safeParse(req.body);
44470
44885
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
@@ -44475,7 +44890,7 @@ async function auth_default(app2, opts) {
44475
44890
  // (/api/client-accounts) supaya "undang rekan" dan "beri akses klien" tak pernah tertukar.
44476
44891
  data: { email: p.data.email, passwordHash: await hashPassword(p.data.password), role: "admin" }
44477
44892
  });
44478
- return view10(user);
44893
+ return view11(user);
44479
44894
  });
44480
44895
  app2.delete("/auth/users/:id", async (req, reply) => {
44481
44896
  const target2 = await prisma.user.findUnique({ where: { id: req.params.id } });
@@ -44498,7 +44913,7 @@ async function auth_default(app2, opts) {
44498
44913
  });
44499
44914
  await deleteUserSessions(user.id);
44500
44915
  await issue(reply, user.id);
44501
- return { user: view10(user) };
44916
+ return { user: view11(user) };
44502
44917
  });
44503
44918
  }
44504
44919
 
@@ -44511,15 +44926,15 @@ async function agent_tokens_default(app2) {
44511
44926
  app2.post("/agent-tokens", async (req, reply) => {
44512
44927
  const p = zAgentTokenCreate.safeParse(req.body);
44513
44928
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
44514
- const { view: view11, token } = await issueAgentToken({ ...p.data, createdBy: req.user?.id });
44515
- 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 });
44516
44931
  });
44517
44932
  app2.patch("/agent-tokens/:id", async (req, reply) => {
44518
44933
  const p = zAgentTokenPatch.safeParse(req.body);
44519
44934
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
44520
44935
  const { id } = req.params;
44521
- const view11 = await patchAgentToken(id, p.data);
44522
- 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" });
44523
44938
  });
44524
44939
  app2.delete("/agent-tokens/:id", async (req, reply) => {
44525
44940
  const { id } = req.params;
@@ -44586,6 +45001,7 @@ function capabilityForRoute(method, path2) {
44586
45001
  if (top === "vps") return rw("vps");
44587
45002
  if (top === "prds") return rw("docs");
44588
45003
  if (top === "terminal") {
45004
+ if (seg[1] === "workspace") return "COOKIE_ONLY";
44589
45005
  if (seg[seg.length - 1] === "ws") return "sessions:write";
44590
45006
  return rw("sessions");
44591
45007
  }
@@ -44817,7 +45233,7 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
44817
45233
  if (PUBLIC.has(`${req.method} ${path2}`)) return;
44818
45234
  if (user?.role === "client" && !clientRouteAllowed(req.method, path2))
44819
45235
  return reply.code(403).send({ error: "portal klien: baca-saja" });
44820
- 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;
44821
45237
  if (path2.startsWith("/api/help")) return;
44822
45238
  if (user) return;
44823
45239
  const agentTok = agentTokenFromReq(req);
@@ -44852,6 +45268,7 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
44852
45268
  await api.register(fs_default);
44853
45269
  const wsOptions = { allowedOrigins: wsControlOrigins(env) };
44854
45270
  await api.register(terminal_default, wsOptions);
45271
+ await api.register(terminalWorkspaceRoutes);
44855
45272
  await api.register(vps_default);
44856
45273
  await api.register(limits);
44857
45274
  await api.register(update);
@@ -44930,6 +45347,7 @@ init_src();
44930
45347
 
44931
45348
  // src/services/scheduler/governor.ts
44932
45349
  init_db();
45350
+ init_notifications();
44933
45351
  var ALREADY_DONE_NOTE = "spec sudah selesai \u2014 tak diluncurkan";
44934
45352
  var canceledRaceNote = (sessionId2) => `dibatalkan saat sesi ${sessionId2} sudah terlanjur lahir \u2014 sesi dibiarkan hidup, tutup dari Terminal bila tak diperlukan`;
44935
45353
  var CAP_FULL_NOTE = "cap penuh \u2014 tak ada slot sesi";
@@ -45064,6 +45482,7 @@ async function startCronSession(cron) {
45064
45482
  // src/services/scheduler/reconcile.ts
45065
45483
  init_db();
45066
45484
  init_src2();
45485
+ init_notifications();
45067
45486
  init_stage_machine();
45068
45487
  init_pty();
45069
45488
  init_session_phases();
@@ -45127,6 +45546,7 @@ var reconcileProdDeps = {
45127
45546
  };
45128
45547
 
45129
45548
  // src/services/scheduler/engine.ts
45549
+ init_notifications();
45130
45550
  init_pty();
45131
45551
  var prodEnd = {
45132
45552
  reconcile: () => reconcile(reconcileProdDeps),
@@ -45501,6 +45921,7 @@ init_src();
45501
45921
  init_db();
45502
45922
  import { execFile as execFile13 } from "node:child_process";
45503
45923
  import { promisify as promisify12 } from "node:util";
45924
+ init_notifications();
45504
45925
  var AUTO_MERGE_WINDOW_MS = 24 * 60 * 60 * 1e3;
45505
45926
  var AUTO_MERGE_GRACE_MS = 15 * 60 * 1e3;
45506
45927
  var TICK_MS5 = 6e4;