@rubytech/create-realagent-code 0.1.575 → 0.1.576
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/__tests__/preserve-house-cloudflare.test.js +2 -72
- package/dist/index.js +8 -59
- package/dist/preserve-house-cloudflare.js +2 -49
- package/package.json +1 -1
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +4 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +5 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +29 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/server/server.js +187 -63
- package/dist/__tests__/converge-client-admins.test.js +0 -50
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// Runs under `node --test dist/__tests__/*`.
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
|
-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync
|
|
9
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import { backupHouseCloudflare, restoreHouseCloudflare,
|
|
12
|
+
import { backupHouseCloudflare, restoreHouseCloudflare, } from "../preserve-house-cloudflare.js";
|
|
13
13
|
function makeDirs() {
|
|
14
14
|
const root = mkdtempSync(join(tmpdir(), "house-1664-"));
|
|
15
15
|
const installDir = join(root, "install");
|
|
@@ -66,73 +66,3 @@ function acctSecrets(installDir, id, contents) {
|
|
|
66
66
|
writeFileSync(p, contents);
|
|
67
67
|
return p;
|
|
68
68
|
}
|
|
69
|
-
test("relocates a per-account master into the house file, mode 600, account untouched", () => {
|
|
70
|
-
const { root, installDir } = makeDirs();
|
|
71
|
-
try {
|
|
72
|
-
const acct = acctSecrets(installDir, "acc-1", "CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=deadbeef\nCF_PAGES_D1_TOKEN=stale\n");
|
|
73
|
-
const before = readFileSync(acct, "utf-8");
|
|
74
|
-
const r = migrateHouseCredentialFromAccount(installDir);
|
|
75
|
-
assert.equal(r.result, "relocated");
|
|
76
|
-
assert.equal(r.source, "acc-1");
|
|
77
|
-
const house = join(installDir, "platform/config", "cloudflare-house.env");
|
|
78
|
-
assert.equal(readFileSync(house, "utf-8"), "CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=deadbeef\n", "only the two master keys are relocated, no stale scoped token");
|
|
79
|
-
assert.equal(statSync(house).mode & 0o777, 0o600, "house file is mode 600");
|
|
80
|
-
assert.equal(readFileSync(acct, "utf-8"), before, "account file untouched");
|
|
81
|
-
}
|
|
82
|
-
finally {
|
|
83
|
-
rmSync(root, { recursive: true, force: true });
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
test("no-op when the house file already exists (never overwrites)", () => {
|
|
87
|
-
const { root, installDir, configDir } = makeDirs();
|
|
88
|
-
try {
|
|
89
|
-
const house = join(configDir, "cloudflare-house.env");
|
|
90
|
-
writeFileSync(house, "CLOUDFLARE_API_TOKEN=existing\nCLOUDFLARE_ACCOUNT_ID=xyz\n");
|
|
91
|
-
acctSecrets(installDir, "acc-1", "CLOUDFLARE_API_TOKEN=other\nCLOUDFLARE_ACCOUNT_ID=zzz\n");
|
|
92
|
-
const r = migrateHouseCredentialFromAccount(installDir);
|
|
93
|
-
assert.equal(r.result, "noop");
|
|
94
|
-
assert.equal(readFileSync(house, "utf-8"), "CLOUDFLARE_API_TOKEN=existing\nCLOUDFLARE_ACCOUNT_ID=xyz\n", "existing house file is untouched");
|
|
95
|
-
}
|
|
96
|
-
finally {
|
|
97
|
-
rmSync(root, { recursive: true, force: true });
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
test("absent when no account carries a master (no house file, no error)", () => {
|
|
101
|
-
const { root, installDir } = makeDirs();
|
|
102
|
-
try {
|
|
103
|
-
acctSecrets(installDir, "acc-1", "CF_PAGES_D1_TOKEN=scoped_only\n");
|
|
104
|
-
const r = migrateHouseCredentialFromAccount(installDir);
|
|
105
|
-
assert.equal(r.result, "absent");
|
|
106
|
-
assert.ok(!existsSync(join(installDir, "platform/config", "cloudflare-house.env")));
|
|
107
|
-
}
|
|
108
|
-
finally {
|
|
109
|
-
rmSync(root, { recursive: true, force: true });
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
test("deterministic pick — lexicographically-first account id wins", () => {
|
|
113
|
-
const { root, installDir } = makeDirs();
|
|
114
|
-
try {
|
|
115
|
-
acctSecrets(installDir, "bbb", "CLOUDFLARE_API_TOKEN=tok_b\nCLOUDFLARE_ACCOUNT_ID=idb\n");
|
|
116
|
-
acctSecrets(installDir, "aaa", "CLOUDFLARE_API_TOKEN=tok_a\nCLOUDFLARE_ACCOUNT_ID=ida\n");
|
|
117
|
-
const r = migrateHouseCredentialFromAccount(installDir);
|
|
118
|
-
assert.equal(r.source, "aaa");
|
|
119
|
-
assert.match(readFileSync(join(installDir, "platform/config", "cloudflare-house.env"), "utf-8"), /tok_a/);
|
|
120
|
-
}
|
|
121
|
-
finally {
|
|
122
|
-
rmSync(root, { recursive: true, force: true });
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
test("full upgrade path: pre-1631 relocates, second run is a no-op", () => {
|
|
126
|
-
const { root, installDir, persistentDir } = makeDirs();
|
|
127
|
-
try {
|
|
128
|
-
// Pre-1631 upgrade: no house file survives the wipe/restore (nothing saved).
|
|
129
|
-
simulateUpgrade(installDir, persistentDir);
|
|
130
|
-
assert.ok(!existsSync(join(installDir, "platform/config", "cloudflare-house.env")));
|
|
131
|
-
acctSecrets(installDir, "acc-1", "CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=deadbeef\n");
|
|
132
|
-
assert.equal(migrateHouseCredentialFromAccount(installDir).result, "relocated");
|
|
133
|
-
assert.equal(migrateHouseCredentialFromAccount(installDir).result, "noop", "second run is idempotent");
|
|
134
|
-
}
|
|
135
|
-
finally {
|
|
136
|
-
rmSync(root, { recursive: true, force: true });
|
|
137
|
-
}
|
|
138
|
-
});
|
package/dist/index.js
CHANGED
|
@@ -10,9 +10,8 @@ import { buildBrandCronBlock, mergeBrandCronBlock } from "./cron-registration.js
|
|
|
10
10
|
import { validateTierFlag, validateEntitlementBase64, applyTierToAccountConfig, entitlementPath } from "./tier-flag.js";
|
|
11
11
|
import { parseOsRelease, isUbuntuLike as isUbuntuLikePure, parseAptCacheCandidate, decideAptResolution, } from "./apt-resolve.js";
|
|
12
12
|
import { findPeerBrandOnDefaultNeo4jPort } from "./peer-brand-detect.js";
|
|
13
|
-
import { removeClientAdminsKey } from "./converge-client-admins.js";
|
|
14
13
|
import { seedPublicInstructionFiles } from "./public-instruction-seed.js";
|
|
15
|
-
import { backupHouseCloudflare, restoreHouseCloudflare,
|
|
14
|
+
import { backupHouseCloudflare, restoreHouseCloudflare, } from "./preserve-house-cloudflare.js";
|
|
16
15
|
import { resolveNeo4jPidfileScrub } from "./neo4j-pidfile-scrub.js";
|
|
17
16
|
import { runInitLogging } from "./init-logging.js";
|
|
18
17
|
import { requireSupportedPlatform, detectPlatform } from "./platform-detect.js";
|
|
@@ -2650,15 +2649,6 @@ function deployPayload() {
|
|
|
2650
2649
|
if (restoreHouseCloudflare(INSTALL_DIR, persistentDir)) {
|
|
2651
2650
|
console.log(" Restored Cloudflare house credential (cloudflare-house.env preserved).");
|
|
2652
2651
|
}
|
|
2653
|
-
// Task 1671 — a pre-1631 install crossing the 0.1.449 boundary has no house
|
|
2654
|
-
// credential yet, but its per-account secrets still carry the account-wide
|
|
2655
|
-
// master (a minter, Task 1670). Relocate it into the house file so the broker
|
|
2656
|
-
// and calendar find it where the 1631 readers expect it, with zero manual
|
|
2657
|
-
// step. Idempotent: no-op once the house file exists (fresh install → absent).
|
|
2658
|
-
{
|
|
2659
|
-
const mig = migrateHouseCredentialFromAccount(INSTALL_DIR);
|
|
2660
|
-
console.log(` [install] op=house-cred-migrate result=${mig.result}${mig.source ? ` source=${mig.source}` : ""}`);
|
|
2661
|
-
}
|
|
2662
2652
|
// users.json is read directly from persistentDir by both
|
|
2663
2653
|
// platform/ui/app/lib/paths.ts (USERS_FILE = MAXY_DIR/users.json) and the
|
|
2664
2654
|
// admin MCP plugin (USERS_FILE = CONFIG_DIR/users.json). No copy into the
|
|
@@ -2690,13 +2680,6 @@ function deployPayload() {
|
|
|
2690
2680
|
// installer is its own npm package and doesn't bundle the platform lib.
|
|
2691
2681
|
// Mirrors checkAdminAuthInvariant() in platform/lib/admins-write/src/index.ts;
|
|
2692
2682
|
// future divergence between the two should be caught by the test suite.
|
|
2693
|
-
// Task 1623 — converge existing client account.json to drop the vestigial
|
|
2694
|
-
// admins[] key before the invariant check reads them. House untouched.
|
|
2695
|
-
for (const r of removeClientAdminsKey(join(INSTALL_DIR, "data", "accounts"))) {
|
|
2696
|
-
if (r.action === "removed" || r.action === "unreadable" || r.action === "write-failed") {
|
|
2697
|
-
console.log(` [converge-1623] account=${r.accountId.slice(0, 8)} admins=${r.action}`);
|
|
2698
|
-
}
|
|
2699
|
-
}
|
|
2700
2683
|
runInstallInvariantCheck(persistentUsersFile, join(INSTALL_DIR, "data", "accounts"));
|
|
2701
2684
|
// The version marker is NOT written here. It is stamped at the very end of a
|
|
2702
2685
|
// fully-successful install (after plugin registration and every throwing
|
|
@@ -3392,47 +3375,13 @@ function setupAccount() {
|
|
|
3392
3375
|
logFile(` [neo4j] passing NEO4J_URI=${neo4jUri} to seed`);
|
|
3393
3376
|
shell("bash", [seedScript], { cwd: INSTALL_DIR, env: neo4jEnv });
|
|
3394
3377
|
}
|
|
3395
|
-
//
|
|
3396
|
-
//
|
|
3397
|
-
//
|
|
3398
|
-
//
|
|
3399
|
-
//
|
|
3400
|
-
//
|
|
3401
|
-
|
|
3402
|
-
if (existsSync(convergeStartDate)) {
|
|
3403
|
-
shell("node", [convergeStartDate], {
|
|
3404
|
-
cwd: INSTALL_DIR,
|
|
3405
|
-
env: { ...neo4jEnv, PLATFORM_ROOT: join(INSTALL_DIR, "platform") },
|
|
3406
|
-
});
|
|
3407
|
-
}
|
|
3408
|
-
// Task 2361 — collapse the two routine description properties onto
|
|
3409
|
-
// `:Event.description`, preferring whichever value the operator or the agent
|
|
3410
|
-
// wrote. Runs after the seed for the same reasons the convergence above does:
|
|
3411
|
-
// the schema exists by then, and the same Neo4j env is already built. Scoped to
|
|
3412
|
-
// this install's accounts via PLATFORM_ROOT, so a co-resident brand sharing one
|
|
3413
|
-
// Neo4j is untouched. Idempotent: a converged install matches nothing and
|
|
3414
|
-
// prints converged=0.
|
|
3415
|
-
const convergeRoutineDescription = join(INSTALL_DIR, "platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.js");
|
|
3416
|
-
if (existsSync(convergeRoutineDescription)) {
|
|
3417
|
-
shell("node", [convergeRoutineDescription], {
|
|
3418
|
-
cwd: INSTALL_DIR,
|
|
3419
|
-
env: { ...neo4jEnv, PLATFORM_ROOT: join(INSTALL_DIR, "platform") },
|
|
3420
|
-
});
|
|
3421
|
-
}
|
|
3422
|
-
// Task 2513 — converge :Visit.startDate and :Visit.endDate onto canonical
|
|
3423
|
-
// UTC. The planner window compares startDate to Z-suffixed bounds LEXICALLY,
|
|
3424
|
-
// so a row storing a +01:00 offset lands in the wrong day under BST. Runs
|
|
3425
|
-
// after the seed for the same reasons the two convergences above do, and is
|
|
3426
|
-
// scoped to this install's accounts so a co-resident brand sharing one Neo4j
|
|
3427
|
-
// is untouched. Idempotent: a converged install rewrites nothing and prints
|
|
3428
|
-
// op=start-migration rewritten=0.
|
|
3429
|
-
const convergeVisitInstants = join(INSTALL_DIR, "platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.js");
|
|
3430
|
-
if (existsSync(convergeVisitInstants)) {
|
|
3431
|
-
shell("node", [convergeVisitInstants], {
|
|
3432
|
-
cwd: INSTALL_DIR,
|
|
3433
|
-
env: { ...neo4jEnv, PLATFORM_ROOT: join(INSTALL_DIR, "platform") },
|
|
3434
|
-
});
|
|
3435
|
-
}
|
|
3378
|
+
// Install-time data migrations were removed on 2026-08-08 by operator
|
|
3379
|
+
// decision. Three convergences ran here (Tasks 2213, 2361, 2513). Every
|
|
3380
|
+
// device in the fleet is past the state each one existed to repair, so on
|
|
3381
|
+
// every install they scanned and rewrote nothing while adding a Neo4j
|
|
3382
|
+
// round-trip and a way to fail: 2513's script leaked its driver and hung
|
|
3383
|
+
// the installer after printing scanned=0. A future migration belongs in a
|
|
3384
|
+
// one-shot the operator runs deliberately, not on the upgrade path.
|
|
3436
3385
|
// Task 2460 — clear the brand-wide agent root this step used to write.
|
|
3437
3386
|
// Every bundled specialist now reaches a session through the per-account
|
|
3438
3387
|
// registry that `syncAccountAgentRegistry` mirrors at spawn from
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// calendar reconcile/publish scripts) fails with "house credential not found"
|
|
7
7
|
// until an operator re-mints the token by hand. The persistent copy lives in
|
|
8
8
|
// the brand's persistent config dir so even a full reinstall restores it.
|
|
9
|
-
import { existsSync, cpSync, chmodSync
|
|
10
|
-
import { join
|
|
9
|
+
import { existsSync, cpSync, chmodSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
11
|
const HOUSE_CRED_FILE = "cloudflare-house.env";
|
|
12
12
|
/**
|
|
13
13
|
* Copy the live house credential (if any) into the persistent store before the
|
|
@@ -35,50 +35,3 @@ export function restoreHouseCloudflare(installDir, persistentDir) {
|
|
|
35
35
|
chmodSync(dest, 0o600);
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
39
|
-
* Task 1671 — on upgrade, if no house credential exists yet, relocate an
|
|
40
|
-
* existing per-account master (CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID)
|
|
41
|
-
* into the house file (mode 600). This lets a pre-1631 install cross the 0.1.449
|
|
42
|
-
* boundary with zero manual step: the broker/calendar (Task 1670) then mint
|
|
43
|
-
* their scoped tokens from the relocated minter where the 1631 readers expect it.
|
|
44
|
-
*
|
|
45
|
-
* Fail-safe and idempotent:
|
|
46
|
-
* - never overwrites an existing house file (returns "noop");
|
|
47
|
-
* - never guesses a token (both master keys required);
|
|
48
|
-
* - deterministic: scans accounts sorted by id, takes the first with a master;
|
|
49
|
-
* - relocates only the two master keys, so no per-account scoped token leaks
|
|
50
|
-
* house-side.
|
|
51
|
-
*
|
|
52
|
-
* The relocated file is preserved on subsequent upgrades by backupHouseCloudflare
|
|
53
|
-
* (Task 1664), which runs before the wipe on the next install.
|
|
54
|
-
*/
|
|
55
|
-
export function migrateHouseCredentialFromAccount(installDir) {
|
|
56
|
-
const house = join(installDir, "platform/config", HOUSE_CRED_FILE);
|
|
57
|
-
if (existsSync(house))
|
|
58
|
-
return { result: "noop" };
|
|
59
|
-
const accountsDir = join(installDir, "data/accounts");
|
|
60
|
-
if (!existsSync(accountsDir))
|
|
61
|
-
return { result: "absent" };
|
|
62
|
-
for (const id of readdirSync(accountsDir).sort()) {
|
|
63
|
-
const secrets = join(accountsDir, id, "secrets", "cloudflare.env");
|
|
64
|
-
if (!existsSync(secrets))
|
|
65
|
-
continue;
|
|
66
|
-
const env = {};
|
|
67
|
-
for (const line of readFileSync(secrets, "utf8").split("\n")) {
|
|
68
|
-
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
69
|
-
if (m)
|
|
70
|
-
env[m[1]] = m[2].trim();
|
|
71
|
-
}
|
|
72
|
-
const apiToken = env.CLOUDFLARE_API_TOKEN;
|
|
73
|
-
const accountId = env.CLOUDFLARE_ACCOUNT_ID;
|
|
74
|
-
if (!apiToken || !accountId)
|
|
75
|
-
continue;
|
|
76
|
-
mkdirSync(dirname(house), { recursive: true });
|
|
77
|
-
writeFileSync(house, `CLOUDFLARE_API_TOKEN=${apiToken}\nCLOUDFLARE_ACCOUNT_ID=${accountId}\n`, {
|
|
78
|
-
mode: 0o600,
|
|
79
|
-
});
|
|
80
|
-
chmodSync(house, 0o600);
|
|
81
|
-
return { result: "relocated", source: id };
|
|
82
|
-
}
|
|
83
|
-
return { result: "absent" };
|
|
84
|
-
}
|
package/package.json
CHANGED
|
@@ -9,6 +9,10 @@ Invoked by the admin agent directly.
|
|
|
9
9
|
|
|
10
10
|
This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
|
|
11
11
|
|
|
12
|
+
## 2026-08-08 (0.1.576)
|
|
13
|
+
|
|
14
|
+
- Installing and upgrading no longer runs data conversions on your existing records. Five had accumulated, each added to repair a state that every install has long since passed, so all five did nothing but add work and risk to every upgrade. One of them stopped the install outright after reporting it had nothing to do. Any future conversion will be something you run deliberately, not part of an upgrade.
|
|
15
|
+
|
|
12
16
|
## 2026-08-08 (0.1.575)
|
|
13
17
|
|
|
14
18
|
- **Reverses one change from 0.1.574.** An answer now stays on the channel you asked from, rather than also being copied to your other channels. The copy arrived as a reply with no question above it, because only the answer travelled and not what you had asked; a WhatsApp question turned up in Telegram as a bare statement. Being reachable on more than one device is already handled per channel, so the copy was removed rather than completed.
|
|
@@ -46,6 +46,7 @@ export declare function parseSpawnRole(body: {
|
|
|
46
46
|
} | null;
|
|
47
47
|
export declare const SESSION_ID_PATTERN: RegExp;
|
|
48
48
|
export declare const RC_SPAWN_URL_WAIT_DEFAULT_MS = 60000;
|
|
49
|
+
export declare const LOG_FOLLOW_KEEPALIVE_DEFAULT_MS = 30000;
|
|
49
50
|
export interface HttpDeps extends Omit<SpawnDeps, 'onSessionReady' | 'watcher'> {
|
|
50
51
|
/** Admin operator's userId, sourced from users.json at boot (same as
|
|
51
52
|
* RcDaemonDeps.userId). Passed to buildRcChildEnv so the scheduling MCP
|
|
@@ -85,6 +86,10 @@ export interface HttpDeps extends Omit<SpawnDeps, 'onSessionReady' | 'watcher'>
|
|
|
85
86
|
* omits this and gets RC_SPAWN_URL_WAIT_DEFAULT_MS (60s); tests inject a
|
|
86
87
|
* short value to exercise the bind-timeout response without a 60s wait. */
|
|
87
88
|
rcSpawnUrlWaitMs?: number;
|
|
89
|
+
/** Task 2557 — idle gap after which `/log?follow=1` writes a bare newline.
|
|
90
|
+
* Production omits this and gets LOG_FOLLOW_KEEPALIVE_DEFAULT_MS (30s);
|
|
91
|
+
* tests inject a short value to exercise the keepalive without a 30s wait. */
|
|
92
|
+
logFollowKeepaliveMs?: number;
|
|
88
93
|
/** Task 1749 — accessor for the standing PTY census's most recent snapshot,
|
|
89
94
|
* wired from `createPtyCensus(...).latest`. Optional so the pre-1749 test
|
|
90
95
|
* deps factories keep working; production index.ts always injects it.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAqB3B,OAAO,KAAK,EAAU,SAAS,EAAW,MAAM,YAAY,CAAA;AAM5D,OAAO,EAkBL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAqBzB,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAoBlD,OAAO,KAAK,EAAE,SAAS,EAAyB,MAAM,iBAAiB,CAAA;AACvE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAA;AAEzE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAE3D,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAA;AAQlE;;;;;;;;;;;mFAWmF;AACnF,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GACvB,OAAO,CAAC,SAAS,EAAE,OAAO,GAAG,QAAQ,CAAC,GAAG,IAAI,CAE/C;AAED;;;;;;;;;;2EAU2E;AAC3E,wBAAgB,cAAc,CAC5B,IAAI,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,EAC3C,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,GAAG,IAAI,CAOlD;AAsFD,eAAO,MAAM,kBAAkB,QAA2B,CAAA;AAS1D,eAAO,MAAM,4BAA4B,QAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
|
|
@@ -207,6 +207,17 @@ export const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
|
207
207
|
// Injectable via `HttpDeps.rcSpawnUrlWaitMs` so the timeout path is
|
|
208
208
|
// unit-testable without a real 60s wait.
|
|
209
209
|
export const RC_SPAWN_URL_WAIT_DEFAULT_MS = 60_000;
|
|
210
|
+
// Task 2557 — how long a follow stream may go without writing a byte before it
|
|
211
|
+
// emits a bare newline. The stream writes only when the JSONL grows, and Node's
|
|
212
|
+
// global fetch (undici) kills a response body after 300s with no bytes, so a
|
|
213
|
+
// conversation between messages killed its own reader: 129 of 129
|
|
214
|
+
// log-follow-close lines on laptop 192.168.88.10 read reason=aborted, shortest
|
|
215
|
+
// lifetime 313440ms, and no follower in the whole log history outlived a quiet
|
|
216
|
+
// patch. 30s costs one byte and leaves a tenfold margin against a slow tick.
|
|
217
|
+
// A genuinely stalled manager still trips undici's bound, because a stalled
|
|
218
|
+
// manager writes no keepalive either. Injectable via
|
|
219
|
+
// `HttpDeps.logFollowKeepaliveMs` so the path is testable without a 30s wait.
|
|
220
|
+
export const LOG_FOLLOW_KEEPALIVE_DEFAULT_MS = 30_000;
|
|
210
221
|
const LIST_TAIL_BYTES = 64 * 1024;
|
|
211
222
|
function timed(logger, method, path, status, ms) {
|
|
212
223
|
logger(`api method=${method} path=${path} outcome=${status} ms=${ms}`);
|
|
@@ -2534,6 +2545,10 @@ export function buildHttpApp(deps) {
|
|
|
2534
2545
|
let position = 0;
|
|
2535
2546
|
let linesStreamed = 0;
|
|
2536
2547
|
let closeReason = 'end';
|
|
2548
|
+
// Task 2557 — when this stream last put a byte on the wire. Every real
|
|
2549
|
+
// write stamps it; the poll loop below sends a keepalive once the gap
|
|
2550
|
+
// reaches the threshold, so an idle session never looks dead to undici.
|
|
2551
|
+
let lastWriteAt = Date.now();
|
|
2537
2552
|
const countNewlines = (buf) => {
|
|
2538
2553
|
let n = 0;
|
|
2539
2554
|
for (let i = 0; i < buf.length; i++)
|
|
@@ -2548,6 +2563,7 @@ export function buildHttpApp(deps) {
|
|
|
2548
2563
|
const buf = chunk;
|
|
2549
2564
|
linesStreamed += countNewlines(buf);
|
|
2550
2565
|
await out.write(buf);
|
|
2566
|
+
lastWriteAt = Date.now();
|
|
2551
2567
|
}
|
|
2552
2568
|
position = initialSize;
|
|
2553
2569
|
}
|
|
@@ -2587,12 +2603,25 @@ export function buildHttpApp(deps) {
|
|
|
2587
2603
|
const buf = chunk;
|
|
2588
2604
|
linesStreamed += countNewlines(buf);
|
|
2589
2605
|
void out.write(buf);
|
|
2606
|
+
lastWriteAt = Date.now();
|
|
2590
2607
|
}
|
|
2591
2608
|
});
|
|
2592
2609
|
};
|
|
2593
2610
|
watchFile(jsonlPath, { interval: 250 }, listener);
|
|
2611
|
+
const keepaliveMs = deps.logFollowKeepaliveMs ?? LOG_FOLLOW_KEEPALIVE_DEFAULT_MS;
|
|
2594
2612
|
while (active) {
|
|
2595
2613
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
2614
|
+
if (!active)
|
|
2615
|
+
break;
|
|
2616
|
+
if (Date.now() - lastWriteAt < keepaliveMs)
|
|
2617
|
+
continue;
|
|
2618
|
+
// Task 2557 — a bare newline, not a file line: it is excluded from
|
|
2619
|
+
// `linesStreamed` because that counter is what this stream carried from
|
|
2620
|
+
// the JSONL, and the emitter drops it at its own `if (!line) continue`.
|
|
2621
|
+
// Its only job is to be a byte, so undici's bodyTimeout never elapses
|
|
2622
|
+
// on a conversation that is merely quiet.
|
|
2623
|
+
await out.write(Buffer.from('\n'));
|
|
2624
|
+
lastWriteAt = Date.now();
|
|
2596
2625
|
}
|
|
2597
2626
|
deps.logger(`log-follow-close sessionId=${sessionId} reason=${closeReason} linesStreamed=${linesStreamed} ms=${Date.now() - followStreamStart}`);
|
|
2598
2627
|
});
|