gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitdc40d859dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mredc40d859/plugins/http/http_common.zig

18.4 KB

  1. // Shared HTTP utilities — compiled into each HTTP plugin at build time
  2. // Not a standalone plugin .so, but a Zig module imported by hl:http1, hl:http2, hl:http3
  3. const std = @import("std");
  4. const api = @import("plugin_api");
  5. pub const HlString = api.HlString;
  6. pub fn hlStr(s: []const u8) HlString {
  7. return .{ .ptr = s.ptr, .len = s.len };
  8. }
  9. pub fn statusReason(code: u16) []const u8 {
  10. return switch (code) {
  11. 200 => "OK",
  12. 201 => "Created",
  13. 204 => "No Content",
  14. 301 => "Moved Permanently",
  15. 302 => "Found",
  16. 304 => "Not Modified",
  17. 400 => "Bad Request",
  18. 401 => "Unauthorized",
  19. 403 => "Forbidden",
  20. 404 => "Not Found",
  21. 405 => "Method Not Allowed",
  22. 409 => "Conflict",
  23. 413 => "Payload Too Large",
  24. 415 => "Unsupported Media Type",
  25. 422 => "Unprocessable Entity",
  26. 429 => "Too Many Requests",
  27. 500 => "Internal Server Error",
  28. 502 => "Bad Gateway",
  29. 503 => "Service Unavailable",
  30. 504 => "Gateway Timeout",
  31. else => "OK",
  32. };
  33. }
  34. /// Decode percent-encoded URL components (%20 → space, etc.)
  35. pub fn urlDecode(allocator: anytype, input: []const u8) ![]u8 {
  36. var result = try allocator.alloc(u8, input.len);
  37. var out: usize = 0;
  38. var i: usize = 0;
  39. while (i < input.len) {
  40. if (input[i] == '%' and i + 2 < input.len) {
  41. const hi = hexVal(input[i + 1]) orelse {
  42. result[out] = input[i];
  43. out += 1;
  44. i += 1;
  45. continue;
  46. };
  47. const lo = hexVal(input[i + 2]) orelse {
  48. result[out] = input[i];
  49. out += 1;
  50. i += 1;
  51. continue;
  52. };
  53. result[out] = (@as(u8, hi) << 4) | @as(u8, lo);
  54. out += 1;
  55. i += 3;
  56. } else if (input[i] == '+') {
  57. result[out] = ' ';
  58. out += 1;
  59. i += 1;
  60. } else {
  61. result[out] = input[i];
  62. out += 1;
  63. i += 1;
  64. }
  65. }
  66. return result[0..out];
  67. }
  68. fn hexVal(c: u8) ?u4 {
  69. if (c >= '0' and c <= '9') return @intCast(c - '0');
  70. if (c >= 'a' and c <= 'f') return @intCast(c - 'a' + 10);
  71. if (c >= 'A' and c <= 'F') return @intCast(c - 'A' + 10);
  72. return null;
  73. }
  74. /// Split a query string into key-value pairs.
  75. /// Returns allocated slices that must be freed by the caller.
  76. pub fn parseQueryString(alloc: anytype, query: []const u8) ![]QueryParam {
  77. var params: @import("std").ArrayListUnmanaged(QueryParam) = .empty;
  78. var iter = @import("std").mem.splitScalar(u8, query, '&');
  79. while (iter.next()) |pair| {
  80. if (pair.len == 0) continue;
  81. if (@import("std").mem.indexOfScalar(u8, pair, '=')) |eq| {
  82. const key = try urlDecode(alloc, pair[0..eq]);
  83. const val = try urlDecode(alloc, pair[eq + 1 ..]);
  84. try params.append(alloc, .{ .key = key, .value = val });
  85. } else {
  86. const key = try urlDecode(alloc, pair);
  87. try params.append(alloc, .{ .key = key, .value = &[_]u8{} });
  88. }
  89. }
  90. return params.toOwnedSlice(alloc);
  91. }
  92. pub const QueryParam = struct {
  93. key: []u8,
  94. value: []u8,
  95. };
  96. /// Lowercase a header key in-place (HTTP/1.1 headers are case-insensitive)
  97. pub fn toLowercase(buf: []u8) void {
  98. for (buf) |*ch| {
  99. if (ch.* >= 'A' and ch.* <= 'Z') {
  100. ch.* = ch.* + 32;
  101. }
  102. }
  103. }
  104. // ---------------------------------------------------------------------------
  105. // RESPONSE BODY COMPRESSION (creator, 2026-08-15) — a TRANSPORT concern, like
  106. // TLS: nobody writes compression in app code; a server honors Accept-Encoding.
  107. // ONE implementation here (decision D8's pattern), each plugin adds only its
  108. // own framing. gzip via Zig std flate — no external library.
  109. // ---------------------------------------------------------------------------
  110. /// Below this a gzip header+footer is overhead, not savings (canonical ~860
  111. /// from Akamai's measurements; MTU-fitting responses gain nothing).
  112. pub const compress_min_len = 860;
  113. /// The texty content types worth compressing, decided from the Content-Type
  114. /// VALUE. Already-compressed formats (images, fonts, archives) get bigger, not
  115. /// smaller. hl:http2 and hl:http3 carry headers as key/value pairs rather than
  116. /// h1's raw blob, so this is the half they can ask.
  117. pub fn compressibleTypeValue(value: []const u8) bool {
  118. const v = std.mem.trim(u8, value, " ");
  119. return std.mem.startsWith(u8, v, "text/") or
  120. std.mem.startsWith(u8, v, "application/javascript") or
  121. std.mem.startsWith(u8, v, "application/json") or
  122. std.mem.startsWith(u8, v, "image/svg");
  123. }
  124. /// The same question asked of h1's assembled custom-header text.
  125. pub fn compressibleContentType(headers_blob: []const u8) bool {
  126. var it = std.mem.splitSequence(u8, headers_blob, "\r\n");
  127. while (it.next()) |line| {
  128. if (line.len > 13 and std.ascii.eqlIgnoreCase(line[0..13], "content-type:")) {
  129. return compressibleTypeValue(line[13..]);
  130. }
  131. if (line.len > 17 and std.ascii.eqlIgnoreCase(line[0..17], "content-encoding:")) return false;
  132. }
  133. // no Content-Type in custom headers → the plugin's default is text/html
  134. return true;
  135. }
  136. /// gzip `body`; null when compression does not pay (or fails — the caller then
  137. /// sends the identity bytes, which is always correct).
  138. pub fn gzipBody(allocator: std.mem.Allocator, body: []const u8) ?[]u8 {
  139. if (body.len < compress_min_len) return null;
  140. // Compress.init asserts `output.buffer.len > 8`, and a plain
  141. // Allocating.init starts EMPTY — the assert then panics on a worker thread
  142. // and kills the server after the first compressed response (found the hard
  143. // way 2026-08-15). initCapacity gives the writer its buffer up front.
  144. var out: std.Io.Writer.Allocating = std.Io.Writer.Allocating.initCapacity(allocator, 4096) catch return null;
  145. defer out.deinit();
  146. const window = allocator.alloc(u8, std.compress.flate.max_window_len) catch return null;
  147. defer allocator.free(window);
  148. // .level_6 — the documented setting; Options carries no defaults.
  149. var c = std.compress.flate.Compress.init(&out.writer, window, .gzip, .level_6) catch return null;
  150. c.writer.writeAll(body) catch return null;
  151. c.finish() catch return null;
  152. if (out.writer.buffered().len >= body.len) return null; // grew: send identity
  153. return allocator.dupe(u8, out.writer.buffered()) catch null;
  154. }
  155. // ── The mission-125 BELL: one eventfd per loop source ────────────────────────
  156. //
  157. // RESTORED 2026-08-29 (mission 257). `hl:fetch` and `hl:smtp` both call these by
  158. // name, and both had been UNBUILDABLE since the 2026-08-20 recovery replay
  159. // dropped them from this file — nothing noticed, because `native/build.zig` did
  160. // not build either plugin. See `native/src/loop_wait.zig` for the contract they
  161. // implement: the loop owns one epoll set over every source's `wake_fd`, adds it
  162. // LEVEL-triggered, and drains the counter itself right after `epoll_wait`. A
  163. // producer therefore only ever has to create the fd and write to it.
  164. //
  165. // Raw syscalls rather than libc, so a plugin that does not link libc (hl:http1)
  166. // can still ring a bell.
  167. /// A source's wake fd: a non-blocking eventfd counter. `-1` on failure, which is
  168. /// the ABI's "no signal, keep polling" value — a plugin that cannot get an fd
  169. /// degrades to the polled path instead of failing to load.
  170. pub fn makeWakeFd() i32 {
  171. const rc = std.os.linux.eventfd(0, std.os.linux.EFD.NONBLOCK | std.os.linux.EFD.CLOEXEC);
  172. if (@as(isize, @bitCast(rc)) < 0) return -1;
  173. return @intCast(rc);
  174. }
  175. /// RING IT. Coalesced, duplicated or early wakes are harmless by design (a bell,
  176. /// not a routing key); only a wake that is never sent can be a bug — so producers
  177. /// ring generously and never reason about whether the loop "needs" it.
  178. pub fn ringWake(fd: i32) void {
  179. if (fd < 0) return;
  180. const one: u64 = 1;
  181. _ = std.os.linux.write(fd, @ptrCast(&one), @sizeOf(u64));
  182. }
  183. // ================================================================================
  184. // WHY A BIND FAILED — IN WORDS, AND WITH THE HOLDER'S NAME (mission 286)
  185. // ================================================================================
  186. // All three transports used to print the same six words — `bind() failed on port
  187. // N` — and drop the errno on the floor. Six words cannot be acted on: a port that
  188. // is taken, a port below 1024 without CAP_NET_BIND_SERVICE, and an address that is
  189. // not one of this machine's are three different mistakes with three different
  190. // fixes, and the kernel had already said which one it was.
  191. //
  192. // So the errno is named, and when it is EADDRINUSE the holder is looked up the way
  193. // an operator would look it up: `/proc/net/{tcp,tcp6,udp,udp6}` for the socket on
  194. // that port, then `/proc/<pid>/fd` for whoever owns its inode. It says "a process
  195. // this one cannot see" rather than guessing when the holder belongs to another
  196. // user — `/proc/<pid>/fd` is unreadable then, and a wrong name is worse than none.
  197. //
  198. // This costs nothing on the happy path: it runs once, on a boot that is about to
  199. // be refused.
  200. fn errnoWord(e: u32) []const u8 {
  201. return switch (e) {
  202. 13 => "EACCES",
  203. 22 => "EINVAL",
  204. 24 => "EMFILE",
  205. 97 => "EAFNOSUPPORT",
  206. 98 => "EADDRINUSE",
  207. 99 => "EADDRNOTAVAIL",
  208. else => "errno",
  209. };
  210. }
  211. fn errnoWhy(e: u32) []const u8 {
  212. return switch (e) {
  213. 13 => "permission denied — a port below 1024 needs CAP_NET_BIND_SERVICE or root",
  214. 22 => "invalid argument — this socket is already bound",
  215. 24 => "too many open files — the process fd limit is reached",
  216. 97 => "address family not supported",
  217. 98 => "the port is already in use",
  218. 99 => "that address is not one of this machine's",
  219. else => "the kernel refused the bind",
  220. };
  221. }
  222. fn hexNibble(c: u8) ?u8 {
  223. return switch (c) {
  224. '0'...'9' => c - '0',
  225. 'a'...'f' => c - 'a' + 10,
  226. 'A'...'F' => c - 'A' + 10,
  227. else => null,
  228. };
  229. }
  230. fn hexValue(s: []const u8) ?u64 {
  231. if (s.len == 0 or s.len > 16) return null;
  232. var v: u64 = 0;
  233. for (s) |c| v = (v << 4) | (hexNibble(c) orelse return null);
  234. return v;
  235. }
  236. /// `/proc/net/*`'s local_address column ("0100007F:1F90") as text the operator
  237. /// recognises. IPv4 is four little-endian bytes; anything longer is printed as the
  238. /// raw hex word rather than mis-rendered as a v6 address it may not be.
  239. fn writeProcAddr(out: []u8, hex: []const u8, port: u16) []const u8 {
  240. if (hex.len == 8) {
  241. const v = hexValue(hex) orelse return "";
  242. const b: [4]u8 = .{
  243. @truncate(v & 0xff),
  244. @truncate((v >> 8) & 0xff),
  245. @truncate((v >> 16) & 0xff),
  246. @truncate((v >> 24) & 0xff),
  247. };
  248. return std.fmt.bufPrint(out, "{d}.{d}.{d}.{d}:{d}", .{ b[0], b[1], b[2], b[3], port }) catch "";
  249. }
  250. return std.fmt.bufPrint(out, "[{s}]:{d}", .{ hex, port }) catch "";
  251. }
  252. // RAW SYSCALLS, for the same reason the wake bell above uses them: this file is
  253. // compiled INTO three plugins, `std.fs` in Zig 0.16 wants an `Io` interface these
  254. // plugins do not carry, and `/proc` is read once, at a boot that is failing.
  255. const lin = std.os.linux;
  256. fn sysOpen(path: [*:0]const u8, dir: bool) ?i32 {
  257. const rc = lin.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true, .DIRECTORY = dir }, 0);
  258. const fd: i64 = @bitCast(@as(u64, rc));
  259. if (fd < 0) return null;
  260. return @intCast(fd);
  261. }
  262. fn sysClose(fd: i32) void {
  263. _ = lin.close(fd);
  264. }
  265. const SocketOnPort = struct { inode: u64, addr_hex: [40]u8, addr_len: usize };
  266. /// The socket sitting on `port`, out of the four `/proc/net` tables. For TCP only a
  267. /// LISTEN socket (`st == 0A`) can block a bind — mission 285 measured that, squat by
  268. /// squat: TIME_WAIT and ESTABLISHED are tolerated because both sides set
  269. /// SO_REUSEADDR. UDP has no states worth filtering.
  270. fn socketOnPort(port: u16, udp: bool) ?SocketOnPort {
  271. const tables: [2][*:0]const u8 = if (udp)
  272. .{ "/proc/net/udp", "/proc/net/udp6" }
  273. else
  274. .{ "/proc/net/tcp", "/proc/net/tcp6" };
  275. for (tables) |path| {
  276. const fd = sysOpen(path, false) orelse continue;
  277. defer sysClose(fd);
  278. var chunk: [8192]u8 = undefined;
  279. var line: [512]u8 = undefined;
  280. var line_len: usize = 0;
  281. while (true) {
  282. const rc = lin.read(fd, &chunk, chunk.len);
  283. const got: i64 = @bitCast(@as(u64, rc));
  284. if (got <= 0) break;
  285. const n: usize = @intCast(got);
  286. for (chunk[0..n]) |ch| {
  287. if (ch != '\n') {
  288. if (line_len < line.len) {
  289. line[line_len] = ch;
  290. line_len += 1;
  291. }
  292. continue;
  293. }
  294. if (parseProcNetLine(line[0..line_len], port, udp)) |hit| return hit;
  295. line_len = 0;
  296. }
  297. }
  298. if (line_len > 0) {
  299. if (parseProcNetLine(line[0..line_len], port, udp)) |hit| return hit;
  300. }
  301. }
  302. return null;
  303. }
  304. /// One `/proc/net/*` row: `sl local rem st tx:rx tr:when retrnsmt uid timeout inode`.
  305. fn parseProcNetLine(line: []const u8, port: u16, udp: bool) ?SocketOnPort {
  306. var it = std.mem.tokenizeAny(u8, line, " \t");
  307. _ = it.next() orelse return null; // sl
  308. const local = it.next() orelse return null;
  309. _ = it.next() orelse return null; // rem_address
  310. const st = it.next() orelse return null;
  311. if (!udp and !std.mem.eql(u8, st, "0A")) return null;
  312. const colon = std.mem.lastIndexOfScalar(u8, local, ':') orelse return null;
  313. const lport = hexValue(local[colon + 1 ..]) orelse return null;
  314. if (lport != port) return null;
  315. var skipped: usize = 0;
  316. while (skipped < 5) : (skipped += 1) _ = it.next() orelse return null;
  317. const inode = std.fmt.parseInt(u64, it.next() orelse return null, 10) catch return null;
  318. const hex = local[0..colon];
  319. var hit = SocketOnPort{ .inode = inode, .addr_hex = undefined, .addr_len = 0 };
  320. if (hex.len > hit.addr_hex.len) return null;
  321. @memcpy(hit.addr_hex[0..hex.len], hex);
  322. hit.addr_len = hex.len;
  323. return hit;
  324. }
  325. /// Whoever has that socket inode open. An unreadable `/proc/<pid>/fd` is SKIPPED,
  326. /// not reported as absent: a holder owned by another user is invisible from here,
  327. /// and the caller says exactly that instead of naming the wrong process.
  328. fn pidHolding(inode: u64, name_out: []u8, name_len: *usize) ?u32 {
  329. const proc_fd = sysOpen("/proc", true) orelse return null;
  330. defer sysClose(proc_fd);
  331. var want: [48]u8 = undefined;
  332. const want_link = std.fmt.bufPrint(&want, "socket:[{d}]", .{inode}) catch return null;
  333. var ents: [8192]u8 align(8) = undefined;
  334. while (true) {
  335. const rc = lin.getdents64(proc_fd, &ents, ents.len);
  336. const got: i64 = @bitCast(@as(u64, rc));
  337. if (got <= 0) return null;
  338. var off: usize = 0;
  339. while (off < @as(usize, @intCast(got))) {
  340. const ent: *align(1) const lin.dirent64 = @ptrCast(&ents[off]);
  341. off += ent.reclen;
  342. const name_ptr: [*:0]const u8 = @ptrCast(&ent.name);
  343. const pid = std.fmt.parseInt(u32, std.mem.span(name_ptr), 10) catch continue;
  344. if (fdsHold(pid, want_link)) {
  345. name_len.* = readComm(pid, name_out);
  346. return pid;
  347. }
  348. }
  349. }
  350. }
  351. /// Does this pid have that socket open? An unreadable `/proc/<pid>/fd` answers
  352. /// false — the process belongs to another user and is simply not visible.
  353. fn fdsHold(pid: u32, want_link: []const u8) bool {
  354. var path: [64:0]u8 = undefined;
  355. const dir_path = std.fmt.bufPrintZ(&path, "/proc/{d}/fd", .{pid}) catch return false;
  356. const dir_fd = sysOpen(dir_path, true) orelse return false;
  357. defer sysClose(dir_fd);
  358. var ents: [4096]u8 align(8) = undefined;
  359. while (true) {
  360. const rc = lin.getdents64(dir_fd, &ents, ents.len);
  361. const got: i64 = @bitCast(@as(u64, rc));
  362. if (got <= 0) return false;
  363. var off: usize = 0;
  364. while (off < @as(usize, @intCast(got))) {
  365. const ent: *align(1) const lin.dirent64 = @ptrCast(&ents[off]);
  366. off += ent.reclen;
  367. const name_ptr: [*:0]const u8 = @ptrCast(&ent.name);
  368. var lbuf: [64]u8 = undefined;
  369. const lrc = lin.readlinkat(dir_fd, name_ptr, &lbuf, lbuf.len);
  370. const llen: i64 = @bitCast(@as(u64, lrc));
  371. if (llen <= 0) continue;
  372. if (std.mem.eql(u8, lbuf[0..@intCast(llen)], want_link)) return true;
  373. }
  374. }
  375. }
  376. fn readComm(pid: u32, out: []u8) usize {
  377. var path: [64:0]u8 = undefined;
  378. const p = std.fmt.bufPrintZ(&path, "/proc/{d}/comm", .{pid}) catch return 0;
  379. const fd = sysOpen(p, false) orelse return 0;
  380. defer sysClose(fd);
  381. const rc = lin.read(fd, out.ptr, out.len);
  382. const got: i64 = @bitCast(@as(u64, rc));
  383. if (got <= 0) return 0;
  384. var len: usize = @intCast(got);
  385. while (len > 0 and (out[len - 1] == '\n' or out[len - 1] == '\r')) len -= 1;
  386. return len;
  387. }
  388. /// The sentence a transport appends to `bind() failed on port N`. It never fails: a
  389. /// buffer too small or a `/proc` that will not answer degrades to the part that IS
  390. /// known, and the errno alone already says more than the old message did.
  391. pub fn bindFailureDetail(out: []u8, rc: usize, port: u16, udp: bool) []const u8 {
  392. const signed: i64 = @bitCast(@as(u64, rc));
  393. const errno: u32 = if (signed < 0 and signed > -4096) @intCast(-signed) else 0;
  394. var w: usize = 0;
  395. const head = std.fmt.bufPrint(out[w..], "{s} — {s}", .{ errnoWord(errno), errnoWhy(errno) }) catch return out[0..w];
  396. w += head.len;
  397. if (errno != 98) return out[0..w];
  398. const sock = socketOnPort(port, udp) orelse {
  399. const gone = std.fmt.bufPrint(out[w..], "; nothing holds it now — the listener came and went", .{}) catch return out[0..w];
  400. return out[0 .. w + gone.len];
  401. };
  402. var abuf: [80]u8 = undefined;
  403. const addr = writeProcAddr(&abuf, sock.addr_hex[0..sock.addr_len], port);
  404. var comm: [32]u8 = undefined;
  405. var comm_len: usize = 0;
  406. if (pidHolding(sock.inode, &comm, &comm_len)) |pid| {
  407. const tail = std.fmt.bufPrint(out[w..], "; held by pid {d} ({s}) on {s}", .{ pid, comm[0..comm_len], addr }) catch return out[0..w];
  408. return out[0 .. w + tail.len];
  409. }
  410. const tail = std.fmt.bufPrint(out[w..], "; held on {s} by a process this one cannot see (another user)", .{addr}) catch return out[0..w];
  411. return out[0 .. w + tail.len];
  412. }

Branches

Latest commits

  • dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre
  • 2667da05tracker#30 (mission 025): /my/ pages from slim cached title cards, episode rows and watch sets (after the jobs /my/series 1.8 s -> 0.06 s, /my/unwatched 4.1 -> 0.18 s); timeline page shows its name once; franchise widget under the poster/title; movies with TV leftovers (First Contact) go through the details repair; tools/count-tmdb-ids.hl; gates 327/0, 52/0, 32/0mre
  • 3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre
  • e63b1d28antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 1cda451dtracker: Hybriel master 190aa11d (#127 both shapes, GC correctness fc838894) — conductor adopts despite /my/series 2.4x after jobs (memory 8.0 → 1.7 GB boot); see reports/071mre
  • c1fa2f2etracker (mission 071): Hybriel master 190aa11d measured on the real copy vs the live binary 8590df63 — NOT adopted (after the first-start jobs /my/series 2.4x slower, /series 1.6x, RSS swings 7.4-12.2 GB; fresh it is flat at 1.7-2.2 GB and /my/unwatched faster), vendor stays 8efba065, candidate kept in .scratch/w071/vendor-190aa11d; tests/kinds.mjs: collection seed off (its TMDB request broke check 1 in 1 of 4 runs); tests/realdata-071.sh + realdata-071-bench.mjs + tools/realdata-071-table.py; README + STATUS (numbers, how to repeat); gates 325/0, 32/0, 50/0mre
  • 8081350atracker docs (mission 070): README (summary, Config HL_GC_BYTES — kept at Hybriel's default, the 256 MiB setting is taken out of docker-compose.yml again: the jobs grew to 12+ GB with it too, see STATUS), Test (three gates), Deploy (first start ~50 min: kinds then seed, restart once after collections done, memory numbers), Vendored Hybriel 8efba065 + #48 audit, Files; STATUS mission 070 entry (merges, migrated counts, lambda audit, gates, RSS old vs new, how to repeat, open points); docs/kinds.md + docs/franchises.md job order; tests/realdata-070-*.sh, tools/count-migrated.hl, tools/ref-params.py, tools/lambda-audit.pymre
  • 1ad19c8ctracker: re-vendor Hybriel master 8efba065 (#126 GC by bytes, #48 lambda parameters copy) (mission 070): bin/hybriel sha256 50361e95…, plugins core crypto data fetch fs http http1 mpackdb proc smtp time web; lambda audit: 13 lambdas change a passed record/list (11 through a local alias), no caller relies on it — unchanged; 319 read-only lambda parameters get & (no copy per call: /my/schedule 2.35 → 0.46 s, /my/unwatched 13.7 → 5.7 s on the real copy); /my/unwatched one merge sort instead of n² inserts; docker-compose HL_GC_BYTES=268435456; deploy.sh runs kinds.mjs + franchises.mjs too (default ports 8700–8710); tests/realdata-070.mjs; gates browser 325/0, kinds 32/0, franchises 50/0mre
  • 46b21389tracker (mission 070, conductor): the sync never destroys migrated data — the one-time summary step MOVES a copied summary to migratedSummary (marker summaries-moved.txt) instead of clearing it; a migrated record's first title/genres/homepage/tagline TMDB replaces → migratedTitle/migratedGenres/migratedHomepage/migratedTagline, a migrated season's/episode's title/summary → migratedTitle/migratedSummary (set once); tests/peek-shows.hl prints them; gate 325/0mre
  • 749019b0Merge t19 (tracker#19 franchises + timelines) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs/faketmdb.mjs, both sides kept; franchise/timeline pages get #20's typed heading (Franchise | …, Timeline | …), their title links via titlePath (/movies|/series|/shows); the collection seed waits for repair, kinds and credits too; franchises.mjs URLs + 2 new checks; gates browser 323/0, kinds 32/0, franchises 50/0mre
  • daf49feaMerge t20 (tracker#20 typed headings + #21 series/shows split) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/search.hl/components/search.hl/browser.mjs/faketmdb.mjs, both sides kept; clock order backfill → repair → kinds → credits; withDetailsFields stores tmdbType + kind; gates: browser.mjs URLs → /series|/movies, typed h1 selectors; kinds.mjs repair/credits off, fixture name = seed name; browser 323/0, kinds 32/0mre
  • 20e09d89Merge t18 (tracker#18 delta sync) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs, both sides kept; pageShowOf summary = summaryOfmre
  • f83571c3tracker#18 (mission 067): daily sync by change lists — TMDB /tv|movie/changes (since the stored day, paged) + TVmaze /updates/shows → only our changed titles (followed: full step, unfollowed: light step — changed seasons, no TVmaze), full walk on first run / gap > 14 days / failed list; show record refreshed (title, tmdbSummary, tagline, status, genres …; renamed titles re-indexed); summary = the creator's own text (page: summary > tmdbSummary > tvmazeSummary), one-time clear of copied summaries (9,647 on the live copy); gate 261, tests/realdata-018*.mjs, README + STATUSmre
  • 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
  • f2b00674Merge t26 (tracker#26 + #28) into main (mission 062): short ids for every new person (castPersonId, guest route), guest stars stored on the title and created as people only when opened (/person/tmdb/<id>?show=<id> → 302), lean watch/follow clicks (showRow a small object, cast/crew from the slug, watches cached per user, face rows only after a season toggle); gate 311, tests/realdata-062.mjs, README + STATUSmre
  • 0553b51ftracker#19 (mission 066): franchises and timelines — tables, /franchises, /franchises/<slug>, /timelines/<slug> (Timeline | Release sort, series by last episode), the Prequel | Timeline | Sequel widget with the franchise above, the creator's editor, TMDB collection seed in the app (resumes, paced); gate tests/franchises.mjs 48/0 + browser.mjs 266/0, tests/realdata-066.mjs, docs/franchises.md, README + STATUSmre
  • fa1f9dfatracker#29 (mission 063): unwatched check muted grey outline + check (accent only on hover), watched stays solid — no code regression, the accent outline read as ticked; gate checks real checks visibly (computed style + screenshot pixel) on /my/unwatched, show, movie, /my/movies; gate 266, tests/realdata-063.mjs, README + STATUSmre
  • 10bb3f93tracker#28 (mission 061): full cast (all seasons, main cast by episodes, guest stars) + crew (created by, directed by, written by, screenplay, story, music) — stored by the details completion, the daily sync, the search import (one details request) and a background credits job (resumes, RSS limit); show page collapsed after 20 with client-side Show all; showBySlug via a slug map; gate 249, tests/realdata-028.mjs, README + STATUSmre
  • d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre
  • 25a50bc4tracker#26 (mission 059): titles from a filmography are completed — on open (skeleton, step-wise face showComplete, no reload) and by the in-app details repair (resumes, TMDB-paced, series in parts); cast from TMDB credits; gate 231, tests/realdata-026.mjs, README + STATUSmre