hanoman 0.1.47 → 0.1.50

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
@@ -5507,6 +5507,10 @@ var init_dto = __esm({
5507
5507
  cwd: external_exports.string(),
5508
5508
  startedAt: external_exports.string(),
5509
5509
  endedAt: external_exports.string().nullable(),
5510
+ // SPEC-844 · ADR-0125 · `string` longgar, bukan enum: baris lama null dan nilai asing dari
5511
+ // instance lebih baru tak boleh melempar di boundary — `sessionOutcome()` yang menafsirkannya.
5512
+ endedReason: external_exports.string().nullable(),
5513
+ reconciledAt: external_exports.string().nullable(),
5510
5514
  exitCode: external_exports.number().nullable(),
5511
5515
  transcriptBytes: external_exports.number().nullable()
5512
5516
  });
@@ -6402,6 +6406,13 @@ var init_session_kind = __esm({
6402
6406
  }
6403
6407
  });
6404
6408
 
6409
+ // ../shared/src/session-end.ts
6410
+ var init_session_end = __esm({
6411
+ "../shared/src/session-end.ts"() {
6412
+ "use strict";
6413
+ }
6414
+ });
6415
+
6405
6416
  // ../shared/src/config.ts
6406
6417
  var zHanomanConfig;
6407
6418
  var init_config = __esm({
@@ -6890,6 +6901,8 @@ var init_webhook = __esm({
6890
6901
  "branch",
6891
6902
  "startedAt",
6892
6903
  "endedAt",
6904
+ "endedReason",
6905
+ "reconciledAt",
6893
6906
  "exitCode"
6894
6907
  ],
6895
6908
  projectIdField: "projectId",
@@ -6898,9 +6911,9 @@ var init_webhook = __esm({
6898
6911
  },
6899
6912
  derived: [{
6900
6913
  type: "session.ended",
6901
- label: "Sesi selesai atau gagal",
6914
+ label: "Sesi selesai, gagal, atau terputus",
6902
6915
  changed: ["endedAt"],
6903
- when: "Sesi ditutup dan endedAt terisi. exitCode bukan 0 berarti pane mati gagal; null berarti tak terbaca, misalnya tmux mati di luar hanoman."
6916
+ when: 'Sesi berakhir dan endedAt terisi. endedReason memberi tahu CARANYA: "closed" = hanoman menutupnya, jadi exitCode berlaku (null = agen masih hidup saat ditutup, 0 = keluar bersih, selain itu gagal); "reconciled" = panenya sudah lenyap saat hanoman menyala lagi (reboot, kill-server, host mati) sehingga hasilnya TAK DIKETAHUI, exitCode selalu null, dan reconciledAt menyebut kapan hanoman menemukannya \u2014 endedAt di kasus itu batas BAWAH, bukan waktu berakhir sebenarnya. null = baris lahir sebelum SPEC-844.'
6904
6917
  }],
6905
6918
  sample: {
6906
6919
  id: "6f0c1c1e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
@@ -6916,6 +6929,8 @@ var init_webhook = __esm({
6916
6929
  branch: "hanoman/spec-481",
6917
6930
  startedAt: "2026-08-01T02:12:31.000Z",
6918
6931
  endedAt: "2026-08-01T09:44:02.000Z",
6932
+ endedReason: "closed",
6933
+ reconciledAt: null,
6919
6934
  exitCode: 0
6920
6935
  }
6921
6936
  },
@@ -7833,6 +7848,7 @@ var init_src = __esm({
7833
7848
  init_portal();
7834
7849
  init_prd_status();
7835
7850
  init_session_kind();
7851
+ init_session_end();
7836
7852
  init_config();
7837
7853
  init_config_registry();
7838
7854
  init_semver();
@@ -8768,6 +8784,16 @@ function resolveHome(env = process.env, home3 = homedir()) {
8768
8784
  const v = env.HANOMAN_HOME?.trim();
8769
8785
  return v ? v : join(home3, ".hanoman");
8770
8786
  }
8787
+ function resolveDataDirs(env = process.env, home3 = homedir()) {
8788
+ const root = resolveHome(env, home3);
8789
+ const dir2 = (key, name2) => env[key]?.trim() || join(root, name2);
8790
+ return {
8791
+ home: root,
8792
+ transcripts: dir2("HANOMAN_TRANSCRIPT_DIR", "transcripts"),
8793
+ uploads: dir2("HANOMAN_UPLOAD_DIR", "uploads"),
8794
+ sshKeys: env.HANOMAN_SSH_KEY_DIR?.trim() || root
8795
+ };
8796
+ }
8771
8797
  function schemeOf(raw2) {
8772
8798
  return raw2.split("://")[0] ?? "?";
8773
8799
  }
@@ -9728,7 +9754,7 @@ function extFor(mimeType) {
9728
9754
  return EXT[mimeType] ?? ".bin";
9729
9755
  }
9730
9756
  function uploadDir() {
9731
- return resolve8(effectiveStr("HANOMAN_UPLOAD_DIR") ?? join5(resolveHome(), "uploads"));
9757
+ return resolve8(effectiveStr("HANOMAN_UPLOAD_DIR")?.trim() || resolveDataDirs().uploads);
9732
9758
  }
9733
9759
  function sessionUploadDir(sessionId2) {
9734
9760
  if (!SESSION_ID.test(sessionId2)) throw new Error(`sessionId tak sah: ${sessionId2}`);
@@ -10271,6 +10297,16 @@ function createSession(projectId, cwd, opts = {}) {
10271
10297
  });
10272
10298
  if (opts.decisionFile) mkdirSync4(dirname7(opts.decisionFile), { recursive: true });
10273
10299
  tmux(
10300
+ // tmux menyerahkan argumen perintah `new-session` ke `default-shell`, dan defaultnya adalah
10301
+ // shell login pemanggil di /etc/passwd. Saat hanoman jalan sebagai user service ber-shell
10302
+ // `/usr/sbin/nologin`, SETIAP pane lahir langsung mati ("Attempted login by UNKNOWN") dan tak
10303
+ // satu pun sesi terminal bisa hidup. Dipatok eksplisit — dan wajib mendahului `new-session`,
10304
+ // sama seperti remain-on-exit di bawah.
10305
+ "set-option",
10306
+ "-g",
10307
+ "default-shell",
10308
+ shellBin(),
10309
+ ";",
10274
10310
  "set-option",
10275
10311
  "-g",
10276
10312
  "remain-on-exit",
@@ -20844,7 +20880,7 @@ var require_websocket_server = __commonJS({
20844
20880
  var keyRegex = /^[+/0-9A-Za-z]{22}==$/;
20845
20881
  var RUNNING = 0;
20846
20882
  var CLOSING = 1;
20847
- var CLOSED = 2;
20883
+ var CLOSED2 = 2;
20848
20884
  var WebSocketServer2 = class extends EventEmitter {
20849
20885
  /**
20850
20886
  * Create a `WebSocketServer` instance.
@@ -20973,7 +21009,7 @@ var require_websocket_server = __commonJS({
20973
21009
  * @public
20974
21010
  */
20975
21011
  close(cb) {
20976
- if (this._state === CLOSED) {
21012
+ if (this._state === CLOSED2) {
20977
21013
  if (cb) {
20978
21014
  this.once("close", () => {
20979
21015
  cb(new Error("The server is not running"));
@@ -21197,7 +21233,7 @@ var require_websocket_server = __commonJS({
21197
21233
  };
21198
21234
  }
21199
21235
  function emitClose(server) {
21200
- server._state = CLOSED;
21236
+ server._state = CLOSED2;
21201
21237
  server.emit("close");
21202
21238
  }
21203
21239
  function socketOnError() {
@@ -24983,8 +25019,8 @@ function components(rel) {
24983
25019
  }
24984
25020
  async function canonicalRoot(root) {
24985
25021
  const out3 = await realpath(root).catch(() => denied("root tidak ada"));
24986
- const stat2 = await lstat(out3);
24987
- if (!stat2.isDirectory() || stat2.isSymbolicLink()) denied("root bukan direktori regular");
25022
+ const stat3 = await lstat(out3);
25023
+ if (!stat3.isDirectory() || stat3.isSymbolicLink()) denied("root bukan direktori regular");
24988
25024
  return out3;
24989
25025
  }
24990
25026
  function beneath(root, candidate) {
@@ -25002,8 +25038,8 @@ async function ensureRepoParents(root, rel) {
25002
25038
  } catch (error) {
25003
25039
  if (error.code !== "EEXIST") throw error;
25004
25040
  }
25005
- const stat2 = await lstat(current);
25006
- if (stat2.isSymbolicLink() || !stat2.isDirectory()) denied("parent tidak aman");
25041
+ const stat3 = await lstat(current);
25042
+ if (stat3.isSymbolicLink() || !stat3.isDirectory()) denied("parent tidak aman");
25007
25043
  const canonical = await realpath(current);
25008
25044
  if (!beneath(base2, canonical) || canonical !== current) denied("parent keluar root");
25009
25045
  }
@@ -25019,8 +25055,8 @@ function ensureRepoParentsSync(root, rel) {
25019
25055
  } catch (error) {
25020
25056
  if (error.code !== "EEXIST") throw error;
25021
25057
  }
25022
- const stat2 = lstatSync(current);
25023
- if (stat2.isSymbolicLink() || !stat2.isDirectory()) denied("parent tidak aman");
25058
+ const stat3 = lstatSync(current);
25059
+ if (stat3.isSymbolicLink() || !stat3.isDirectory()) denied("parent tidak aman");
25024
25060
  if (realpathSync2(current) !== current || !beneath(base2, current)) denied("parent keluar root");
25025
25061
  }
25026
25062
  }
@@ -25030,14 +25066,14 @@ async function resolveRepoEntry(root, rel, opts = {}) {
25030
25066
  let current = base2;
25031
25067
  for (let i = 0; i < parts.length; i++) {
25032
25068
  current = join6(current, parts[i]);
25033
- const stat2 = await lstat(current).catch((error) => {
25069
+ const stat3 = await lstat(current).catch((error) => {
25034
25070
  if (error.code === "ENOENT" && opts.allowMissingTail) return null;
25035
25071
  if (error.code === "ENOENT" && opts.allowMissingFinal && i === parts.length - 1) return null;
25036
25072
  return denied("komponen tidak ada");
25037
25073
  });
25038
- if (!stat2) break;
25039
- if (stat2.isSymbolicLink()) denied("symlink");
25040
- if (i < parts.length - 1 && !stat2.isDirectory()) denied("parent bukan direktori");
25074
+ if (!stat3) break;
25075
+ if (stat3.isSymbolicLink()) denied("symlink");
25076
+ if (i < parts.length - 1 && !stat3.isDirectory()) denied("parent bukan direktori");
25041
25077
  }
25042
25078
  if (!beneath(base2, resolve9(current))) denied("keluar root");
25043
25079
  const parent = resolve9(current, "..");
@@ -25052,9 +25088,9 @@ function assertSafeRepoPathSync(root, rel, allowMissingFinal = false, allowMissi
25052
25088
  for (let i = 0; i < parts.length; i++) {
25053
25089
  current = join6(current, parts[i]);
25054
25090
  try {
25055
- const stat2 = lstatSync(current);
25056
- if (stat2.isSymbolicLink()) denied("symlink");
25057
- if (i < parts.length - 1 && !stat2.isDirectory()) denied("parent bukan direktori");
25091
+ const stat3 = lstatSync(current);
25092
+ if (stat3.isSymbolicLink()) denied("symlink");
25093
+ if (i < parts.length - 1 && !stat3.isDirectory()) denied("parent bukan direktori");
25058
25094
  } catch (error) {
25059
25095
  if (allowMissingTail && error.code === "ENOENT") break;
25060
25096
  if (allowMissingFinal && error.code === "ENOENT" && i === parts.length - 1) break;
@@ -25109,8 +25145,8 @@ async function readRepoFile(root, rel) {
25109
25145
  const entry = await resolveRepoEntry(root, rel);
25110
25146
  const handle = await open2(entry.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch(() => denied("final component tidak aman"));
25111
25147
  try {
25112
- const stat2 = await handle.stat();
25113
- if (!stat2.isFile()) denied("bukan file regular");
25148
+ const stat3 = await handle.stat();
25149
+ if (!stat3.isFile()) denied("bukan file regular");
25114
25150
  return await handle.readFile();
25115
25151
  } finally {
25116
25152
  await handle.close();
@@ -26560,8 +26596,8 @@ var FileTokenizer = class _FileTokenizer extends AbstractTokenizer {
26560
26596
  */
26561
26597
  static async fromFile(sourceFilePath) {
26562
26598
  const fileHandle = await fsOpen(sourceFilePath, "r");
26563
- const stat2 = await fileHandle.stat();
26564
- return new _FileTokenizer(fileHandle, { fileInfo: { path: sourceFilePath, size: stat2.size } });
26599
+ const stat3 = await fileHandle.stat();
26600
+ return new _FileTokenizer(fileHandle, { fileInfo: { path: sourceFilePath, size: stat3.size } });
26565
26601
  }
26566
26602
  constructor(fileHandle, options2) {
26567
26603
  super(options2);
@@ -39673,16 +39709,32 @@ async function buildChecklist(vpsId) {
39673
39709
  }
39674
39710
 
39675
39711
  // src/services/vps-key.ts
39712
+ init_src2();
39676
39713
  init_config2();
39677
39714
  import { execFileSync as execFileSync2 } from "node:child_process";
39678
- import { existsSync as existsSync12, mkdirSync as mkdirSync8, readFileSync as readFileSync9 } from "node:fs";
39715
+ import { chmodSync as chmodSync3, copyFileSync, existsSync as existsSync12, mkdirSync as mkdirSync8, readFileSync as readFileSync9, unlinkSync as unlinkSync2 } from "node:fs";
39679
39716
  import { homedir as homedir5 } from "node:os";
39680
39717
  import { join as join17 } from "node:path";
39681
- var keyDir = () => effectiveStr("HANOMAN_SSH_KEY_DIR") ?? join17(homedir5(), ".hanoman");
39718
+ var keyDir = () => effectiveStr("HANOMAN_SSH_KEY_DIR") ?? resolveDataDirs().sshKeys;
39719
+ var KEY_FILES = ["id_ed25519", "id_ed25519.pub"];
39720
+ function adoptLegacyKey(dir2) {
39721
+ if (effectiveStr("HANOMAN_SSH_KEY_DIR")) return;
39722
+ const legacy = join17(homedir5(), ".hanoman");
39723
+ if (legacy === dir2) return;
39724
+ if (!KEY_FILES.every((f) => existsSync12(join17(legacy, f)))) return;
39725
+ mkdirSync8(dir2, { recursive: true, mode: 448 });
39726
+ for (const f of KEY_FILES) {
39727
+ copyFileSync(join17(legacy, f), join17(dir2, f));
39728
+ chmodSync3(join17(dir2, f), 384);
39729
+ }
39730
+ for (const f of KEY_FILES) unlinkSync2(join17(legacy, f));
39731
+ console.log(`vps: key SSH dipindah dari ${legacy} ke ${dir2} (SPEC-846 \u2014 satu batas backup)`);
39732
+ }
39682
39733
  function ensureHanomanKey() {
39683
39734
  const dir2 = keyDir();
39684
- const privPath = join17(dir2, "id_ed25519");
39735
+ const privPath = join17(dir2, KEY_FILES[0]);
39685
39736
  const pubPath = `${privPath}.pub`;
39737
+ if (!existsSync12(privPath)) adoptLegacyKey(dir2);
39686
39738
  if (!existsSync12(privPath)) {
39687
39739
  mkdirSync8(dir2, { recursive: true, mode: 448 });
39688
39740
  execFileSync2(
@@ -40708,11 +40760,11 @@ import { randomUUID as randomUUID8 } from "node:crypto";
40708
40760
  init_config2();
40709
40761
  init_src2();
40710
40762
  import { randomUUID as randomUUID7 } from "node:crypto";
40711
- import { mkdir as mkdir5, writeFile as writeFile4, readFile as readFile4, unlink as unlink4 } from "node:fs/promises";
40763
+ import { mkdir as mkdir5, writeFile as writeFile4, readFile as readFile4, unlink as unlink4, readdir as readdir5, stat as stat2 } from "node:fs/promises";
40712
40764
  import { join as join20, resolve as resolve17, basename as basename5 } from "node:path";
40713
40765
  var MAX_TRANSCRIPT_BYTES = 1024 * 1024;
40714
40766
  function transcriptDir() {
40715
- return resolve17(effectiveStr("HANOMAN_TRANSCRIPT_DIR") ?? join20(resolveHome(), "transcripts"));
40767
+ return resolve17(effectiveStr("HANOMAN_TRANSCRIPT_DIR")?.trim() || resolveDataDirs().transcripts);
40716
40768
  }
40717
40769
  function clamp(text) {
40718
40770
  const buf = Buffer.from(text, "utf8");
@@ -40743,11 +40795,35 @@ async function readTranscript(key) {
40743
40795
  }
40744
40796
  async function deleteTranscript(key) {
40745
40797
  if (!key) return;
40746
- await unlink4(join20(transcriptDir(), basename5(key))).catch(() => {
40747
- });
40798
+ try {
40799
+ await unlink4(join20(transcriptDir(), basename5(key)));
40800
+ } catch (e) {
40801
+ if (e.code !== "ENOENT") throw e;
40802
+ }
40803
+ }
40804
+ async function listTranscripts() {
40805
+ const dir2 = transcriptDir();
40806
+ let names;
40807
+ try {
40808
+ names = await readdir5(dir2);
40809
+ } catch (e) {
40810
+ if (e.code === "ENOENT") return [];
40811
+ throw e;
40812
+ }
40813
+ const rows = [];
40814
+ for (const name2 of names) {
40815
+ if (!name2.endsWith(".log")) continue;
40816
+ try {
40817
+ rows.push({ key: name2, mtimeMs: (await stat2(join20(dir2, name2))).mtimeMs });
40818
+ } catch {
40819
+ }
40820
+ }
40821
+ return rows;
40748
40822
  }
40749
40823
 
40750
40824
  // src/services/session-history.ts
40825
+ var CLOSED = "closed";
40826
+ var RECONCILED = "reconciled";
40751
40827
  var view5 = (r) => ({
40752
40828
  id: r.id,
40753
40829
  sessionId: r.sessionId,
@@ -40763,6 +40839,8 @@ var view5 = (r) => ({
40763
40839
  cwd: r.cwd,
40764
40840
  startedAt: r.startedAt.toISOString(),
40765
40841
  endedAt: r.endedAt?.toISOString() ?? null,
40842
+ endedReason: r.endedReason,
40843
+ reconciledAt: r.reconciledAt?.toISOString() ?? null,
40766
40844
  exitCode: r.exitCode,
40767
40845
  transcriptBytes: r.transcriptBytes
40768
40846
  });
@@ -40795,11 +40873,19 @@ async function finishSession(d) {
40795
40873
  orderBy: { startedAt: "desc" }
40796
40874
  });
40797
40875
  if (!open4) return;
40798
- const t = d.transcript ? await saveTranscript(d.transcript) : { key: "", bytes: 0 };
40876
+ let t = { key: "", bytes: 0 };
40877
+ if (d.transcript) {
40878
+ try {
40879
+ t = await saveTranscript(d.transcript);
40880
+ } catch (e) {
40881
+ console.error("riwayat sesi (transkrip tak tersimpan):", e);
40882
+ }
40883
+ }
40799
40884
  await prisma.sessionHistory.update({
40800
40885
  where: { id: open4.id },
40801
40886
  data: {
40802
40887
  endedAt: /* @__PURE__ */ new Date(),
40888
+ endedReason: CLOSED,
40803
40889
  exitCode: d.exitCode,
40804
40890
  transcriptKey: t.key || null,
40805
40891
  transcriptBytes: t.key ? t.bytes : null
@@ -40844,15 +40930,71 @@ async function transcriptOf(id) {
40844
40930
  const text = await readTranscript(r.transcriptKey);
40845
40931
  return text === null ? null : { text, bytes: Buffer.byteLength(text, "utf8") };
40846
40932
  }
40933
+ var PURGE_BATCH = 200;
40847
40934
  async function purgeHistory(q) {
40848
40935
  const where = {
40849
40936
  ...q.projectId ? { projectId: q.projectId } : {},
40850
40937
  ...q.before ? { startedAt: { lt: q.before } } : {}
40851
40938
  };
40852
- const doomed = await prisma.sessionHistory.findMany({ where, select: { transcriptKey: true } });
40853
- for (const d of doomed) if (d.transcriptKey) await deleteTranscript(d.transcriptKey);
40854
- const { count } = await prisma.sessionHistory.deleteMany({ where });
40855
- return count;
40939
+ const doomed = await prisma.sessionHistory.findMany({ where, select: { id: true, transcriptKey: true } });
40940
+ const report = { purged: 0, transcriptsDeleted: 0, transcriptsFailed: 0 };
40941
+ for (let i = 0; i < doomed.length; i += PURGE_BATCH) {
40942
+ const batch = doomed.slice(i, i + PURGE_BATCH);
40943
+ const { count } = await prisma.sessionHistory.deleteMany({ where: { id: { in: batch.map((d) => d.id) } } });
40944
+ report.purged += count;
40945
+ for (const d of batch) {
40946
+ if (!d.transcriptKey) continue;
40947
+ try {
40948
+ await deleteTranscript(d.transcriptKey);
40949
+ report.transcriptsDeleted++;
40950
+ } catch {
40951
+ report.transcriptsFailed++;
40952
+ }
40953
+ }
40954
+ }
40955
+ return report;
40956
+ }
40957
+ var TRANSCRIPT_GC_GRACE_MS = 60 * 6e4;
40958
+ async function reconcileTranscripts(opts = {}) {
40959
+ const rows = await prisma.sessionHistory.findMany({
40960
+ where: { transcriptKey: { not: null } },
40961
+ select: { id: true, transcriptKey: true }
40962
+ });
40963
+ const referenced = new Set(rows.map((r) => r.transcriptKey));
40964
+ const onDisk = await listTranscripts();
40965
+ const present = new Set(onDisk.map((f) => f.key));
40966
+ const cutoff = Date.now() - (opts.graceMs ?? TRANSCRIPT_GC_GRACE_MS);
40967
+ const report = { orphans: 0, dangling: 0, failed: 0 };
40968
+ for (const f of onDisk) {
40969
+ if (referenced.has(f.key) || f.mtimeMs > cutoff) continue;
40970
+ if (opts.dryRun) {
40971
+ report.orphans++;
40972
+ continue;
40973
+ }
40974
+ try {
40975
+ await deleteTranscript(f.key);
40976
+ report.orphans++;
40977
+ } catch {
40978
+ report.failed++;
40979
+ }
40980
+ }
40981
+ for (const r of rows) {
40982
+ if (present.has(r.transcriptKey)) continue;
40983
+ if (opts.dryRun) {
40984
+ report.dangling++;
40985
+ continue;
40986
+ }
40987
+ try {
40988
+ await prisma.sessionHistory.update({
40989
+ where: { id: r.id },
40990
+ data: { transcriptKey: null, transcriptBytes: null }
40991
+ });
40992
+ report.dangling++;
40993
+ } catch {
40994
+ report.failed++;
40995
+ }
40996
+ }
40997
+ return report;
40856
40998
  }
40857
40999
  async function reconcileHistory(liveSessionIds) {
40858
41000
  const open4 = await prisma.sessionHistory.findMany({
@@ -40860,10 +41002,14 @@ async function reconcileHistory(liveSessionIds) {
40860
41002
  select: { id: true, sessionId: true, updatedAt: true }
40861
41003
  });
40862
41004
  const live = new Set(liveSessionIds);
41005
+ const at = /* @__PURE__ */ new Date();
40863
41006
  let closed = 0;
40864
41007
  for (const r of open4) {
40865
41008
  if (live.has(r.sessionId)) continue;
40866
- await prisma.sessionHistory.update({ where: { id: r.id }, data: { endedAt: r.updatedAt } });
41009
+ await prisma.sessionHistory.update({
41010
+ where: { id: r.id },
41011
+ data: { endedAt: r.updatedAt, endedReason: RECONCILED, reconciledAt: at }
41012
+ });
40867
41013
  closed++;
40868
41014
  }
40869
41015
  return closed;
@@ -40901,7 +41047,7 @@ async function session_history_default(app2) {
40901
41047
  cut = new Date(before2);
40902
41048
  if (Number.isNaN(cut.getTime())) return reply.code(400).send({ error: "before bukan tanggal valid" });
40903
41049
  }
40904
- return { purged: await purgeHistory({ projectId, before: cut }) };
41050
+ return purgeHistory({ projectId, before: cut });
40905
41051
  });
40906
41052
  }
40907
41053
 
@@ -46793,10 +46939,25 @@ var RETENTION_DAYS = {
46793
46939
  };
46794
46940
  var before = (now, days) => new Date(now.getTime() - days * 864e5);
46795
46941
  async function runRetention(opts = {}, deps = {}) {
46942
+ const report = {
46943
+ candidates: 0,
46944
+ deleted: 0,
46945
+ bytes: 0,
46946
+ failed: 0,
46947
+ orphans: 0,
46948
+ dangling: 0
46949
+ };
46950
+ await deleteExpired(report, opts, deps);
46951
+ const gc = await reconcileTranscripts({ dryRun: opts.dryRun });
46952
+ report.orphans = gc.orphans;
46953
+ report.dangling = gc.dangling;
46954
+ report.failed += gc.failed;
46955
+ return report;
46956
+ }
46957
+ async function deleteExpired(report, opts, deps) {
46796
46958
  const now = opts.now ?? /* @__PURE__ */ new Date();
46797
46959
  const holds = opts.holds ?? /* @__PURE__ */ new Set();
46798
46960
  let remaining = Math.min(Math.max(opts.batchSize ?? 100, 1), 1e3);
46799
- const report = { candidates: 0, deleted: 0, bytes: 0, failed: 0 };
46800
46961
  const removeTranscript = deps.deleteTranscript ?? deleteTranscript;
46801
46962
  const removeUpload = deps.deleteUpload ?? deleteUpload;
46802
46963
  const sessions = await prisma.sessionHistory.findMany({
@@ -46810,14 +46971,16 @@ async function runRetention(opts = {}, deps = {}) {
46810
46971
  report.bytes += row.transcriptBytes ?? 0;
46811
46972
  if (opts.dryRun) continue;
46812
46973
  try {
46813
- if (row.transcriptKey) await removeTranscript(row.transcriptKey);
46814
46974
  await prisma.sessionHistory.delete({ where: { id: row.id } });
46815
46975
  report.deleted++;
46816
46976
  remaining--;
46817
46977
  } catch {
46818
46978
  report.failed++;
46979
+ continue;
46819
46980
  }
46820
- if (remaining <= 0) return report;
46981
+ if (row.transcriptKey) await removeTranscript(row.transcriptKey).catch(() => {
46982
+ });
46983
+ if (remaining <= 0) return;
46821
46984
  }
46822
46985
  const tickets2 = await prisma.ticket.findMany({
46823
46986
  where: { OR: [
@@ -46841,7 +47004,7 @@ async function runRetention(opts = {}, deps = {}) {
46841
47004
  } catch {
46842
47005
  report.failed++;
46843
47006
  }
46844
- if (remaining <= 0) return report;
47007
+ if (remaining <= 0) return;
46845
47008
  }
46846
47009
  const deliveries = await prisma.webhookDelivery.findMany({
46847
47010
  where: { status: { in: ["sent", "failed", "dropped"] }, createdAt: { lt: before(now, RETENTION_DAYS.deliveries) } },
@@ -46860,7 +47023,7 @@ async function runRetention(opts = {}, deps = {}) {
46860
47023
  report.failed++;
46861
47024
  }
46862
47025
  }
46863
- if (remaining <= 0) return report;
47026
+ if (remaining <= 0) return;
46864
47027
  }
46865
47028
  const results = await prisma.sessionResult.findMany({
46866
47029
  where: { createdAt: { lt: before(now, RETENTION_DAYS.sessionResults) } },
@@ -46881,7 +47044,6 @@ async function runRetention(opts = {}, deps = {}) {
46881
47044
  }
46882
47045
  if (remaining <= 0) break;
46883
47046
  }
46884
- return report;
46885
47047
  }
46886
47048
  var timer8;
46887
47049
  function startRetentionSweep() {
@@ -46889,7 +47051,9 @@ function startRetentionSweep() {
46889
47051
  const sweep2 = () => {
46890
47052
  const holds = new Set((process.env.HANOMAN_RETENTION_HOLDS ?? "").split(",").map((v) => v.trim()).filter(Boolean));
46891
47053
  void runRetention({ holds }).then((report) => {
46892
- if (report.deleted || report.failed) console.log(`retention: ${report.deleted} dihapus, ${report.failed} gagal`);
47054
+ if (report.deleted || report.failed || report.orphans || report.dangling) {
47055
+ console.log(`retention: ${report.deleted} dihapus, ${report.failed} gagal, ${report.orphans} transkrip yatim disapu, ${report.dangling} metadata menggantung dibersihkan`);
47056
+ }
46893
47057
  }).catch((error) => console.error("retention sweep:", error));
46894
47058
  };
46895
47059
  sweep2();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanoman",
3
- "version": "0.1.47",
3
+ "version": "0.1.50",
4
4
  "description": "Orchestrator + dashboard workflow docs-driven untuk sesi Claude Code / Codex",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,27 @@
1
+ -- SPEC-844 · ADR-0125 · riwayat sesi mencatat BAGAIMANA sebuah baris ditutup.
2
+ --
3
+ -- Ditulis tangan (bukan `migrate dev`): worktree tetangga membuat `migrate dev` me-reset DB saat
4
+ -- ada drift. ADITIF murni — dua kolom NULLABLE tanpa default, tak ada tabel diredefinisi, jadi
5
+ -- jebakan `ADD COLUMN … DEFAULT CURRENT_TIMESTAMP` (ADR-0090) tak berlaku di sini.
6
+ ALTER TABLE "SessionHistory" ADD COLUMN "endedReason" TEXT;
7
+ ALTER TABLE "SessionHistory" ADD COLUMN "reconciledAt" DATETIME;
8
+
9
+ -- Backfill SEKALI JALAN. Baris lama tak bisa ditanyai, tapi jejaknya masih ada: `reconcileHistory`
10
+ -- membaca `updatedAt` SEBELUM update-nya sendiri (jadi `updatedAt` melompat ke waktu boot dan
11
+ -- meninggalkan `endedAt` di belakang), sementara `finishSession` menulis `endedAt = new Date()` DI
12
+ -- DALAM update yang sama (jadi jaraknya nol). Terukur pada DB hidup 806 baris: tutup normal
13
+ -- 0–39 ms (n=777) vs rekonsiliasi 275 966–82 224 277 ms (n=20) — empat orde besaran tanpa satu pun
14
+ -- baris di antaranya; ambang 60 000 ms duduk di tengah celah itu.
15
+ --
16
+ -- Ini backfill, BUKAN aturan render: menjadikannya kontrak akan mengunci detail penyimpanan Prisma
17
+ -- sebagai semantik produk. Prisma menyimpan DateTime SQLite sebagai INTEGER milidetik, jadi
18
+ -- selisihnya aritmetika biasa; `CAST` dipasang supaya representasi lain (teks ISO) menghasilkan
19
+ -- selisih 0 → nol baris cocok → seluruh tabel jatuh ke 'closed', yaitu perilaku sebelum spec ini.
20
+ UPDATE "SessionHistory"
21
+ SET "endedReason" = 'reconciled', "reconciledAt" = "updatedAt"
22
+ WHERE "endedAt" IS NOT NULL
23
+ AND CAST("updatedAt" AS INTEGER) - CAST("endedAt" AS INTEGER) > 60000;
24
+
25
+ UPDATE "SessionHistory"
26
+ SET "endedReason" = 'closed'
27
+ WHERE "endedAt" IS NOT NULL AND "endedReason" IS NULL;
@@ -392,6 +392,13 @@ model SessionHistory {
392
392
  startedAt DateTime @default(now())
393
393
  endedAt DateTime?
394
394
  exitCode Int?
395
+ // SPEC-844 · ADR-0125 · CARA baris ini ditutup: "closed" (killSession) atau "reconciled" (pane
396
+ // sudah lenyap saat boot — hasilnya tak diketahui). null = baris lahir sebelum kolom ini ada.
397
+ // Kelas hasil (selesai/gagal) sengaja TIDAK disimpan: ia turunan exitCode (sessionOutcome).
398
+ endedReason String?
399
+ // Kapan boot menemukan panenya lenyap. `endedAt` baris rekonsiliasi adalah batas BAWAH
400
+ // ("terakhir diketahui hidup"); kolom ini batas atasnya.
401
+ reconciledAt DateTime?
395
402
  transcriptKey String?
396
403
  transcriptBytes Int?
397
404
  createdAt DateTime @default(now())