hanoman 0.1.39 → 0.1.41

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
@@ -4557,7 +4557,7 @@ function cmpVersion(a, b) {
4557
4557
  }
4558
4558
  return 0;
4559
4559
  }
4560
- var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, zSourceChange, zSpec, NOTIFY_SOUNDS, MODELS, EFFORTS, E_5_6, E_LUNA, E_BASE, CODEX_MODELS, RETIRED_CODEX_MODELS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, CHANGELOG_ENGINE_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4560
+ var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, zSourceChange, zManualDone, zSpec, NOTIFY_SOUNDS, MODELS, EFFORTS, E_5_6, E_LUNA, E_BASE, CODEX_MODELS, RETIRED_CODEX_MODELS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, CHANGELOG_ENGINE_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4561
4561
  var init_entities = __esm({
4562
4562
  "../shared/src/entities.ts"() {
4563
4563
  "use strict";
@@ -4615,6 +4615,11 @@ var init_entities = __esm({
4615
4615
  by: external_exports.string(),
4616
4616
  payload: external_exports.unknown().optional()
4617
4617
  });
4618
+ zManualDone = external_exports.object({
4619
+ at: external_exports.string(),
4620
+ by: external_exports.string(),
4621
+ reason: external_exports.string().optional()
4622
+ });
4618
4623
  zSpec = external_exports.object({
4619
4624
  id: external_exports.string(),
4620
4625
  projectId: external_exports.string(),
@@ -4646,7 +4651,10 @@ var init_entities = __esm({
4646
4651
  // SPEC-546 · ADR-0109 · jejak konversi type. `.default([])` menjaga respons/klien versi lama
4647
4652
  // tetap parse; kolom DB-nya `Json?` sehingga baris yang belum pernah dikonversi mengirim
4648
4653
  // `null` — pemakai UI menulis `spec.sourceHistory ?? []`, cermin `blockedBy`.
4649
- sourceHistory: external_exports.array(zSourceChange).default([])
4654
+ sourceHistory: external_exports.array(zSourceChange).default([]),
4655
+ // SPEC-804 · ADR-0120 · terisi hanya bila item ditandai selesai manual; null = selesai lewat
4656
+ // sesi atau belum selesai. `.nullable().default(null)` menjaga respons/klien versi lama parse.
4657
+ manualDone: zManualDone.nullable().default(null)
4650
4658
  });
4651
4659
  NOTIFY_SOUNDS = [
4652
4660
  "off",
@@ -5450,7 +5458,7 @@ var init_cron_expr = __esm({
5450
5458
  function flowForSource(source) {
5451
5459
  return source === "qa" ? "qa" : source === "audit" ? "audit" : source === "goal" ? "goal" : "feature";
5452
5460
  }
5453
- var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, USER_ROLES, zUserRole, zLogin, zSignup, zSetup, zChangePassword, zCreateClientAccount, zUpdateClientAccount, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus, zCronExpr, zCreateCron, zPatchCron, zSchedulerCron, zSchedulerCronRun;
5461
+ var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zMarkSpecDone, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, USER_ROLES, zUserRole, zLogin, zSignup, zSetup, zChangePassword, zCreateClientAccount, zUpdateClientAccount, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus, zCronExpr, zCreateCron, zPatchCron, zSchedulerCron, zSchedulerCronRun;
5454
5462
  var init_dto = __esm({
5455
5463
  "../shared/src/dto.ts"() {
5456
5464
  "use strict";
@@ -5557,6 +5565,10 @@ var init_dto = __esm({
5557
5565
  if (o.payload !== void 0 && !payloadMatchesSource(o.source, o.payload))
5558
5566
  ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["payload"], message: "bentuk payload tak cocok dengan source" });
5559
5567
  });
5568
+ zMarkSpecDone = external_exports.object({
5569
+ reason: external_exports.string().trim().max(280).optional(),
5570
+ confirm: external_exports.boolean().optional()
5571
+ });
5560
5572
  zIntegrate = external_exports.object({
5561
5573
  op: external_exports.enum(["merge", "rebase"]),
5562
5574
  target: external_exports.string().regex(/^(local|origin):.+/)
@@ -6028,6 +6040,8 @@ var init_api = __esm({
6028
6040
  specIntegrate: (id) => `${API}/specs/${id}/integrate`,
6029
6041
  // SPEC-546 · ADR-0109 · ubah type/source item in-place (operasi khusus, bukan field PATCH).
6030
6042
  specSource: (id) => `${API}/specs/${id}/source`,
6043
+ // SPEC-804 · ADR-0120 · tandai item selesai manual (operasi khusus, bukan field PATCH).
6044
+ specDone: (id) => `${API}/specs/${id}/done`,
6031
6045
  specReview: (id) => `${API}/specs/${id}/review`,
6032
6046
  specReviewFile: (id, path2) => `${API}/specs/${id}/review/${path2}`,
6033
6047
  settings: `${API}/settings`,
@@ -6195,6 +6209,8 @@ var init_api = __esm({
6195
6209
  syncNow: `${API}/sync/now`,
6196
6210
  // SPEC-270 · ADR-0067 · antrean konflik rekonsil (cookie-authed)
6197
6211
  syncConflicts: `${API}/sync/conflicts`,
6212
+ // SPEC-799 · ADR-0119 · penghapusan yang menunggu jendela online (cookie-only, cermin syncNow).
6213
+ syncPending: `${API}/sync/pending`,
6198
6214
  syncConflictResolve: (entity, recordId) => `${API}/sync/conflicts/${encodeURIComponent(entity)}/${encodeURIComponent(recordId)}/resolve`,
6199
6215
  // SPEC-257 · ADR-0065 · agent token (kelola cookie-only) + katalog capability
6200
6216
  agentTokens: `${API}/agent-tokens`,
@@ -6747,6 +6763,9 @@ var init_webhook = __esm({
6747
6763
  "autoMerge",
6748
6764
  "createdAt",
6749
6765
  "startedAt",
6766
+ // SPEC-804 · ADR-0120 · penerima harus bisa membedakan "selesai lewat sesi" dari "ditandai
6767
+ // manusia" tanpa mendiff dua amplop. Peristiwanya tetap `spec.stage_changed`.
6768
+ "manualDone",
6750
6769
  "updatedAt"
6751
6770
  ],
6752
6771
  projectIdField: "projectId",
@@ -6785,6 +6804,7 @@ var init_webhook = __esm({
6785
6804
  autoMerge: null,
6786
6805
  createdAt: "2026-08-01T02:10:00.000Z",
6787
6806
  startedAt: "2026-08-01T02:12:31.000Z",
6807
+ manualDone: null,
6788
6808
  updatedAt: "2026-08-01T09:41:22.000Z"
6789
6809
  }
6790
6810
  },
@@ -9736,8 +9756,8 @@ function assertRuntimeBoundary(env, runtime) {
9736
9756
  if (runtime.uid === 0) throw new Error("production Hanoman harus berjalan sebagai user non-root");
9737
9757
  if (env.HANOMAN_SESSION_SANDBOX !== "podman")
9738
9758
  throw new Error("HANOMAN_SESSION_SANDBOX=podman wajib di production");
9739
- if (!env.HANOMAN_PUBLIC_ORIGINS || !env.HANOMAN_CONTROL_ORIGINS)
9740
- throw new Error("public/control origin wajib dipisahkan di production");
9759
+ if (!env.HANOMAN_CONTROL_ORIGINS || !env.HANOMAN_PUBLIC_ORIGINS && !env.HANOMAN_SINGLE_ORIGIN)
9760
+ throw new Error("public/control origin wajib dipisahkan di production (HANOMAN_SINGLE_ORIGIN=1 untuk sadar memilih satu origin)");
9741
9761
  if (!env.HANOMAN_TRUST_PROXY) throw new Error("trusted proxy hop/CIDR wajib di production");
9742
9762
  if (runtime.host !== "127.0.0.1" && runtime.host !== "::1" && runtime.host !== "localhost")
9743
9763
  throw new Error("origin Hanoman harus bind loopback; buka hanya reverse proxy");
@@ -10487,16 +10507,55 @@ var init_rename_project = __esm({
10487
10507
  }
10488
10508
  });
10489
10509
 
10510
+ // src/services/tombstone.ts
10511
+ async function findTombstone(entity, recordId) {
10512
+ const row = await prisma.syncTombstone.findUnique({ where: { entity_recordId: { entity, recordId } } });
10513
+ return row ? view(row) : null;
10514
+ }
10515
+ async function writeTombstone(entity, recordId, version2, data, deviceId) {
10516
+ const prev = await findTombstone(entity, recordId);
10517
+ if (prev && prev.version >= version2) return prev;
10518
+ const row = await prisma.syncTombstone.upsert({
10519
+ where: { entity_recordId: { entity, recordId } },
10520
+ create: { entity, recordId, version: version2, data, deviceId: deviceId ?? null },
10521
+ update: { version: version2, data, deviceId: deviceId ?? null, deletedAt: /* @__PURE__ */ new Date() }
10522
+ });
10523
+ return view(row);
10524
+ }
10525
+ async function clearTombstone(entity, recordId) {
10526
+ await prisma.syncTombstone.deleteMany({ where: { entity, recordId } });
10527
+ }
10528
+ var view;
10529
+ var init_tombstone = __esm({
10530
+ "src/services/tombstone.ts"() {
10531
+ "use strict";
10532
+ init_db();
10533
+ view = (r) => ({
10534
+ entity: r.entity,
10535
+ recordId: r.recordId,
10536
+ version: r.version,
10537
+ data: r.data ?? {},
10538
+ deletedAt: r.deletedAt,
10539
+ deviceId: r.deviceId
10540
+ });
10541
+ }
10542
+ });
10543
+
10490
10544
  // src/services/sync.ts
10491
10545
  var sync_exports = {};
10492
10546
  __export(sync_exports, {
10547
+ PARENTS: () => PARENTS,
10493
10548
  SYNCED: () => SYNCED,
10494
10549
  __DATE_FIELDS: () => __DATE_FIELDS,
10495
10550
  __FIELDS: () => __FIELDS,
10496
10551
  __FIELDS_FOR_TEST: () => __FIELDS_FOR_TEST,
10552
+ __JSON_FIELDS: () => __JSON_FIELDS,
10497
10553
  applyPush: () => applyPush,
10498
10554
  backfillFeed: () => backfillFeed,
10555
+ consumeTombstoneOnRecreate: () => consumeTombstoneOnRecreate,
10556
+ deleteRow: () => deleteRow,
10499
10557
  isEntity: () => isEntity,
10558
+ publishDelete: () => publishDelete,
10500
10559
  publishLocal: () => publishLocal,
10501
10560
  pull: () => pull,
10502
10561
  setAcceptedHook: () => setAcceptedHook,
@@ -10561,10 +10620,24 @@ async function snapshot(entity, id) {
10561
10620
  for (const f of FIELDS[entity]) data[f] = jsonSafe(row[f]);
10562
10621
  return { version: Number(row.version), data };
10563
10622
  }
10564
- async function applyPush(entity, id, baseVersion, data, deviceId) {
10623
+ async function applyPush(entity, id, baseVersion, data, deviceId, op = "upsert") {
10565
10624
  validateSyncData(entity, data, { allowProjectRename: true });
10625
+ if (op === "delete") {
10626
+ const already = await findTombstone(entity, id);
10627
+ if (already) return { ok: true, version: already.version };
10628
+ const snap2 = await snapshot(entity, id);
10629
+ const version2 = (snap2?.version ?? baseVersion) + 1;
10630
+ if (snap2) await DELEGATE[entity].delete({ where: { id } });
10631
+ await writeTombstone(entity, id, version2, snap2?.data ?? data, deviceId);
10632
+ await publishDelete(entity, id);
10633
+ return { ok: true, version: version2 };
10634
+ }
10566
10635
  if (entity === "project" && typeof data.renamedFrom === "string" && data.renamedFrom && data.renamedFrom !== id) {
10567
10636
  const oldId = data.renamedFrom;
10637
+ const destTomb = await findTombstone("project", id);
10638
+ if (destTomb) {
10639
+ return { ok: false, conflict: true, deleted: true, deletedVersion: destTomb.version, server: null };
10640
+ }
10568
10641
  const already = await DELEGATE.project.findUnique({ where: { id }, select: { version: true } });
10569
10642
  if (already) return { ok: true, version: Number(already.version) };
10570
10643
  const old = await DELEGATE.project.findUnique({ where: { id: oldId }, select: { version: true } });
@@ -10578,17 +10651,24 @@ async function applyPush(entity, id, baseVersion, data, deviceId) {
10578
10651
  const snap2 = await snapshot("project", id);
10579
10652
  const logData = { ...snap2?.data ?? {}, renamedFrom: oldId };
10580
10653
  const log3 = await prisma.syncLog.create({
10581
- data: { entity: "project", recordId: id, version: newVersion2, data: logData, deviceId: deviceId ?? null }
10654
+ data: { entity: "project", recordId: id, version: newVersion2, op: "upsert", data: logData, deviceId: deviceId ?? null }
10582
10655
  });
10583
- onAccepted?.({ entity: "project", recordId: id, version: newVersion2, data: logData, seq: String(log3.seq) });
10656
+ onAccepted?.({ entity: "project", recordId: id, version: newVersion2, op: "upsert", data: logData, seq: String(log3.seq) });
10584
10657
  return { ok: true, version: newVersion2 };
10585
10658
  }
10586
10659
  }
10660
+ const tomb = await findTombstone(entity, id);
10587
10661
  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) };
10662
+ const currentVersion = existing ? Number(existing.version) : tomb ? tomb.version : null;
10663
+ if (currentVersion !== null && currentVersion !== baseVersion) {
10664
+ return {
10665
+ ok: false,
10666
+ conflict: true,
10667
+ server: await snapshot(entity, id),
10668
+ ...tomb && !existing ? { deleted: true, deletedVersion: tomb.version } : {}
10669
+ };
10590
10670
  }
10591
- const newVersion = existing ? Number(existing.version) + 1 : 1;
10671
+ const newVersion = (currentVersion ?? 0) + 1;
10592
10672
  const writeData = coerce2(entity, data);
10593
10673
  const stamp2 = writeData.updatedAt ?? /* @__PURE__ */ new Date();
10594
10674
  await DELEGATE[entity].upsert({
@@ -10596,11 +10676,12 @@ async function applyPush(entity, id, baseVersion, data, deviceId) {
10596
10676
  create: { id, ...writeData, version: newVersion, updatedAt: stamp2 },
10597
10677
  update: { ...writeData, version: newVersion, updatedAt: stamp2 }
10598
10678
  });
10679
+ if (tomb) await clearTombstone(entity, id);
10599
10680
  const snap = await snapshot(entity, id);
10600
10681
  const log2 = await prisma.syncLog.create({
10601
- data: { entity, recordId: id, version: newVersion, data: snap?.data ?? {}, deviceId: deviceId ?? null }
10682
+ data: { entity, recordId: id, version: newVersion, op: "upsert", data: snap?.data ?? {}, deviceId: deviceId ?? null }
10602
10683
  });
10603
- onAccepted?.({ entity, recordId: id, version: newVersion, data: snap?.data ?? {}, seq: String(log2.seq) });
10684
+ onAccepted?.({ entity, recordId: id, version: newVersion, op: "upsert", data: snap?.data ?? {}, seq: String(log2.seq) });
10604
10685
  return { ok: true, version: newVersion };
10605
10686
  }
10606
10687
  async function pull(sinceCursor, limit = 500) {
@@ -10613,7 +10694,13 @@ async function pull(sinceCursor, limit = 500) {
10613
10694
  const cursor = rows.length ? String(rows[rows.length - 1].seq) : sinceCursor || "0";
10614
10695
  return {
10615
10696
  cursor,
10616
- records: rows.map((r) => ({ entity: r.entity, recordId: r.recordId, version: r.version, data: r.data }))
10697
+ records: rows.map((r) => ({
10698
+ entity: r.entity,
10699
+ recordId: r.recordId,
10700
+ version: r.version,
10701
+ op: r.op === "delete" ? "delete" : "upsert",
10702
+ data: r.data
10703
+ }))
10617
10704
  };
10618
10705
  }
10619
10706
  async function publishLocal(entity, id) {
@@ -10622,9 +10709,38 @@ async function publishLocal(entity, id) {
10622
10709
  const newVersion = snap.version + 1;
10623
10710
  await DELEGATE[entity].update({ where: { id }, data: { version: newVersion } });
10624
10711
  const log2 = await prisma.syncLog.create({
10625
- data: { entity, recordId: id, version: newVersion, data: snap.data ?? {}, deviceId: null }
10712
+ data: { entity, recordId: id, version: newVersion, op: "upsert", data: snap.data ?? {}, deviceId: null }
10626
10713
  });
10627
- onAccepted?.({ entity, recordId: id, version: newVersion, data: snap.data ?? {}, seq: String(log2.seq) });
10714
+ onAccepted?.({ entity, recordId: id, version: newVersion, op: "upsert", data: snap.data ?? {}, seq: String(log2.seq) });
10715
+ }
10716
+ async function deleteRow(entity, id) {
10717
+ await DELEGATE[entity].delete({ where: { id } });
10718
+ }
10719
+ async function publishDelete(entity, id) {
10720
+ const tomb = await findTombstone(entity, id);
10721
+ if (!tomb) return;
10722
+ const log2 = await prisma.syncLog.create({
10723
+ data: {
10724
+ entity,
10725
+ recordId: id,
10726
+ version: tomb.version,
10727
+ op: "delete",
10728
+ data: tomb.data,
10729
+ deviceId: tomb.deviceId
10730
+ }
10731
+ });
10732
+ onAccepted?.({ entity, recordId: id, version: tomb.version, op: "delete", data: tomb.data, seq: String(log2.seq) });
10733
+ }
10734
+ async function consumeTombstoneOnRecreate(entity, id) {
10735
+ const tomb = await findTombstone(entity, id);
10736
+ if (!tomb) return false;
10737
+ const row = await DELEGATE[entity].findUnique({ where: { id }, select: { version: true } });
10738
+ if (!row) return false;
10739
+ await clearTombstone(entity, id);
10740
+ if (Number(row.version) < tomb.version) {
10741
+ await DELEGATE[entity].update({ where: { id }, data: { version: tomb.version } });
10742
+ }
10743
+ return true;
10628
10744
  }
10629
10745
  async function backfillFeed() {
10630
10746
  let published = 0;
@@ -10640,6 +10756,16 @@ async function backfillFeed() {
10640
10756
  published++;
10641
10757
  }
10642
10758
  }
10759
+ for (const t of await prisma.syncTombstone.findMany({ select: { entity: true, recordId: true, version: true } })) {
10760
+ if (!isEntity(t.entity)) continue;
10761
+ const has = await prisma.syncLog.findFirst({
10762
+ where: { entity: t.entity, recordId: t.recordId, version: t.version, op: "delete" },
10763
+ select: { seq: true }
10764
+ });
10765
+ if (has) continue;
10766
+ await publishDelete(t.entity, t.recordId);
10767
+ published++;
10768
+ }
10643
10769
  return published;
10644
10770
  }
10645
10771
  async function upsertLocal(entity, id, version2, data) {
@@ -10667,12 +10793,13 @@ async function upsertLocal(entity, id, version2, data) {
10667
10793
  function setAcceptedHook(hook) {
10668
10794
  onAccepted = hook;
10669
10795
  }
10670
- var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, __FIELDS, __DATE_FIELDS, NUMBER_FIELDS, BOOLEAN_FIELDS, JSON_FIELDS, onAccepted, __FIELDS_FOR_TEST;
10796
+ var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, PARENTS, __FIELDS, __DATE_FIELDS, NUMBER_FIELDS, BOOLEAN_FIELDS, JSON_FIELDS, __JSON_FIELDS, onAccepted, __FIELDS_FOR_TEST;
10671
10797
  var init_sync = __esm({
10672
10798
  "src/services/sync.ts"() {
10673
10799
  "use strict";
10674
10800
  init_db();
10675
10801
  init_rename_project();
10802
+ init_tombstone();
10676
10803
  SYNCED = ["project", "spec", "vps", "sessionResult", "ticket", "ticketAttachment", "customAgent", "githubIssue"];
10677
10804
  DELEGATE = {
10678
10805
  project: prisma.project,
@@ -10698,7 +10825,10 @@ var init_sync = __esm({
10698
10825
  // TETAP berhasil, jadi kolom yang terlewat di sini mendarat sebagai null palsu di tiap client
10699
10826
  // tanpa satu pun error (kelas gagal-senyap ADR-0090/0093/0094/0105). BUKAN DATE_FIELDS —
10700
10827
  // `at` hidup di dalam JSON-nya, kolomnya sendiri bukan DateTime.
10701
- spec: ["projectId", "title", "source", "stage", "priority", "author", "objective", "payload", "branchFrom", "baseSha", "headSha", "dependsOn", "sourceHistory", "createdAt", "startedAt", "doneAt", "updatedAt"],
10828
+ // SPEC-804 · ADR-0120 · manualDone ikut menyeberang: "item ini ditandai selesai manusia" adalah
10829
+ // bagian keadaan yang harus dilihat sama oleh semua mesin — di antaranya gerbang auto-merge.
10830
+ // BUKAN DATE_FIELDS, alasan yang sama dengan sourceHistory.
10831
+ spec: ["projectId", "title", "source", "stage", "priority", "author", "objective", "payload", "branchFrom", "baseSha", "headSha", "dependsOn", "sourceHistory", "manualDone", "createdAt", "startedAt", "doneAt", "updatedAt"],
10702
10832
  vps: ["name", "host", "port", "user", "health", "audit", "hardened", "lastSeenAt", "lastAuditAt", "updatedAt"],
10703
10833
  sessionResult: ["projectId", "specId", "oldStage", "newStage", "commitSha", "branch", "prUrl", "status", "deviceId", "author", "createdAt", "updatedAt"],
10704
10834
  // SPEC-268 · ADR-0066 · metadata tiket (lampiran biner tak disync). accessKeyHash wajib
@@ -10746,6 +10876,13 @@ var init_sync = __esm({
10746
10876
  customAgent: ["createdAt", "updatedAt"],
10747
10877
  githubIssue: ["issueCreatedAt", "issueUpdatedAt", "pulledAt", "createdAt", "updatedAt"]
10748
10878
  };
10879
+ PARENTS = {
10880
+ spec: [{ field: "projectId", entity: "project" }],
10881
+ ticket: [{ field: "projectId", entity: "project" }],
10882
+ ticketAttachment: [{ field: "ticketId", entity: "ticket" }],
10883
+ customAgent: [{ field: "projectId", entity: "project" }],
10884
+ githubIssue: [{ field: "projectId", entity: "project" }]
10885
+ };
10749
10886
  __FIELDS = FIELDS;
10750
10887
  __DATE_FIELDS = DATE_FIELDS;
10751
10888
  NUMBER_FIELDS = /* @__PURE__ */ new Set([
@@ -10759,12 +10896,14 @@ var init_sync = __esm({
10759
10896
  "spec:payload",
10760
10897
  "spec:dependsOn",
10761
10898
  "spec:sourceHistory",
10899
+ "spec:manualDone",
10762
10900
  "vps:health",
10763
10901
  "vps:audit",
10764
10902
  "customAgent:tools",
10765
10903
  "customAgent:mentions",
10766
10904
  "githubIssue:labels"
10767
10905
  ]);
10906
+ __JSON_FIELDS = JSON_FIELDS;
10768
10907
  __FIELDS_FOR_TEST = FIELDS;
10769
10908
  }
10770
10909
  });
@@ -10888,6 +11027,134 @@ var init_codex_trust = __esm({
10888
11027
  }
10889
11028
  });
10890
11029
 
11030
+ // src/services/notifications.ts
11031
+ async function recordDrift(vpsId, vpsName, drift, snapshotId) {
11032
+ if (drift.length === 0) return;
11033
+ const ids = drift.map((d) => d.itemId);
11034
+ const shown = ids.slice(0, 5).join(", ") + (ids.length > 5 ? `, +${ids.length - 5} lagi` : "");
11035
+ const title = `Drift di "${vpsName}": ${drift.length} item regresi (${shown})`;
11036
+ await prisma.notification.create({
11037
+ data: { type: "drift", key: `drift:${vpsId}:${snapshotId}`, title, projectId: null }
11038
+ }).catch(() => {
11039
+ });
11040
+ }
11041
+ async function recordCompletion(specId, title, projectId) {
11042
+ await prisma.spec.updateMany({ where: { id: specId, doneAt: null }, data: { doneAt: /* @__PURE__ */ new Date() } }).catch(() => {
11043
+ });
11044
+ const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
11045
+ await prisma.notification.create({
11046
+ data: { type: "done", key: `done:${specId}`, specId, sessionId: sessionId2, title, projectId }
11047
+ }).catch(() => {
11048
+ });
11049
+ }
11050
+ async function recordSyncDelete(entity, recordId, version2, title) {
11051
+ await prisma.notification.create({
11052
+ data: { type: "sync", key: `sync-delete:${entity}:${recordId}:${version2}`, title, projectId: null }
11053
+ }).catch(() => {
11054
+ });
11055
+ }
11056
+ async function recordFailure(specId, title, projectId, reason) {
11057
+ const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
11058
+ await prisma.notification.create({
11059
+ data: { type: "fail", key: `fail:${specId}`, specId, sessionId: sessionId2, title: `Gagal: ${title} \u2014 ${reason}`, projectId }
11060
+ }).catch(() => {
11061
+ });
11062
+ }
11063
+ async function recordAutoMerge(specId, projectId, title) {
11064
+ const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
11065
+ await prisma.notification.create({
11066
+ data: { type: "automerge", key: `automerge:${specId}`, specId, sessionId: sessionId2, title, projectId }
11067
+ }).catch(() => {
11068
+ });
11069
+ }
11070
+ async function recordCleanupFailure(sessionId2, projectId, entry, reason) {
11071
+ await prisma.notification.create({
11072
+ data: {
11073
+ type: "cleanup",
11074
+ key: `cleanup:${entry}`,
11075
+ sessionId: sessionId2,
11076
+ projectId,
11077
+ title: `Worktree sesi ${sessionId2} gagal dibersihkan \u2014 ${reason}`
11078
+ }
11079
+ }).catch(() => {
11080
+ });
11081
+ }
11082
+ async function recordSourceChange(specId, projectId, title, from, to, seq) {
11083
+ const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
11084
+ await prisma.notification.create({
11085
+ data: {
11086
+ type: "spec-source",
11087
+ key: `source:${specId}:${seq}`,
11088
+ specId,
11089
+ sessionId: sessionId2,
11090
+ title: `${specId} \xB7 type ${from} \u2192 ${to} \u2014 ${title}`,
11091
+ projectId
11092
+ }
11093
+ }).catch(() => {
11094
+ });
11095
+ }
11096
+ async function recordNewTicket(ticketId, projectId, projectName, category, title) {
11097
+ const short = title.length > 80 ? title.slice(0, 77) + "\u2026" : title;
11098
+ const t = `Keluhan baru di "${projectName}": ${category}: ${short}`;
11099
+ await prisma.notification.create({
11100
+ data: { type: "ticket", key: `ticket:${ticketId}`, projectId, title: t }
11101
+ }).catch(() => {
11102
+ });
11103
+ }
11104
+ async function recordLeadDecision(decisionId, title, projectId, specId, sessionId2) {
11105
+ await prisma.notification.create({
11106
+ data: { type: "lead", key: `lead:${decisionId}`, specId, sessionId: sessionId2, projectId, title }
11107
+ }).catch(() => {
11108
+ });
11109
+ }
11110
+ async function recordCronRun(cronId, cronName, projectId, dueAt, status, note) {
11111
+ const verb = status === "launched" ? "berjalan" : status === "skipped" ? "dilewati" : "gagal";
11112
+ const title = `Cron "${cronName}" ${verb}${note ? ` \u2014 ${note}` : ""}`;
11113
+ await prisma.notification.create({
11114
+ data: { type: "cron", key: `cron:${cronId}:${dueAt.toISOString()}`, title, projectId }
11115
+ }).catch(() => {
11116
+ });
11117
+ }
11118
+ async function scanDecisions(read2 = liveDecisions) {
11119
+ const next = /* @__PURE__ */ new Set();
11120
+ const fresh = [];
11121
+ for (const s2 of read2()) {
11122
+ if (!markerFilled(s2.decisionFile)) continue;
11123
+ next.add(s2.id);
11124
+ if (!awaiting.has(s2.id)) fresh.push(s2);
11125
+ }
11126
+ awaiting = next;
11127
+ for (const s2 of fresh) {
11128
+ const title = s2.specId ? (await prisma.spec.findUnique({ where: { id: s2.specId }, select: { title: true } }))?.title ?? s2.specId : s2.id;
11129
+ await prisma.notification.create({
11130
+ data: { type: "decision", specId: s2.specId ?? null, sessionId: s2.id, projectId: s2.projectId || null, title }
11131
+ });
11132
+ }
11133
+ }
11134
+ async function notificationsFeed(p = {}) {
11135
+ await scanDecisions();
11136
+ const pageSize = p.limit ? Math.max(1, Math.floor(+p.limit) || 1) : DEFAULT_FEED_TAKE;
11137
+ const page = p.page ? Math.max(1, Math.floor(+p.page) || 1) : 1;
11138
+ const total = await prisma.notification.count();
11139
+ const items = await prisma.notification.findMany({
11140
+ orderBy: { createdAt: "desc" },
11141
+ skip: (page - 1) * pageSize,
11142
+ take: pageSize
11143
+ });
11144
+ const unread = await prisma.notification.count({ where: { readAt: null } });
11145
+ return { items, unread, total, page, pageSize };
11146
+ }
11147
+ var awaiting, DEFAULT_FEED_TAKE;
11148
+ var init_notifications = __esm({
11149
+ "src/services/notifications.ts"() {
11150
+ "use strict";
11151
+ init_db();
11152
+ init_pty();
11153
+ awaiting = /* @__PURE__ */ new Set();
11154
+ DEFAULT_FEED_TAKE = 50;
11155
+ }
11156
+ });
11157
+
10891
11158
  // src/services/agent-token.ts
10892
11159
  import { randomBytes as randomBytes2, createHash, timingSafeEqual } from "node:crypto";
10893
11160
  function toAgentTokenView(t) {
@@ -16171,7 +16438,14 @@ function validateIncomingRecord(input) {
16171
16438
  if (!row.data || typeof row.data !== "object" || Array.isArray(row.data)) throw new Error("sync data invalid");
16172
16439
  if (Buffer.byteLength(JSON.stringify(input)) > MAX_SYNC_RECORD_BYTES) throw new Error("sync record terlalu besar");
16173
16440
  validateSyncData(row.entity, row.data, { allowProjectRename: true });
16174
- return { entity: row.entity, recordId: row.recordId, version: Number(row.version), data: row.data };
16441
+ const op = row.op === void 0 || row.op === null || row.op === "upsert" ? "upsert" : row.op === "delete" ? "delete" : null;
16442
+ return {
16443
+ entity: row.entity,
16444
+ recordId: row.recordId,
16445
+ version: Number(row.version),
16446
+ data: row.data,
16447
+ op
16448
+ };
16175
16449
  }
16176
16450
  async function getCursor() {
16177
16451
  const s2 = await prisma.syncState.findUnique({ where: { id: 1 } });
@@ -16180,15 +16454,50 @@ async function getCursor() {
16180
16454
  async function setCursor(cursor) {
16181
16455
  await prisma.syncState.upsert({ where: { id: 1 }, create: { id: 1, cursor }, update: { cursor } });
16182
16456
  }
16183
- async function applyRemote(entity, recordId, version2, data) {
16184
- if (!isEntity(entity)) return;
16457
+ async function applyRemote(entity, recordId, version2, data, op = "upsert") {
16458
+ if (!isEntity(entity)) return "dropped";
16459
+ if (op === "delete") {
16460
+ await applyRemoteDelete(entity, recordId, version2, data);
16461
+ return "applied";
16462
+ }
16463
+ const tomb = await findTombstone(entity, recordId);
16464
+ if (tomb) {
16465
+ if (version2 <= tomb.version) return "dropped";
16466
+ await clearTombstone(entity, recordId);
16467
+ }
16468
+ if (await parentTombstoned(entity, data)) return "dropped";
16185
16469
  await upsertLocal(entity, recordId, version2, data);
16470
+ return "applied";
16471
+ }
16472
+ async function applyRemoteDelete(entity, recordId, version2, data) {
16473
+ const existing = await snapshot(entity, recordId);
16474
+ await writeTombstone(entity, recordId, version2, existing?.data ?? data);
16475
+ if (existing) await deleteRow(entity, recordId);
16476
+ const pending2 = await prisma.syncOutbox.findFirst({ where: { entity, recordId } });
16477
+ if (!pending2) return;
16478
+ await clearOutbox(entity, recordId);
16479
+ if (existing) {
16480
+ await recordSyncDelete(
16481
+ entity,
16482
+ recordId,
16483
+ version2,
16484
+ `Dihapus di peer: ${entity} ${recordId} \u2014 suntingan lokal yang belum tersinkron dibuang`
16485
+ );
16486
+ }
16487
+ }
16488
+ async function parentTombstoned(entity, data) {
16489
+ for (const p of PARENTS[entity] ?? []) {
16490
+ const v = data[p.field];
16491
+ if (typeof v !== "string" || !v) continue;
16492
+ if (await findTombstone(p.entity, v)) return true;
16493
+ }
16494
+ return false;
16186
16495
  }
16187
16496
  async function applyFeedFrame(msg) {
16188
16497
  if (!msg.entity || !msg.recordId) return true;
16189
16498
  try {
16190
16499
  const record2 = validateIncomingRecord({ ...msg, version: Number(msg.version ?? 0), data: msg.data ?? {} });
16191
- await applyRemote(record2.entity, record2.recordId, record2.version, record2.data);
16500
+ if (record2.op) await applyRemote(record2.entity, record2.recordId, record2.version, record2.data, record2.op);
16192
16501
  } catch {
16193
16502
  feedHole = true;
16194
16503
  return false;
@@ -16197,7 +16506,7 @@ async function applyFeedFrame(msg) {
16197
16506
  return true;
16198
16507
  }
16199
16508
  async function syncOnce(transport) {
16200
- let pulled = 0, pushed = 0, conflicts = 0;
16509
+ let pulled = 0, pushed = 0, conflicts = 0, deleted = 0, dropped = 0;
16201
16510
  const cursor = await getCursor();
16202
16511
  const outbox = await listOutbox();
16203
16512
  const pending2 = new Set(outbox.map((o) => `${o.entity}:${o.recordId}`));
@@ -16216,7 +16525,11 @@ async function syncOnce(transport) {
16216
16525
  const deferred = [];
16217
16526
  for (const rec of records) {
16218
16527
  if (!isEntity(rec.entity)) continue;
16219
- if (pending2.has(`${rec.entity}:${rec.recordId}`)) {
16528
+ if (!rec.op) {
16529
+ dropped++;
16530
+ continue;
16531
+ }
16532
+ if (rec.op === "upsert" && pending2.has(`${rec.entity}:${rec.recordId}`)) {
16220
16533
  const local = await snapshot(rec.entity, rec.recordId);
16221
16534
  if (local && JSON.stringify(local.data) !== JSON.stringify(rec.data)) {
16222
16535
  await markConflict(
@@ -16229,8 +16542,10 @@ async function syncOnce(transport) {
16229
16542
  continue;
16230
16543
  }
16231
16544
  try {
16232
- await applyRemote(rec.entity, rec.recordId, rec.version, rec.data);
16233
- pulled++;
16545
+ const r = await applyRemote(rec.entity, rec.recordId, rec.version, rec.data, rec.op);
16546
+ if (r === "dropped") dropped++;
16547
+ else if (rec.op === "delete") deleted++;
16548
+ else pulled++;
16234
16549
  } catch {
16235
16550
  deferred.push(rec);
16236
16551
  }
@@ -16240,15 +16555,18 @@ async function syncOnce(transport) {
16240
16555
  const still = [];
16241
16556
  for (const rec of rest) {
16242
16557
  try {
16243
- await applyRemote(rec.entity, rec.recordId, rec.version, rec.data);
16244
- pulled++;
16558
+ const r = await applyRemote(rec.entity, rec.recordId, rec.version, rec.data, rec.op ?? "upsert");
16559
+ if (r === "dropped") dropped++;
16560
+ else if (rec.op === "delete") deleted++;
16561
+ else pulled++;
16245
16562
  } catch {
16246
16563
  still.push(rec);
16247
16564
  }
16248
16565
  }
16249
16566
  if (still.length === rest.length) {
16250
16567
  for (const rec of still) {
16251
- console.warn(`sync: record ${rec.entity}:${rec.recordId} tak bisa diterapkan (induk absen?) \u2014 dilewati`);
16568
+ console.warn(`sync: record ${rec.entity}:${rec.recordId} tak bisa diterapkan \u2014 dilewati`);
16569
+ dropped++;
16252
16570
  }
16253
16571
  break;
16254
16572
  }
@@ -16282,7 +16600,24 @@ async function syncOnce(transport) {
16282
16600
  }
16283
16601
  const snap = await snapshot(item.entity, item.recordId);
16284
16602
  if (!snap) {
16285
- await clearOutbox(item.entity, item.recordId);
16603
+ const tomb = await findTombstone(item.entity, item.recordId);
16604
+ if (!tomb) {
16605
+ await clearOutbox(item.entity, item.recordId);
16606
+ continue;
16607
+ }
16608
+ const res2 = await transport("POST", "/api/sync/push", {
16609
+ records: [{
16610
+ entity: item.entity,
16611
+ id: item.recordId,
16612
+ baseVersion: Math.max(tomb.version - 1, 0),
16613
+ op: "delete",
16614
+ data: tomb.data
16615
+ }]
16616
+ });
16617
+ if (res2.body?.results?.[0]?.ok) {
16618
+ await clearOutbox(item.entity, item.recordId);
16619
+ pushed++;
16620
+ }
16286
16621
  continue;
16287
16622
  }
16288
16623
  const res = await transport("POST", "/api/sync/push", {
@@ -16298,6 +16633,18 @@ async function syncOnce(transport) {
16298
16633
  await clearOutbox(item.entity, item.recordId);
16299
16634
  pushed++;
16300
16635
  } else if (r?.conflict) {
16636
+ if (r.deleted) {
16637
+ await applyRemote(
16638
+ item.entity,
16639
+ item.recordId,
16640
+ Number(r.deletedVersion ?? snap.version + 1),
16641
+ snap.data,
16642
+ "delete"
16643
+ );
16644
+ await clearOutbox(item.entity, item.recordId);
16645
+ deleted++;
16646
+ continue;
16647
+ }
16301
16648
  const server = r.server;
16302
16649
  if (server && JSON.stringify(server.data) !== JSON.stringify(snap.data)) {
16303
16650
  await markConflict(
@@ -16312,7 +16659,7 @@ async function syncOnce(transport) {
16312
16659
  }
16313
16660
  }
16314
16661
  }
16315
- return { pulled, pushed, conflicts };
16662
+ return { pulled, pushed, conflicts, deleted, dropped };
16316
16663
  }
16317
16664
  function fetchTransport(base2, token) {
16318
16665
  return async (method, path2, body) => {
@@ -16343,13 +16690,15 @@ async function syncNow(opts) {
16343
16690
  const transport = fetchTransport(base2, token);
16344
16691
  if (!opts?.full) return syncOnce(transport);
16345
16692
  await setCursor("0");
16346
- const total = { pulled: 0, pushed: 0, conflicts: 0 };
16693
+ const total = { pulled: 0, pushed: 0, conflicts: 0, deleted: 0, dropped: 0 };
16347
16694
  let seen = "0";
16348
16695
  for (let page = 0; page < FULL_PULL_MAX_PAGES; page++) {
16349
16696
  const s2 = await syncOnce(transport);
16350
16697
  total.pulled += s2.pulled;
16351
16698
  total.pushed += s2.pushed;
16352
16699
  total.conflicts += s2.conflicts;
16700
+ total.deleted += s2.deleted;
16701
+ total.dropped += s2.dropped;
16353
16702
  const now = await getCursor();
16354
16703
  if (now === seen) break;
16355
16704
  seen = now;
@@ -16432,6 +16781,8 @@ var init_sync_client = __esm({
16432
16781
  "use strict";
16433
16782
  init_db();
16434
16783
  init_sync();
16784
+ init_tombstone();
16785
+ init_notifications();
16435
16786
  init_conflicts();
16436
16787
  init_outbox();
16437
16788
  init_rename_project();
@@ -24804,11 +25155,42 @@ init_sync();
24804
25155
  async function notifySynced(entity, id) {
24805
25156
  try {
24806
25157
  if (!isEntity(entity)) return;
25158
+ await consumeTombstoneOnRecreate(entity, id);
24807
25159
  if (effectiveStr("SYNC_SERVER_URL")) await enqueueOutbox(entity, id);
24808
25160
  else await publishLocal(entity, id);
24809
25161
  } catch {
24810
25162
  }
24811
25163
  }
25164
+ async function notifyDeleted(entity, id) {
25165
+ try {
25166
+ if (!isEntity(entity)) return;
25167
+ if (effectiveStr("SYNC_SERVER_URL")) await enqueueOutbox(entity, id);
25168
+ else await publishDelete(entity, id);
25169
+ } catch {
25170
+ }
25171
+ }
25172
+
25173
+ // src/services/sync-delete.ts
25174
+ init_sync();
25175
+ init_tombstone();
25176
+ init_outbox();
25177
+ async function deleteSynced(entity, id, deviceId) {
25178
+ const snap = await snapshot(entity, id);
25179
+ if (!snap) return false;
25180
+ await deleteRow(entity, id);
25181
+ await writeTombstone(entity, id, snap.version + 1, snap.data, deviceId);
25182
+ await notifyDeleted(entity, id);
25183
+ return true;
25184
+ }
25185
+ async function listPendingDeletes() {
25186
+ const out3 = [];
25187
+ for (const item of await listOutbox()) {
25188
+ const tomb = await findTombstone(item.entity, item.recordId);
25189
+ if (!tomb) continue;
25190
+ out3.push({ entity: item.entity, recordId: item.recordId, deletedAt: tomb.deletedAt.toISOString() });
25191
+ }
25192
+ return out3;
25193
+ }
24812
25194
 
24813
25195
  // src/services/branches.ts
24814
25196
  import { execFile as execFile2 } from "node:child_process";
@@ -24962,7 +25344,7 @@ async function projects_default(app2) {
24962
25344
  if (!await prisma.project.findUnique({ where: { id } })) return reply.code(404).send({ error: "not found" });
24963
25345
  const active = listSessions().filter((s2) => s2.projectId === id && !s2.exited).length;
24964
25346
  if (active) return reply.code(409).send({ error: `project "${id}" masih punya ${active} sesi aktif` });
24965
- await prisma.project.delete({ where: { id } });
25347
+ await deleteSynced("project", id);
24966
25348
  return reply.code(204).send();
24967
25349
  });
24968
25350
  app2.get("/projects/:id/branches", async (req, reply) => {
@@ -25413,120 +25795,64 @@ function appendSourceHistory(current, entry) {
25413
25795
  return [...Array.isArray(current) ? current : [], entry];
25414
25796
  }
25415
25797
 
25416
- // src/services/notifications.ts
25798
+ // src/routes/specs.ts
25799
+ init_notifications();
25800
+
25801
+ // src/services/spec-complete.ts
25417
25802
  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
- });
25803
+ init_notifications();
25804
+
25805
+ // src/services/session-result.ts
25806
+ init_db();
25807
+ import { randomUUID as randomUUID3 } from "node:crypto";
25808
+ var WHITELIST = [
25809
+ "projectId",
25810
+ "specId",
25811
+ "oldStage",
25812
+ "newStage",
25813
+ "commitSha",
25814
+ "branch",
25815
+ "prUrl",
25816
+ "status",
25817
+ "deviceId",
25818
+ "author"
25819
+ ];
25820
+ async function recordSessionResult(input) {
25821
+ const id = randomUUID3();
25822
+ const data = { id };
25823
+ for (const f of WHITELIST) if (input[f] !== void 0 && input[f] !== null) data[f] = input[f];
25824
+ if (data.status === void 0) data.status = "done";
25825
+ if (data.projectId === void 0) throw new Error("session result butuh projectId");
25826
+ await prisma.sessionResult.create({ data });
25827
+ await notifySynced("sessionResult", id);
25828
+ return { id };
25485
25829
  }
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(() => {
25830
+
25831
+ // src/services/spec-complete.ts
25832
+ async function completeSpecManually(spec, input) {
25833
+ const at = input.at ?? /* @__PURE__ */ new Date();
25834
+ const manualDone = {
25835
+ at: at.toISOString(),
25836
+ by: input.by,
25837
+ ...input.reason ? { reason: input.reason } : {}
25838
+ };
25839
+ const { count } = await prisma.spec.updateMany({
25840
+ where: { id: spec.id, stage: { not: "done" } },
25841
+ data: { stage: "done", manualDone }
25490
25842
  });
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 }
25843
+ if (count === 0) return { ok: false };
25844
+ await recordCompletion(spec.id, spec.title, spec.projectId);
25845
+ await recordSessionResult({
25846
+ projectId: spec.projectId,
25847
+ specId: spec.id,
25848
+ oldStage: spec.stage,
25849
+ newStage: "done",
25850
+ status: "done",
25851
+ author: input.by
25497
25852
  }).catch(() => {
25498
25853
  });
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 };
25854
+ await notifySynced("spec", spec.id);
25855
+ return { ok: true, spec: await prisma.spec.findUnique({ where: { id: spec.id } }) };
25530
25856
  }
25531
25857
 
25532
25858
  // src/routes/specs.ts
@@ -28187,6 +28513,7 @@ init_db();
28187
28513
  init_pty();
28188
28514
  init_session_phases();
28189
28515
  init_stage_machine();
28516
+ init_notifications();
28190
28517
 
28191
28518
  // src/services/spec-head.ts
28192
28519
  init_db();
@@ -28449,6 +28776,23 @@ ${item.context}` : item.context;
28449
28776
  await notifySynced("spec", id);
28450
28777
  return updated;
28451
28778
  });
28779
+ app2.post("/specs/:id/done", async (req, reply) => {
28780
+ const { id } = req.params;
28781
+ const parsed = zMarkSpecDone.safeParse(req.body ?? {});
28782
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
28783
+ const spec = await prisma.spec.findUnique({ where: { id } });
28784
+ if (!spec) return reply.code(404).send({ error: "not found" });
28785
+ if (spec.stage === "done") return reply.code(409).send({ error: "backlog item sudah selesai" });
28786
+ const live = listSessions().find((s2) => s2.specId === id && !s2.exited);
28787
+ if (live && parsed.data.confirm !== true)
28788
+ return reply.code(409).send({ error: "confirm-required", session: { id: live.id, agent: live.agent } });
28789
+ const res = await completeSpecManually(spec, {
28790
+ by: req.user?.email ?? "system",
28791
+ reason: parsed.data.reason || void 0
28792
+ });
28793
+ if (!res.ok) return reply.code(409).send({ error: "backlog item sudah selesai" });
28794
+ return res.spec;
28795
+ });
28452
28796
  app2.get("/specs/:id/docs", async (req) => ({ files: await listSpecDocs(req.params.id) }));
28453
28797
  app2.get("/specs/:id/docs/*", async (req, reply) => {
28454
28798
  const { id } = req.params;
@@ -28469,7 +28813,7 @@ ${item.context}` : item.context;
28469
28813
  app2.delete("/specs/:id", async (req, reply) => {
28470
28814
  const { id } = req.params;
28471
28815
  const gone = await prisma.spec.findUnique({ where: { id }, select: { projectId: true } });
28472
- await prisma.spec.delete({ where: { id } }).catch(() => {
28816
+ await deleteSynced("spec", id).catch(() => {
28473
28817
  });
28474
28818
  if (gone) {
28475
28819
  const rows = await prisma.spec.findMany({
@@ -28555,6 +28899,7 @@ ${item.context}` : item.context;
28555
28899
 
28556
28900
  // src/routes/notifications.ts
28557
28901
  init_db();
28902
+ init_notifications();
28558
28903
  async function notifications_default(app2) {
28559
28904
  app2.get("/notifications", async (req) => notificationsFeed(req.query));
28560
28905
  app2.post("/notifications/read", async (_req, reply) => {
@@ -30176,6 +30521,7 @@ import { execFile as execFile8 } from "node:child_process";
30176
30521
  import { readdir as readdir2, rm as rm2 } from "node:fs/promises";
30177
30522
  import { promisify as promisify9 } from "node:util";
30178
30523
  import { join as join9, resolve as resolve14 } from "node:path";
30524
+ init_notifications();
30179
30525
  var TICK_MS = 6e4;
30180
30526
  var trashDirOf = (repoDir) => resolve14(repoDir, ".worktrees", ".trash");
30181
30527
  var exec8 = promisify9(execFile8);
@@ -30274,33 +30620,8 @@ function startWorktreeReaper(deps = prodReaperDeps) {
30274
30620
  timer.unref();
30275
30621
  }
30276
30622
 
30277
- // src/services/session-result.ts
30278
- init_db();
30279
- import { randomUUID as randomUUID3 } from "node:crypto";
30280
- var WHITELIST = [
30281
- "projectId",
30282
- "specId",
30283
- "oldStage",
30284
- "newStage",
30285
- "commitSha",
30286
- "branch",
30287
- "prUrl",
30288
- "status",
30289
- "deviceId",
30290
- "author"
30291
- ];
30292
- async function recordSessionResult(input) {
30293
- const id = randomUUID3();
30294
- const data = { id };
30295
- for (const f of WHITELIST) if (input[f] !== void 0 && input[f] !== null) data[f] = input[f];
30296
- if (data.status === void 0) data.status = "done";
30297
- if (data.projectId === void 0) throw new Error("session result butuh projectId");
30298
- await prisma.sessionResult.create({ data });
30299
- await notifySynced("sessionResult", id);
30300
- return { id };
30301
- }
30302
-
30303
30623
  // src/routes/terminal.ts
30624
+ init_notifications();
30304
30625
  init_stage_machine();
30305
30626
  init_pty();
30306
30627
  var TERMINAL_WS_MESSAGES_PER_MINUTE = 6e3;
@@ -31011,6 +31332,7 @@ function computeDrift(prev, curr) {
31011
31332
  }
31012
31333
 
31013
31334
  // src/services/vps-audit.ts
31335
+ init_notifications();
31014
31336
  var CRITICAL = [
31015
31337
  "sudo_ok",
31016
31338
  "os_supported",
@@ -31274,12 +31596,9 @@ async function vps_default(app2) {
31274
31596
  return updated;
31275
31597
  });
31276
31598
  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
- }
31599
+ const { id } = req.params;
31600
+ if (!await deleteSynced("vps", id)) return reply.code(404).send({ error: "not found" });
31601
+ return reply.code(204).send();
31283
31602
  });
31284
31603
  app2.post("/vps/:id/audit", async (req, reply) => {
31285
31604
  const v = await prisma.vps.findUnique({ where: { id: req.params.id } });
@@ -31807,6 +32126,7 @@ async function update(app2) {
31807
32126
 
31808
32127
  // src/services/events.ts
31809
32128
  init_pty();
32129
+ init_notifications();
31810
32130
 
31811
32131
  // src/services/lead/deciding.ts
31812
32132
  var deciding = /* @__PURE__ */ new Set();
@@ -31975,7 +32295,7 @@ async function events_default(app2, opts) {
31975
32295
  // src/routes/device-tokens.ts
31976
32296
  init_src();
31977
32297
  init_db();
31978
- var view = (t) => ({
32298
+ var view2 = (t) => ({
31979
32299
  id: t.id,
31980
32300
  name: t.name,
31981
32301
  createdAt: t.createdAt.toISOString(),
@@ -31983,7 +32303,7 @@ var view = (t) => ({
31983
32303
  revokedAt: t.revokedAt?.toISOString() ?? null
31984
32304
  });
31985
32305
  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));
32306
+ app2.get("/device-tokens", async (req) => (await prisma.deviceToken.findMany({ where: { userId: req.user.id }, orderBy: { createdAt: "desc" } })).map(view2));
31987
32307
  app2.post("/device-tokens", async (req, reply) => {
31988
32308
  const p = zIssueDeviceToken.safeParse(req.body);
31989
32309
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
@@ -32133,7 +32453,10 @@ var zPush = external_exports.object({
32133
32453
  entity: external_exports.string(),
32134
32454
  id: external_exports.string(),
32135
32455
  baseVersion: external_exports.number().int().nonnegative(),
32136
- data: external_exports.record(external_exports.unknown())
32456
+ data: external_exports.record(external_exports.unknown()),
32457
+ // SPEC-799 · ADR-0119 · absen = "upsert" (client versi lama). Hub versi lama membuang field ini
32458
+ // dan sekadar memperlakukan push delete sebagai update — status quo, bukan galat.
32459
+ op: external_exports.enum(["upsert", "delete"]).optional()
32137
32460
  }))
32138
32461
  });
32139
32462
  var AUTHORED = ["spec", "sessionResult"];
@@ -32163,7 +32486,7 @@ async function sync_default(app2) {
32163
32486
  }
32164
32487
  const data = { ...rec.data };
32165
32488
  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);
32489
+ const r = await applyPush(rec.entity, rec.id, rec.baseVersion, data, req.device.id, rec.op ?? "upsert");
32167
32490
  results.push({ id: rec.id, ...r });
32168
32491
  }
32169
32492
  return { results };
@@ -32175,6 +32498,10 @@ async function sync_default(app2) {
32175
32498
  return { ok: true, full, ...stats2 };
32176
32499
  });
32177
32500
  app2.get("/sync/conflicts", async () => ({ conflicts: await listConflicts() }));
32501
+ app2.get("/sync/pending", async () => {
32502
+ const deletes = await listPendingDeletes();
32503
+ return { deletes, total: deletes.length };
32504
+ });
32178
32505
  const zResolve = external_exports.object({ choice: external_exports.enum(["local", "server"]) });
32179
32506
  app2.post("/sync/conflicts/:entity/:recordId/resolve", async (req, reply) => {
32180
32507
  const { entity, recordId } = req.params;
@@ -32225,7 +32552,7 @@ async function sync_default(app2) {
32225
32552
 
32226
32553
  // src/routes/session-results.ts
32227
32554
  init_db();
32228
- var view2 = (r) => ({
32555
+ var view3 = (r) => ({
32229
32556
  id: r.id,
32230
32557
  projectId: r.projectId,
32231
32558
  specId: r.specId,
@@ -32248,7 +32575,7 @@ async function session_results_default(app2) {
32248
32575
  orderBy: { createdAt: "desc" },
32249
32576
  take: take2
32250
32577
  });
32251
- return rows.map(view2);
32578
+ return rows.map(view3);
32252
32579
  });
32253
32580
  app2.delete("/session-results", async (req, reply) => {
32254
32581
  const { projectId, before: before2 } = req.query;
@@ -32260,8 +32587,10 @@ async function session_results_default(app2) {
32260
32587
  if (Number.isNaN(d.getTime())) return reply.code(400).send({ error: "before bukan tanggal valid" });
32261
32588
  where.createdAt = { lt: d };
32262
32589
  }
32263
- const { count } = await prisma.sessionResult.deleteMany({ where });
32264
- return { purged: count };
32590
+ const rows = await prisma.sessionResult.findMany({ where, select: { id: true } });
32591
+ let purged = 0;
32592
+ for (const r of rows) if (await deleteSynced("sessionResult", r.id)) purged++;
32593
+ return { purged };
32265
32594
  });
32266
32595
  }
32267
32596
 
@@ -32314,7 +32643,7 @@ async function deleteTranscript(key) {
32314
32643
  }
32315
32644
 
32316
32645
  // src/services/session-history.ts
32317
- var view3 = (r) => ({
32646
+ var view4 = (r) => ({
32318
32647
  id: r.id,
32319
32648
  sessionId: r.sessionId,
32320
32649
  projectId: r.projectId,
@@ -32398,11 +32727,11 @@ async function listHistory(q) {
32398
32727
  skip: (page - 1) * pageSize,
32399
32728
  take: pageSize
32400
32729
  });
32401
- return { items: rows.map(view3), total, page, pageSize };
32730
+ return { items: rows.map(view4), total, page, pageSize };
32402
32731
  }
32403
32732
  async function getHistory(id) {
32404
32733
  const r = await prisma.sessionHistory.findUnique({ where: { id } });
32405
- return r ? { ...view3(r), hasTranscript: !!r.transcriptKey } : null;
32734
+ return r ? { ...view4(r), hasTranscript: !!r.transcriptKey } : null;
32406
32735
  }
32407
32736
  async function transcriptOf(id) {
32408
32737
  const r = await prisma.sessionHistory.findUnique({ where: { id }, select: { transcriptKey: true } });
@@ -32478,7 +32807,7 @@ init_config_apply();
32478
32807
  init_sync_client();
32479
32808
  var isSecret2 = (e) => e.kind === "secret";
32480
32809
  var agentBlocked = (req, e) => Boolean(req.agent) && e.category === "credential";
32481
- function view4(e) {
32810
+ function view5(e) {
32482
32811
  const eff = effectiveStr(e.key);
32483
32812
  const base2 = {
32484
32813
  key: e.key,
@@ -32498,7 +32827,7 @@ function view4(e) {
32498
32827
  }
32499
32828
  async function config_default(app2) {
32500
32829
  app2.get("/config", async () => ({
32501
- entries: CONFIG_REGISTRY.map(view4),
32830
+ entries: CONFIG_REGISTRY.map(view5),
32502
32831
  sync: syncStatus()
32503
32832
  }));
32504
32833
  app2.put("/config", async (req, reply) => {
@@ -32510,7 +32839,7 @@ async function config_default(app2) {
32510
32839
  const raw2 = b.value ?? "";
32511
32840
  if (isSecret2(entry) && raw2.trim() === "") {
32512
32841
  if (rawDbValue(entry.key) === void 0) return reply.code(400).send({ error: "tak boleh kosong" });
32513
- return view4(entry);
32842
+ return view5(entry);
32514
32843
  }
32515
32844
  const parsed = parseConfigValue(entry, raw2);
32516
32845
  if (!parsed.ok) return reply.code(400).send({ error: parsed.error });
@@ -32520,11 +32849,11 @@ async function config_default(app2) {
32520
32849
  } catch (error) {
32521
32850
  return reply.code(400).send({ error: error.message });
32522
32851
  }
32523
- return view4(entry);
32852
+ return view5(entry);
32524
32853
  }
32525
32854
  await setConfig(entry.key, parsed.value);
32526
32855
  await applyConfigSideEffect(entry.key);
32527
- return view4(entry);
32856
+ return view5(entry);
32528
32857
  });
32529
32858
  app2.delete("/config/:key", async (req, reply) => {
32530
32859
  const { key } = req.params;
@@ -32590,6 +32919,7 @@ async function pruneOldTickets(now = Date.now()) {
32590
32919
 
32591
32920
  // src/services/ticket-intake.ts
32592
32921
  init_db();
32922
+ init_notifications();
32593
32923
 
32594
32924
  // src/services/upload-pipeline.ts
32595
32925
  import { randomUUID as randomUUID6 } from "node:crypto";
@@ -33823,25 +34153,25 @@ var cachedDecoders = {
33823
34153
  };
33824
34154
  var cachedEncoder = new globalThis.TextEncoder();
33825
34155
  var byteToHexLookupTable = Array.from({ length: 256 }, (_, index) => index.toString(16).padStart(2, "0"));
33826
- function getUintBE(view11) {
33827
- const { byteLength } = view11;
34156
+ function getUintBE(view12) {
34157
+ const { byteLength } = view12;
33828
34158
  if (byteLength === 6) {
33829
- return view11.getUint16(0) * 2 ** 32 + view11.getUint32(2);
34159
+ return view12.getUint16(0) * 2 ** 32 + view12.getUint32(2);
33830
34160
  }
33831
34161
  if (byteLength === 5) {
33832
- return view11.getUint8(0) * 2 ** 32 + view11.getUint32(1);
34162
+ return view12.getUint8(0) * 2 ** 32 + view12.getUint32(1);
33833
34163
  }
33834
34164
  if (byteLength === 4) {
33835
- return view11.getUint32(0);
34165
+ return view12.getUint32(0);
33836
34166
  }
33837
34167
  if (byteLength === 3) {
33838
- return view11.getUint8(0) * 2 ** 16 + view11.getUint16(1);
34168
+ return view12.getUint8(0) * 2 ** 16 + view12.getUint16(1);
33839
34169
  }
33840
34170
  if (byteLength === 2) {
33841
- return view11.getUint16(0);
34171
+ return view12.getUint16(0);
33842
34172
  }
33843
34173
  if (byteLength === 1) {
33844
- return view11.getUint8(0);
34174
+ return view12.getUint8(0);
33845
34175
  }
33846
34176
  }
33847
34177
 
@@ -40231,9 +40561,9 @@ async function help_default(app2) {
40231
40561
  return reply.code(201).send({ number: ticket.number, key, statusPath });
40232
40562
  });
40233
40563
  app2.get("/help/:slug/tickets/:key", async (req, reply) => {
40234
- const { slug, key } = req.params;
40564
+ const { key } = req.params;
40235
40565
  const t = await prisma.ticket.findFirst({
40236
- where: { projectId: slug, OR: [{ accessKeyHash: hashAccessKey(key) }, { shareToken: key }] }
40566
+ where: { OR: [{ accessKeyHash: hashAccessKey(key) }, { shareToken: key }] }
40237
40567
  });
40238
40568
  if (!t) return reply.code(404).send({ error: "not found" });
40239
40569
  let stage = null;
@@ -40326,7 +40656,7 @@ async function acceptTicket(t, opts) {
40326
40656
 
40327
40657
  // src/routes/tickets.ts
40328
40658
  init_src();
40329
- var view5 = (t) => ({
40659
+ var view6 = (t) => ({
40330
40660
  id: t.id,
40331
40661
  projectId: t.projectId,
40332
40662
  number: t.number,
@@ -40338,7 +40668,7 @@ var view5 = (t) => ({
40338
40668
  attachmentCount: t._count?.attachments ?? 0,
40339
40669
  createdAt: t.createdAt.toISOString()
40340
40670
  });
40341
- async function tickets_default(app2) {
40671
+ async function tickets_default(app2, opts = {}) {
40342
40672
  app2.get("/tickets", async (req) => {
40343
40673
  const { project, status, q, page, limit } = req.query;
40344
40674
  const where = {};
@@ -40354,7 +40684,7 @@ async function tickets_default(app2) {
40354
40684
  rows = rows.filter((t) => `${t.title} ${t.reporterEmail}`.toLowerCase().includes(n2));
40355
40685
  }
40356
40686
  const unreviewed = rows.filter((t) => t.status === "new").length;
40357
- return { ...paginate(rows.map(view5), page, limit), unreviewed };
40687
+ return { ...paginate(rows.map(view6), page, limit), unreviewed };
40358
40688
  });
40359
40689
  app2.get("/tickets/:id", async (req, reply) => {
40360
40690
  const { id } = req.params;
@@ -40369,10 +40699,10 @@ async function tickets_default(app2) {
40369
40699
  shareToken = generateShareToken();
40370
40700
  await prisma.ticket.update({ where: { id }, data: { shareToken } });
40371
40701
  }
40372
- const base2 = `${req.protocol}://${req.headers.host ?? "localhost"}`;
40702
+ const base2 = opts.publicBase ?? `${req.protocol}://${req.headers.host ?? "localhost"}`;
40373
40703
  const publicStatusUrl = `${base2}/help/${encodeURIComponent(t.projectId)}/status/${shareToken}`;
40374
40704
  return {
40375
- ...view5(t),
40705
+ ...view6(t),
40376
40706
  detail: t.detail,
40377
40707
  attachments: t.attachments.map((a) => ({ id: a.id, filename: a.filename, mimeType: a.mimeType, size: a.size })),
40378
40708
  spec,
@@ -40432,7 +40762,7 @@ async function tickets_default(app2) {
40432
40762
  });
40433
40763
  const spec = updated.specId ? await prisma.spec.findUnique({ where: { id: updated.specId } }) : null;
40434
40764
  return {
40435
- ...view5(updated),
40765
+ ...view6(updated),
40436
40766
  detail: updated.detail,
40437
40767
  attachments: updated.attachments.map((a) => ({ id: a.id, filename: a.filename, mimeType: a.mimeType, size: a.size })),
40438
40768
  spec
@@ -40443,7 +40773,7 @@ async function tickets_default(app2) {
40443
40773
  const t = await prisma.ticket.findUnique({ where: { id }, include: { attachments: true } });
40444
40774
  if (!t) return reply.code(404).send({ error: "not found" });
40445
40775
  for (const a of t.attachments) await deleteUpload(a.storageKey);
40446
- await prisma.ticket.delete({ where: { id } });
40776
+ await deleteSynced("ticket", id);
40447
40777
  return { ok: true };
40448
40778
  });
40449
40779
  }
@@ -40550,6 +40880,7 @@ async function isQueued(id) {
40550
40880
  // src/services/scheduler/cron.ts
40551
40881
  init_db();
40552
40882
  init_src();
40883
+ init_notifications();
40553
40884
  var GRACE_MS = 30 * 6e4;
40554
40885
  function computeNextRun(expr, after) {
40555
40886
  const spec = parseCron(expr);
@@ -40900,6 +41231,7 @@ async function leadProjects() {
40900
41231
  init_src();
40901
41232
  init_db();
40902
41233
  init_pty();
41234
+ init_notifications();
40903
41235
 
40904
41236
  // src/services/lead/prompt.ts
40905
41237
  init_src();
@@ -41671,6 +42003,7 @@ init_src();
41671
42003
  init_db();
41672
42004
  init_pty();
41673
42005
  init_session_phases();
42006
+ init_notifications();
41674
42007
 
41675
42008
  // src/services/webhooks/actor.ts
41676
42009
  init_src();
@@ -41823,6 +42156,7 @@ Bukti integrasi: ${evidence.join("; ")} \u2192 ${verdict}.` }
41823
42156
  init_src();
41824
42157
  init_pty();
41825
42158
  init_tui_dialog();
42159
+ init_notifications();
41826
42160
  import { writeFileSync as writeFileSync6 } from "node:fs";
41827
42161
 
41828
42162
  // src/services/lead/pane.ts
@@ -42143,6 +42477,7 @@ function sweep(liveIds) {
42143
42477
  init_db();
42144
42478
  init_pty();
42145
42479
  init_session_phases();
42480
+ init_notifications();
42146
42481
  var moduleOf = (p) => p.split("/").slice(0, 2).join("/");
42147
42482
  function findCollisions(areas) {
42148
42483
  const out3 = [];
@@ -42410,6 +42745,7 @@ async function orderProject(projectId, deps) {
42410
42745
  }
42411
42746
 
42412
42747
  // src/services/lead/engine.ts
42748
+ init_notifications();
42413
42749
  var TICK_MS2 = 5e3;
42414
42750
  var busyDetect = false;
42415
42751
  var busyPulse = false;
@@ -42924,7 +43260,7 @@ async function generateChangelog(projectId, req, deps = {}) {
42924
43260
  }
42925
43261
 
42926
43262
  // src/routes/changelog.ts
42927
- var view6 = (c) => ({
43263
+ var view7 = (c) => ({
42928
43264
  id: c.id,
42929
43265
  projectId: c.projectId,
42930
43266
  mode: c.mode,
@@ -42964,7 +43300,7 @@ async function changelog_default(app2) {
42964
43300
  if (!await prisma.project.findUnique({ where: { id } })) return reply.code(404).send({ error: "not found" });
42965
43301
  const { page, limit, q } = req.query;
42966
43302
  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);
43303
+ return paginate(rows.filter((r) => changelogMatches(r, q ?? "")).map(view7), page, limit);
42968
43304
  });
42969
43305
  app2.get("/projects/:id/changelog/:cid", async (req, reply) => {
42970
43306
  const { id, cid } = req.params;
@@ -42978,7 +43314,7 @@ async function changelog_default(app2) {
42978
43314
  eyebrow: `hanoman \xB7 ${id} \xB7 changelog`,
42979
43315
  path: row.title
42980
43316
  });
42981
- return view6(row);
43317
+ return view7(row);
42982
43318
  });
42983
43319
  app2.post("/projects/:id/changelog", async (req, reply) => {
42984
43320
  const { id } = req.params;
@@ -42987,7 +43323,7 @@ async function changelog_default(app2) {
42987
43323
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
42988
43324
  const r = await generateChangelog(id, parsed.data);
42989
43325
  if (!r.ok) return reply.code(422).send({ error: r.reason });
42990
- return reply.code(201).send(view6(r.row));
43326
+ return reply.code(201).send(view7(r.row));
42991
43327
  });
42992
43328
  app2.delete("/projects/:id/changelog/:cid", async (req, reply) => {
42993
43329
  const { id, cid } = req.params;
@@ -43126,7 +43462,7 @@ async function installCustomAgents() {
43126
43462
 
43127
43463
  // src/routes/custom-agents.ts
43128
43464
  var rowsOf = async () => await prisma.customAgent.findMany();
43129
- var view7 = (r, projectId) => ({
43465
+ var view8 = (r, projectId) => ({
43130
43466
  id: r.id,
43131
43467
  projectId: r.projectId,
43132
43468
  name: r.name,
@@ -43171,7 +43507,7 @@ async function custom_agents_default(app2) {
43171
43507
  const byName = /* @__PURE__ */ new Map();
43172
43508
  for (const r of rows) if (r.projectId === null) byName.set(r.name, r);
43173
43509
  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));
43510
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)).map((r) => view8(r, projectId));
43175
43511
  });
43176
43512
  app2.post("/custom-agents", async (req, reply) => {
43177
43513
  const parsed = zCreateCustomAgent.safeParse(req.body);
@@ -43218,7 +43554,7 @@ async function custom_agents_default(app2) {
43218
43554
  } });
43219
43555
  await loadCustomAgents();
43220
43556
  await notifySynced("customAgent", id);
43221
- return reply.code(201).send(view7(row));
43557
+ return reply.code(201).send(view8(row));
43222
43558
  });
43223
43559
  app2.patch("/custom-agents/:id", async (req, reply) => {
43224
43560
  const { id } = req.params;
@@ -43265,13 +43601,13 @@ async function custom_agents_default(app2) {
43265
43601
  } });
43266
43602
  await loadCustomAgents();
43267
43603
  await notifySynced("customAgent", id);
43268
- return view7(row);
43604
+ return view8(row);
43269
43605
  });
43270
43606
  app2.delete("/custom-agents/:id", async (req, reply) => {
43271
43607
  const { id } = req.params;
43272
43608
  const existing = await prisma.customAgent.findUnique({ where: { id } });
43273
43609
  if (!existing) return reply.code(404).send({ error: "not found" });
43274
- await prisma.customAgent.delete({ where: { id } });
43610
+ await deleteSynced("customAgent", id);
43275
43611
  const name2 = existing.name;
43276
43612
  for (const r of await rowsOf()) {
43277
43613
  const m = mentionsOf(r.mentions);
@@ -43606,7 +43942,7 @@ var STATUS = {
43606
43942
  unauthorized: 401,
43607
43943
  other: 502
43608
43944
  };
43609
- var view8 = (i) => ({
43945
+ var view9 = (i) => ({
43610
43946
  id: i.id,
43611
43947
  projectId: i.projectId,
43612
43948
  repoSlug: i.repoSlug,
@@ -43641,7 +43977,7 @@ async function githubIssues(app2) {
43641
43977
  where: { projectId: id, ...status ? { status } : {} },
43642
43978
  orderBy: [{ number: "desc" }]
43643
43979
  });
43644
- return reply.send(paginate(items.map(view8), page, limit));
43980
+ return reply.send(paginate(items.map(view9), page, limit));
43645
43981
  });
43646
43982
  app2.post("/github-issues/accept", async (req, reply) => {
43647
43983
  const parsed = zAcceptMany.safeParse(req.body ?? {});
@@ -44401,7 +44737,7 @@ async function portal_default(app2) {
44401
44737
  // src/routes/client-accounts.ts
44402
44738
  init_src();
44403
44739
  init_db();
44404
- var view9 = (u) => ({
44740
+ var view10 = (u) => ({
44405
44741
  id: u.id,
44406
44742
  email: u.email,
44407
44743
  disabled: u.disabled,
@@ -44432,7 +44768,7 @@ async function client_accounts_default(app2) {
44432
44768
  orderBy: { createdAt: "asc" },
44433
44769
  include: { projectAccess: { select: { projectId: true } } }
44434
44770
  });
44435
- return { items: rows.map(view9) };
44771
+ return { items: rows.map(view10) };
44436
44772
  });
44437
44773
  app2.post("/client-accounts", async (req, reply) => {
44438
44774
  const p = zCreateClientAccount.safeParse(req.body);
@@ -44446,7 +44782,7 @@ async function client_accounts_default(app2) {
44446
44782
  await prisma.user.delete({ where: { id: user.id } });
44447
44783
  return reply.code(400).send({ error: "ada project yang tidak dikenal" });
44448
44784
  }
44449
- return reply.code(201).send(view9(await load(user.id)));
44785
+ return reply.code(201).send(view10(await load(user.id)));
44450
44786
  });
44451
44787
  app2.patch("/client-accounts/:id", async (req, reply) => {
44452
44788
  const { id } = req.params;
@@ -44460,7 +44796,7 @@ async function client_accounts_default(app2) {
44460
44796
  if (p.data.password)
44461
44797
  await prisma.user.update({ where: { id }, data: { passwordHash: await hashPassword(p.data.password) } });
44462
44798
  if (p.data.disabled || p.data.password) await deleteUserSessions(id);
44463
- return view9(await load(id));
44799
+ return view10(await load(id));
44464
44800
  });
44465
44801
  app2.delete("/client-accounts/:id", async (req, reply) => {
44466
44802
  const { id } = req.params;
@@ -44556,7 +44892,7 @@ async function consumeSetupToken(home3) {
44556
44892
 
44557
44893
  // src/routes/auth.ts
44558
44894
  var BOOTSTRAP_USER_ID = "bootstrap-admin";
44559
- var view10 = (u) => ({ id: u.id, email: u.email, role: u.role, createdAt: u.createdAt.toISOString() });
44895
+ var view11 = (u) => ({ id: u.id, email: u.email, role: u.role, createdAt: u.createdAt.toISOString() });
44560
44896
  async function issue(reply, userId) {
44561
44897
  const token = await createSession2(userId);
44562
44898
  reply.setCookie(COOKIE_NAME, token, cookieOpts());
@@ -44596,7 +44932,7 @@ async function auth_default(app2, opts) {
44596
44932
  if (opts.bootstrapRequired) await consumeSetupToken(opts.home);
44597
44933
  setupAttempts.clear(req.ip);
44598
44934
  await issue(reply, user.id);
44599
- return { user: view10(user) };
44935
+ return { user: view11(user) };
44600
44936
  });
44601
44937
  app2.post("/auth/login", async (req, reply) => {
44602
44938
  if (loginThrottle(req.ip).blocked) return reply.code(429).send({ error: "too many attempts" });
@@ -44609,7 +44945,7 @@ async function auth_default(app2, opts) {
44609
44945
  }
44610
44946
  clearLoginFails(req.ip);
44611
44947
  await issue(reply, user.id);
44612
- return { user: view10(user) };
44948
+ return { user: view11(user) };
44613
44949
  });
44614
44950
  app2.post("/auth/logout", async (req, reply) => {
44615
44951
  const token = req.cookies?.[COOKIE_NAME];
@@ -44617,7 +44953,7 @@ async function auth_default(app2, opts) {
44617
44953
  reply.clearCookie(COOKIE_NAME, { path: "/" });
44618
44954
  return reply.code(204).send();
44619
44955
  });
44620
- app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view10));
44956
+ app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view11));
44621
44957
  app2.post("/auth/users", async (req, reply) => {
44622
44958
  const p = zSignup.safeParse(req.body);
44623
44959
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
@@ -44628,7 +44964,7 @@ async function auth_default(app2, opts) {
44628
44964
  // (/api/client-accounts) supaya "undang rekan" dan "beri akses klien" tak pernah tertukar.
44629
44965
  data: { email: p.data.email, passwordHash: await hashPassword(p.data.password), role: "admin" }
44630
44966
  });
44631
- return view10(user);
44967
+ return view11(user);
44632
44968
  });
44633
44969
  app2.delete("/auth/users/:id", async (req, reply) => {
44634
44970
  const target2 = await prisma.user.findUnique({ where: { id: req.params.id } });
@@ -44651,7 +44987,7 @@ async function auth_default(app2, opts) {
44651
44987
  });
44652
44988
  await deleteUserSessions(user.id);
44653
44989
  await issue(reply, user.id);
44654
- return { user: view10(user) };
44990
+ return { user: view11(user) };
44655
44991
  });
44656
44992
  }
44657
44993
 
@@ -44664,15 +45000,15 @@ async function agent_tokens_default(app2) {
44664
45000
  app2.post("/agent-tokens", async (req, reply) => {
44665
45001
  const p = zAgentTokenCreate.safeParse(req.body);
44666
45002
  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 });
45003
+ const { view: view12, token } = await issueAgentToken({ ...p.data, createdBy: req.user?.id });
45004
+ return reply.code(201).send({ ...view12, token });
44669
45005
  });
44670
45006
  app2.patch("/agent-tokens/:id", async (req, reply) => {
44671
45007
  const p = zAgentTokenPatch.safeParse(req.body);
44672
45008
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
44673
45009
  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" });
45010
+ const view12 = await patchAgentToken(id, p.data);
45011
+ return view12 ? reply.send(view12) : reply.code(404).send({ error: "not found" });
44676
45012
  });
44677
45013
  app2.delete("/agent-tokens/:id", async (req, reply) => {
44678
45014
  const { id } = req.params;
@@ -44881,22 +45217,31 @@ init_runtime();
44881
45217
 
44882
45218
  // src/services/ingress-policy.ts
44883
45219
  function origins(value, key) {
44884
- const out3 = /* @__PURE__ */ new Set();
45220
+ const out3 = [];
44885
45221
  for (const raw2 of value?.split(",") ?? []) {
44886
45222
  const input = raw2.trim();
44887
45223
  if (!input) continue;
44888
45224
  const parsed = new URL(input);
44889
45225
  if (!/^https?:$/.test(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== "/")
44890
45226
  throw new Error(`${key} harus berisi exact http(s) origin`);
44891
- out3.add(parsed.host.toLowerCase());
45227
+ out3.push({ host: parsed.host.toLowerCase(), base: `${parsed.protocol}//${parsed.host}` });
44892
45228
  }
44893
45229
  return out3;
44894
45230
  }
44895
45231
  function loadIngressPolicy(env) {
44896
- const publicHosts = origins(env.HANOMAN_PUBLIC_ORIGINS, "HANOMAN_PUBLIC_ORIGINS");
44897
- const controlHosts = origins(env.HANOMAN_CONTROL_ORIGINS, "HANOMAN_CONTROL_ORIGINS");
45232
+ const pub = origins(env.HANOMAN_PUBLIC_ORIGINS, "HANOMAN_PUBLIC_ORIGINS");
45233
+ const control = origins(env.HANOMAN_CONTROL_ORIGINS, "HANOMAN_CONTROL_ORIGINS");
45234
+ const publicHosts = new Set(pub.map((o) => o.host));
45235
+ const controlHosts = new Set(control.map((o) => o.host));
44898
45236
  for (const host2 of publicHosts) if (controlHosts.has(host2)) throw new Error("public dan control origin harus berbeda");
44899
- return { publicHosts, controlHosts, enforce: publicHosts.size > 0 || controlHosts.size > 0 };
45237
+ return {
45238
+ publicHosts,
45239
+ controlHosts,
45240
+ // SPEC-805 · origin publik pertama, lengkap dengan scheme-nya: satu-satunya basis sah untuk
45241
+ // link status yang dibagikan operator, yang selalu lahir di request ber-host control.
45242
+ publicBase: pub[0]?.base ?? null,
45243
+ enforce: publicHosts.size > 0 || controlHosts.size > 0
45244
+ };
44900
45245
  }
44901
45246
  function publicPath(method, path2) {
44902
45247
  if (method === "GET" && path2 === "/api/health") return true;
@@ -44908,7 +45253,8 @@ function classifyIngress(req, policy) {
44908
45253
  const host2 = req.host.trim().toLowerCase();
44909
45254
  const path2 = req.url.split("?")[0] ?? req.url;
44910
45255
  if (policy.publicHosts.has(host2)) return publicPath(req.method, path2) ? "public" : "denied";
44911
- if (policy.controlHosts.has(host2)) return publicPath(req.method, path2) && path2.startsWith("/api/help") ? "denied" : "control";
45256
+ if (policy.controlHosts.has(host2))
45257
+ return policy.publicBase && path2.startsWith("/api/help") ? "denied" : "control";
44912
45258
  return "denied";
44913
45259
  }
44914
45260
  function trustProxyFromEnv(env) {
@@ -44941,6 +45287,8 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
44941
45287
  app2.addHook("onRequest", async (req, reply) => {
44942
45288
  const role = classifyIngress({ host: req.headers.host ?? "", method: req.method, url: req.url }, ingress);
44943
45289
  if (role === "denied") return reply.code(404).send({ error: "not found" });
45290
+ if (role === "control" && ingress.publicBase && req.url.startsWith("/help/"))
45291
+ return reply.redirect(`${ingress.publicBase}${req.url}`, 302);
44944
45292
  });
44945
45293
  app2.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
44946
45294
  if (!body) return done(null, void 0);
@@ -44971,7 +45319,7 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
44971
45319
  if (PUBLIC.has(`${req.method} ${path2}`)) return;
44972
45320
  if (user?.role === "client" && !clientRouteAllowed(req.method, path2))
44973
45321
  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;
45322
+ if (path2.startsWith("/api/sync") && path2 !== "/api/sync/now" && path2 !== "/api/sync/pending" && !path2.startsWith("/api/sync/conflicts")) return;
44975
45323
  if (path2.startsWith("/api/help")) return;
44976
45324
  if (user) return;
44977
45325
  const agentTok = agentTokenFromReq(req);
@@ -45020,7 +45368,7 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
45020
45368
  await api.register(session_history_default);
45021
45369
  await api.register(config_default);
45022
45370
  await api.register(help_default);
45023
- await api.register(tickets_default);
45371
+ await api.register(tickets_default, { publicBase: ingress.publicBase });
45024
45372
  await api.register(scheduler_default);
45025
45373
  await api.register(codex);
45026
45374
  await api.register(methods);
@@ -45085,6 +45433,7 @@ init_src();
45085
45433
 
45086
45434
  // src/services/scheduler/governor.ts
45087
45435
  init_db();
45436
+ init_notifications();
45088
45437
  var ALREADY_DONE_NOTE = "spec sudah selesai \u2014 tak diluncurkan";
45089
45438
  var canceledRaceNote = (sessionId2) => `dibatalkan saat sesi ${sessionId2} sudah terlanjur lahir \u2014 sesi dibiarkan hidup, tutup dari Terminal bila tak diperlukan`;
45090
45439
  var CAP_FULL_NOTE = "cap penuh \u2014 tak ada slot sesi";
@@ -45219,6 +45568,7 @@ async function startCronSession(cron) {
45219
45568
  // src/services/scheduler/reconcile.ts
45220
45569
  init_db();
45221
45570
  init_src2();
45571
+ init_notifications();
45222
45572
  init_stage_machine();
45223
45573
  init_pty();
45224
45574
  init_session_phases();
@@ -45282,6 +45632,7 @@ var reconcileProdDeps = {
45282
45632
  };
45283
45633
 
45284
45634
  // src/services/scheduler/engine.ts
45635
+ init_notifications();
45285
45636
  init_pty();
45286
45637
  var prodEnd = {
45287
45638
  reconcile: () => reconcile(reconcileProdDeps),
@@ -45656,6 +46007,7 @@ init_src();
45656
46007
  init_db();
45657
46008
  import { execFile as execFile13 } from "node:child_process";
45658
46009
  import { promisify as promisify12 } from "node:util";
46010
+ init_notifications();
45659
46011
  var AUTO_MERGE_WINDOW_MS = 24 * 60 * 60 * 1e3;
45660
46012
  var AUTO_MERGE_GRACE_MS = 15 * 60 * 1e3;
45661
46013
  var TICK_MS5 = 6e4;
@@ -45709,6 +46061,7 @@ async function sweepAutoMerge(deps = prodAutoMergeDeps, now = /* @__PURE__ */ ne
45709
46061
  async function settleOne(c, deps, now) {
45710
46062
  const spec = await prisma.spec.findUnique({ where: { id: c.specId } });
45711
46063
  if (!spec || spec.stage !== "done") return false;
46064
+ if (spec.manualDone) return false;
45712
46065
  const project = await prisma.project.findUnique({ where: { id: spec.projectId } });
45713
46066
  if (!project) return false;
45714
46067
  const policy = resolveAutoMerge(