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/README.md +43 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +85 -37
- package/dist/server.js +208 -44
- package/package.json +1 -1
- package/prisma/migrations/20260819130000_session_history_ended_reason/migration.sql +27 -0
- package/prisma/schema.prisma +7 -0
- package/web/assets/{index-Cnd5rKXp.js → index-Ddz2nble.js} +1543 -1552
- package/web/index.html +1 -1
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
|
|
6914
|
+
label: "Sesi selesai, gagal, atau terputus",
|
|
6902
6915
|
changed: ["endedAt"],
|
|
6903
|
-
when:
|
|
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")
|
|
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
|
|
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 ===
|
|
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 =
|
|
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
|
|
24987
|
-
if (!
|
|
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
|
|
25006
|
-
if (
|
|
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
|
|
25023
|
-
if (
|
|
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
|
|
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 (!
|
|
25039
|
-
if (
|
|
25040
|
-
if (i < parts.length - 1 && !
|
|
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
|
|
25056
|
-
if (
|
|
25057
|
-
if (i < parts.length - 1 && !
|
|
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
|
|
25113
|
-
if (!
|
|
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
|
|
26564
|
-
return new _FileTokenizer(fileHandle, { fileInfo: { path: sourceFilePath, 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") ??
|
|
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,
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40854
|
-
|
|
40855
|
-
|
|
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({
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
@@ -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;
|
package/prisma/schema.prisma
CHANGED
|
@@ -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())
|