@jjh32456/dsh-longlive 1.6.8 → 1.8.0
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/CHANGELOG.md +69 -1539
- package/LICENSE +1 -1
- package/QUICKSTART.md +33 -12
- package/README.en.md +17 -7
- package/README.md +29 -10
- package/SPEC.md +5 -5
- package/cordis.patch.yml +17 -12
- package/docs/ANSWER-GUARD.md +92 -0
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/AUTO-RECALL-WIRING.md +1 -1
- package/docs/AUTO-RECALL.md +15 -9
- package/docs/CONFIG.md +143 -37
- package/docs/ERRORS.md +17 -5
- package/docs/FAQ.md +7 -7
- package/docs/FEATURES.md +66 -29
- package/docs/MIGRATION-1.6.0.md +2 -2
- package/docs/OPERATIONS.md +6 -6
- package/docs/PANEL-INTEGRATION.md +6 -0
- package/docs/PRIVACY.md +88 -0
- package/docs/QUICKSTART.md +5 -5
- package/docs/README.en.md +3 -2
- package/docs/RESIDENT-WATCHER.md +1 -1
- package/docs/SESSION-WATCH.md +6 -6
- package/docs/TROUBLESHOOTING.md +4 -4
- package/package.json +42 -117
- package/runbook.md +2 -2
- package/scripts/backfill-auto-cards.mjs +98 -0
- package/scripts/install-surgery-task.ps1 +5 -2
- package/scripts/lib/session-watch-fixture.mjs +1 -1
- package/scripts/longlive-doctor.mjs +6 -0
- package/scripts/migrate-180.mjs +163 -0
- package/scripts/recall-index-build.mjs +128 -0
- package/scripts/session-watch-drill.mjs +1 -1
- package/scripts/surgery-run.cmd +1 -1
- package/scripts/unattended-surgery.ps1 +5 -2
- package/scripts/verify-card-anchors.mjs +138 -0
- package/src/actions/audit.js +125 -0
- package/src/actions/evidence.js +122 -0
- package/src/actions/fs.js +162 -0
- package/src/activity/index.js +129 -18
- package/src/activity/model.js +11 -1
- package/src/activity/store.js +22 -3
- package/src/activity/thinking.js +176 -0
- package/src/adapters/constants.js +11 -0
- package/src/adapters/engram/data/aliases.json +51 -0
- package/src/adapters/engram/data/policy.json +8 -0
- package/src/adapters/engram/data/routing-rules.json +546 -0
- package/src/adapters/engram/index.js +235 -0
- package/src/adapters/registry.js +315 -0
- package/src/answer/audit.js +193 -0
- package/src/answer/evidence.js +30 -0
- package/src/answer/guard.js +1370 -0
- package/src/answer/index.js +1067 -0
- package/src/answer/query.js +111 -0
- package/src/answer/second-hop.mjs +101 -0
- package/src/auto-truncate.js +1 -1
- package/src/config.js +145 -48
- package/src/deps.js +6 -4
- package/src/features.js +158 -8
- package/src/identity/index.js +13 -5
- package/src/identity/inject-position.js +2 -2
- package/src/index.js +97 -7
- package/src/lifecycle/gap-clocks.js +328 -0
- package/src/lifecycle/index.js +269 -0
- package/src/memory/activity-capture.js +229 -0
- package/src/memory/anchors.js +194 -0
- package/src/memory/audit-records.js +222 -0
- package/src/memory/auto-cards.js +401 -0
- package/src/memory/capture.js +442 -0
- package/src/memory/card-anchors.js +203 -0
- package/src/memory/conflicts.js +106 -0
- package/src/memory/decay.js +311 -0
- package/src/memory/distill.js +471 -0
- package/src/memory/event-index.js +917 -0
- package/src/memory/evidence-chain.js +295 -0
- package/src/memory/evidence.js +145 -0
- package/src/memory/filter-runner.js +1 -1
- package/src/memory/filter.js +5 -1
- package/src/memory/forget-v2.js +410 -0
- package/src/memory/forget.js +71 -0
- package/src/memory/frames-activation.js +247 -0
- package/src/memory/fts-query.js +174 -0
- package/src/memory/governance.js +604 -0
- package/src/memory/index.js +1 -0
- package/src/memory/l2-gate.js +169 -0
- package/src/memory/lock.js +121 -0
- package/src/memory/migrate.js +139 -0
- package/src/memory/recall-v2.js +223 -0
- package/src/memory/recall.js +65 -2
- package/src/memory/records.js +425 -0
- package/src/memory/retention.js +128 -0
- package/src/memory/review.js +124 -0
- package/src/memory/trust.js +88 -0
- package/src/memory/version-chain.js +264 -0
- package/src/memory/wal.js +137 -0
- package/src/memory/working-frame.js +516 -0
- package/src/metrics/classify.mjs +5 -3
- package/src/metrics/index.mjs +21 -1
- package/src/metrics/prestep-latency.mjs +300 -0
- package/src/metrics/store.mjs +25 -0
- package/src/policy-profile.js +18 -3
- package/src/recall/anaphora.mjs +87 -0
- package/src/recall/cards.mjs +7 -0
- package/src/recall/embedding.mjs +8 -8
- package/src/recall/entities.mjs +112 -0
- package/src/recall/index.js +235 -19
- package/src/recall/inject-order.mjs +10 -8
- package/src/recall/privacy.mjs +1 -1
- package/src/recall/recall-core.mjs +116 -10
- package/src/recall/shared-visibility.js +302 -0
- package/src/recall/time-normalizer.mjs +196 -0
- package/src/role/activation.js +191 -0
- package/src/role/attention.js +318 -0
- package/src/role/base.js +36 -0
- package/src/role/channels.js +423 -0
- package/src/role/index.js +425 -0
- package/src/role/interfaces.js +115 -0
- package/src/role/personality.js +370 -0
- package/src/role/readers.js +151 -0
- package/src/role/relationships.js +477 -0
- package/src/role/store.js +202 -0
- package/src/role/writeprotect.js +373 -0
- package/src/router/aliases.json +176 -25
- package/src/router/background/agent-runner.mjs +1 -1
- package/src/router/background/anchor.mjs +1 -1
- package/src/router/background/audit.mjs +2 -2
- package/src/router/background/budget-ledger.mjs +2 -2
- package/src/router/background/host-sim.mjs +1 -1
- package/src/router/background/memory.mjs +4 -4
- package/src/router/background/queue.mjs +1 -1
- package/src/router/background/retry.mjs +1 -1
- package/src/router/background/router.mjs +1 -1
- package/src/router/background/stream.mjs +1 -1
- package/src/router/background/tool-policy.mjs +1 -1
- package/src/router/design-headings.json +5 -5
- package/src/router/face/config.mjs +1 -1
- package/src/router/face/identity.mjs +3 -3
- package/src/router/judge.mjs +8 -7
- package/src/router/policies/generic.json +21 -4
- package/src/router/route.js +109 -22
- package/src/router/router.mjs +10 -6
- package/src/router/routing-rules.json +260 -445
- package/src/router/run-sim.mjs +8 -8
- package/src/router/runtime.js +23 -12
- package/src/router/service/config.mjs +5 -5
- package/src/router/service/context.mjs +1 -1
- package/src/router/service/queue.mjs +1 -1
- package/src/router/shadow.mjs +4 -3
- package/src/surgery-recovery.js +2 -2
- package/src/tool-output.js +2 -2
- package/src/tools.js +39 -7
- package/src/truncate-visible.js +2 -2
- package/src/ui/actions.js +1312 -0
- package/src/ui/panel-api.js +367 -0
- package/src/ui/panel-auth.js +251 -0
- package/src/ui/panel-integrity.js +243 -0
- package/src/ui/routes-readonly.js +695 -0
- package/src/ui/routes.js +3 -0
- package/docs/DEV.md +0 -279
- package/docs/PRESENCE-BUS-WIRING.md +0 -491
- package/docs/PRESENCE-BUS.md +0 -694
- package/docs/SURGERY.md +0 -186
- package/scripts/mark-core-cards.mjs +0 -289
- package/scripts/pack.mjs +0 -392
- package/scripts/router-proposals.mjs +0 -386
- package/src/router/fixtures/M1.json +0 -16
- package/src/router/fixtures/M2.json +0 -16
- package/src/router/fixtures/M3.json +0 -20
- package/src/router/fixtures/M4.json +0 -20
- package/src/router/fixtures/M5.json +0 -45
- package/src/router/fixtures/M6.json +0 -53
- package/src/router/fixtures/M7.json +0 -46
- package/src/router/fixtures/S1.json +0 -20
- package/src/router/fixtures/S2.json +0 -23
- package/src/router/fixtures/S3.json +0 -20
- package/src/router/fixtures/S4.json +0 -34
- package/src/router/fixtures/S5.json +0 -43
- package/src/router/fixtures/S6.json +0 -47
- package/src/router/fixtures/T1.json +0 -23
- package/src/router/fixtures/T2.json +0 -16
- package/src/router/fixtures/T3.json +0 -19
- package/src/router/fixtures/T4.json +0 -16
- package/src/router/fixtures/X1.json +0 -17
- package/src/router/fixtures/X2.json +0 -20
- package/src/router/gen-design-headings.mjs +0 -107
- package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
- package/test/activity-realtime.test.mjs +0 -959
- package/test/activity-stream.test.mjs +0 -512
- package/test/auto-recall.test.mjs +0 -474
- package/test/auto-truncate.test.mjs +0 -931
- package/test/autobind.test.mjs +0 -399
- package/test/bus.test.mjs +0 -1572
- package/test/cli-guard.test.mjs +0 -198
- package/test/conclusion.test.mjs +0 -408
- package/test/config-schema.test.mjs +0 -210
- package/test/d6-output.test.mjs +0 -194
- package/test/d6.test.mjs +0 -395
- package/test/deps.test.mjs +0 -342
- package/test/doctor-setup.test.mjs +0 -297
- package/test/embedding-recall.test.mjs +0 -347
- package/test/feature-flags.test.mjs +0 -585
- package/test/filter-shadow.test.mjs +0 -170
- package/test/fixtures/activity-append-child.mjs +0 -29
- package/test/fixtures/baseline-0.8.0-registration.json +0 -556
- package/test/fixtures/d6-header-tools.json +0 -1
- package/test/fixtures/lock-child.mjs +0 -67
- package/test/fixtures/pre-step-container.mjs +0 -100
- package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
- package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
- package/test/fixtures/profile-session-longlive-config.json +0 -20
- package/test/fixtures/proposal-cas-child.mjs +0 -66
- package/test/host-engine-status.test.mjs +0 -307
- package/test/hot-truncate.test.mjs +0 -1349
- package/test/identity-card-marker.test.mjs +0 -307
- package/test/identity-router/run.mjs +0 -721
- package/test/integration-pre-step.test.mjs +0 -553
- package/test/live.test.mjs +0 -1023
- package/test/maintenance-schedule.test.mjs +0 -538
- package/test/memory.test.mjs +0 -2828
- package/test/metrics.test.mjs +0 -478
- package/test/orchestrator.test.mjs +0 -266
- package/test/p1_probe.mjs +0 -97
- package/test/packaging.test.mjs +0 -184
- package/test/portability.test.mjs +0 -343
- package/test/rc4-regressions.test.mjs +0 -562
- package/test/rc5-regressions.test.mjs +0 -197
- package/test/rc6-chinese.test.mjs +0 -394
- package/test/router-1100/apply-off.test.mjs +0 -175
- package/test/router-1100/budget-reconcile.test.mjs +0 -215
- package/test/router-1100/config-context.test.mjs +0 -211
- package/test/router-1100/helpers.mjs +0 -129
- package/test/router-1100/hint-envelope.test.mjs +0 -150
- package/test/router-1100/lock-crossproc.test.mjs +0 -206
- package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
- package/test/router-1100/router-apply.test.mjs +0 -372
- package/test/router-1100/router-fixb.test.mjs +0 -602
- package/test/router-1100/router-proposals.test.mjs +0 -405
- package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
- package/test/router-1100/router-security-cr.test.mjs +0 -333
- package/test/router-1100/service-lifecycle.test.mjs +0 -220
- package/test/router-1100/tools-contract.test.mjs +0 -164
- package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
- package/test/router-1100/v6-contract.test.mjs +0 -158
- package/test/router-1100/v6b-contract.test.mjs +0 -293
- package/test/router-1100/v6c-contract.test.mjs +0 -249
- package/test/router-1100/wal-rotation.test.mjs +0 -375
- package/test/router-1100/wp1-config.test.mjs +0 -170
- package/test/router-1100/wp1-default-off.test.mjs +0 -77
- package/test/router-1100/wp1-model-status.test.mjs +0 -90
- package/test/router-1100/wp1-status.test.mjs +0 -220
- package/test/router-1100/wp1-tools.test.mjs +0 -146
- package/test/router-1100/wp5-cli.test.mjs +0 -240
- package/test/router-1100/wp5-model-auto.test.mjs +0 -418
- package/test/router-1100/wp6-merge.test.mjs +0 -386
- package/test/router-1100/wp6-wake.test.mjs +0 -502
- package/test/router-background/audit.test.mjs +0 -37
- package/test/router-background/budget-ledger.test.mjs +0 -330
- package/test/router-background/cross-process.test.mjs +0 -222
- package/test/router-background/fixtures/cross-child.mjs +0 -85
- package/test/router-background/fixtures/real-shapes.mjs +0 -239
- package/test/router-background/router.test.mjs +0 -1076
- package/test/router-background/stream-retry.test.mjs +0 -358
- package/test/router-background/tool-agent.test.mjs +0 -384
- package/test/router-face/bridge.test.mjs +0 -169
- package/test/router-face/face.test.mjs +0 -316
- package/test/router-failclosed.test.mjs +0 -466
- package/test/router-identity.test.mjs +0 -384
- package/test/router-l1-l2.contract.test.mjs +0 -1075
- package/test/router-memory-tools.test.mjs +0 -262
- package/test/router-policy.test.mjs +0 -225
- package/test/router-runtime.test.mjs +0 -438
- package/test/router-shadow.test.mjs +0 -287
- package/test/router.test.mjs +0 -903
- package/test/run-rc.mjs +0 -315
- package/test/run.mjs +0 -2469
- package/test/safety-defaults.test.mjs +0 -181
- package/test/seeded-load.test.mjs +0 -436
- package/test/session-original.test.mjs +0 -338
- package/test/state-truth.test.mjs +0 -937
- package/test/surgery-recovery.test.mjs +0 -936
- package/test/t7_drive.mjs +0 -151
- package/test/tool-output.test.mjs +0 -393
- package/test/tool-schema.test.mjs +0 -177
- package/test/tools_contract.test.mjs +0 -716
- package/test/truncate-visible.test.mjs +0 -1150
- package/test/ui-flags.test.mjs +0 -477
- package/test/v4-hardening.test.mjs +0 -427
- package/test/version-consistency.test.mjs +0 -286
package/test/live.test.mjs
DELETED
|
@@ -1,1023 +0,0 @@
|
|
|
1
|
-
// Live session registry tests (L0-L9).
|
|
2
|
-
//
|
|
3
|
-
// The registry is a cross-process, crash-tolerant table, so these cases write
|
|
4
|
-
// real files under the harness tmp directory and L3 runs real child processes.
|
|
5
|
-
// Nothing here writes inside the package tree (P6 checks that afterwards),
|
|
6
|
-
// reads credentials, or touches the network.
|
|
7
|
-
|
|
8
|
-
import fs from 'node:fs';
|
|
9
|
-
import os from 'node:os';
|
|
10
|
-
import path from 'node:path';
|
|
11
|
-
import crypto from 'node:crypto';
|
|
12
|
-
import { spawn } from 'node:child_process';
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
LIVE_SCHEMA_VERSION,
|
|
16
|
-
LIVE_CONFIRM,
|
|
17
|
-
LIVE_RECORD_FIELDS,
|
|
18
|
-
GOAL_MAX_CHARS,
|
|
19
|
-
pidAlive,
|
|
20
|
-
liveFile,
|
|
21
|
-
liveUpdate,
|
|
22
|
-
liveList,
|
|
23
|
-
liveGet,
|
|
24
|
-
livePrune,
|
|
25
|
-
} from '../src/live.js';
|
|
26
|
-
import { TOOLS } from '../src/tools.js';
|
|
27
|
-
|
|
28
|
-
const LIVE_MODULE_URL = new URL('../src/live.js', import.meta.url).href;
|
|
29
|
-
const SUITE = 'live-suite';
|
|
30
|
-
const NOW = Date.parse('2026-05-06T07:08:09.000Z');
|
|
31
|
-
const HOST = os.hostname();
|
|
32
|
-
const REMOTE_HOST = 'live-test-remote-host';
|
|
33
|
-
const FAKE_PID = 0x7ffffff0;
|
|
34
|
-
|
|
35
|
-
function iso(ms) {
|
|
36
|
-
return new Date(ms).toISOString();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function sha256(bytes) {
|
|
40
|
-
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function liveDirOf(root) {
|
|
44
|
-
return path.join(root, '.longlive', 'live');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function newRoot(TMP, name) {
|
|
48
|
-
const root = path.join(TMP, SUITE, name);
|
|
49
|
-
fs.mkdirSync(root, { recursive: true });
|
|
50
|
-
return root;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** One complete row with every field, ready to be tweaked per case. */
|
|
54
|
-
function makeRecord(overrides = {}) {
|
|
55
|
-
return {
|
|
56
|
-
schemaVersion: LIVE_SCHEMA_VERSION,
|
|
57
|
-
sessionId: 'row',
|
|
58
|
-
pid: process.pid,
|
|
59
|
-
host: HOST,
|
|
60
|
-
startedAt: iso(NOW),
|
|
61
|
-
updatedAt: iso(NOW),
|
|
62
|
-
heartbeatAt: iso(NOW),
|
|
63
|
-
ttlMs: 60000,
|
|
64
|
-
state: 'running',
|
|
65
|
-
turn: 0,
|
|
66
|
-
goal: '',
|
|
67
|
-
blocked_on: '',
|
|
68
|
-
queued: [],
|
|
69
|
-
last_action: { at: iso(NOW), kind: 'test', detail: '' },
|
|
70
|
-
...overrides,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function putRow(root, record) {
|
|
75
|
-
const dir = liveDirOf(root);
|
|
76
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
-
const file = path.join(dir, `${record.sessionId}.json`);
|
|
78
|
-
fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
|
|
79
|
-
return file;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** name -> sha256 of every regular file, sorted, for byte-identical checks. */
|
|
83
|
-
function snapshotFiles(dir) {
|
|
84
|
-
const out = {};
|
|
85
|
-
if (!fs.existsSync(dir)) return out;
|
|
86
|
-
for (const name of fs.readdirSync(dir).sort()) {
|
|
87
|
-
const full = path.join(dir, name);
|
|
88
|
-
if (fs.statSync(full).isFile()) out[name] = sha256(fs.readFileSync(full));
|
|
89
|
-
}
|
|
90
|
-
return out;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Swap fs methods for one call; restores them even when the call throws. */
|
|
94
|
-
function withFsPatch(patches, fn) {
|
|
95
|
-
const originals = new Map();
|
|
96
|
-
for (const [key, value] of Object.entries(patches)) {
|
|
97
|
-
originals.set(key, fs[key]);
|
|
98
|
-
fs[key] = value;
|
|
99
|
-
}
|
|
100
|
-
try {
|
|
101
|
-
return fn();
|
|
102
|
-
} finally {
|
|
103
|
-
for (const [key, value] of originals) fs[key] = value;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Run one child to completion without stdio pipes; results travel by file. */
|
|
108
|
-
function runChild(script, args, timeoutMs = 60000) {
|
|
109
|
-
return new Promise((resolve) => {
|
|
110
|
-
const child = spawn(process.execPath, [script, ...args], { stdio: 'ignore', windowsHide: true });
|
|
111
|
-
const timer = setTimeout(() => {
|
|
112
|
-
try {
|
|
113
|
-
child.kill();
|
|
114
|
-
} catch {
|
|
115
|
-
/* already gone */
|
|
116
|
-
}
|
|
117
|
-
resolve({ code: 'timeout', signal: null });
|
|
118
|
-
}, timeoutMs);
|
|
119
|
-
child.on('error', (error) => {
|
|
120
|
-
clearTimeout(timer);
|
|
121
|
-
resolve({ code: 'spawn-error', error: error.message });
|
|
122
|
-
});
|
|
123
|
-
child.on('exit', (code, signal) => {
|
|
124
|
-
clearTimeout(timer);
|
|
125
|
-
resolve({ code, signal });
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Each child patches the same row 12 times with a ~52 KB payload, so the
|
|
131
|
-
// renames genuinely overlap. Results go to per-child files: a piped stdout can
|
|
132
|
-
// be unavailable under Windows confinement, a file cannot.
|
|
133
|
-
const WORKER_SOURCE = [
|
|
134
|
-
"import fs from 'node:fs';",
|
|
135
|
-
`import { liveUpdate } from ${JSON.stringify(LIVE_MODULE_URL)};`,
|
|
136
|
-
'',
|
|
137
|
-
"const [root, sessionId, index, outFile] = process.argv.slice(2);",
|
|
138
|
-
"const queue = Array.from({ length: 300 }, (_, i) => 'q-' + index + '-' + i + '-' + 'x'.repeat(160));",
|
|
139
|
-
'let last = null;',
|
|
140
|
-
'try {',
|
|
141
|
-
' for (let round = 0; round < 12; round += 1) {',
|
|
142
|
-
' last = liveUpdate({',
|
|
143
|
-
' sessionId,',
|
|
144
|
-
" state: round % 2 === 0 ? 'running' : 'waiting',",
|
|
145
|
-
' turn: round,',
|
|
146
|
-
" goal: 'race ' + index + ':' + round,",
|
|
147
|
-
' queued: queue,',
|
|
148
|
-
' ttlMs: 60000,',
|
|
149
|
-
' }, { memoryRoot: root });',
|
|
150
|
-
' }',
|
|
151
|
-
' fs.writeFileSync(outFile, JSON.stringify({',
|
|
152
|
-
' ok: true, sha256: last.sha256, bytes: last.bytes,',
|
|
153
|
-
" superseded: last.superseded, pid: process.pid, sessionId: last.sessionId,",
|
|
154
|
-
' }));',
|
|
155
|
-
'} catch (error) {',
|
|
156
|
-
' fs.writeFileSync(outFile, JSON.stringify({',
|
|
157
|
-
" ok: false, code: error.code ?? null, message: error.message,",
|
|
158
|
-
' }));',
|
|
159
|
-
' process.exitCode = 1;',
|
|
160
|
-
'}',
|
|
161
|
-
'',
|
|
162
|
-
].join('\n');
|
|
163
|
-
|
|
164
|
-
export async function runLiveSuite({ runCase, runAsyncCase, runSymlinkCase, assert, expectCode, TMP }) {
|
|
165
|
-
fs.mkdirSync(path.join(TMP, SUITE), { recursive: true });
|
|
166
|
-
|
|
167
|
-
// L0 — wiring: the four tools exist and carry the documented schema.
|
|
168
|
-
runCase('L0', '四个 session_live_* 已注册,state 必填且枚举只有五种声明态', () => {
|
|
169
|
-
const names = [
|
|
170
|
-
'session_live_update',
|
|
171
|
-
'session_live_list',
|
|
172
|
-
'session_live_get',
|
|
173
|
-
'session_live_prune',
|
|
174
|
-
];
|
|
175
|
-
for (const name of names) {
|
|
176
|
-
assert(TOOLS.some((tool) => tool.name === name), `missing tool ${name}`);
|
|
177
|
-
}
|
|
178
|
-
const update = TOOLS.find((tool) => tool.name === 'session_live_update');
|
|
179
|
-
assert(update.parameters.state.type === 'string', 'state must be a string');
|
|
180
|
-
assert(
|
|
181
|
-
JSON.stringify(update.parameters.state.enum)
|
|
182
|
-
=== JSON.stringify(['idle', 'running', 'waiting', 'blocked', 'stale']),
|
|
183
|
-
`state enum must be the five declared states, got ${JSON.stringify(update.parameters.state.enum)}`,
|
|
184
|
-
);
|
|
185
|
-
assert(update.parameters.sessionId, 'sessionId schema must exist');
|
|
186
|
-
const list = TOOLS.find((tool) => tool.name === 'session_live_list');
|
|
187
|
-
assert(list.parameters.includeDead.type === 'boolean', 'includeDead schema');
|
|
188
|
-
const get = TOOLS.find((tool) => tool.name === 'session_live_get');
|
|
189
|
-
assert(get.parameters.sessionId.type === 'string', 'get sessionId schema');
|
|
190
|
-
const prune = TOOLS.find((tool) => tool.name === 'session_live_prune');
|
|
191
|
-
assert(prune.parameters.confirm.type === 'string', 'prune confirm schema');
|
|
192
|
-
assert(prune.parameters.dryRun.type === 'boolean', 'prune dryRun schema');
|
|
193
|
-
|
|
194
|
-
const root = newRoot(TMP, 'l0');
|
|
195
|
-
const written = update.invoke({ sessionId: 's-l0', state: 'idle' }, { memoryRoot: root });
|
|
196
|
-
assert(written.ok && written.created === true, 'registered invoke must write through live.js');
|
|
197
|
-
return '4 tools registered; state enum = 5 declared states';
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
// L1 — round-trip, exact field set, patch semantics, goal truncation.
|
|
201
|
-
runCase('L1', '写读往返:15 字段齐全,patch 保留旧值,goal 截断并标注', () => {
|
|
202
|
-
const root = newRoot(TMP, 'l1');
|
|
203
|
-
const sessionId = 's-l1';
|
|
204
|
-
const longGoal = '一二三四五六七八九十'.repeat(9); // 90 code points
|
|
205
|
-
const first = liveUpdate({
|
|
206
|
-
sessionId,
|
|
207
|
-
state: 'blocked',
|
|
208
|
-
turn: 7,
|
|
209
|
-
goal: longGoal,
|
|
210
|
-
blocked_on: 'waiting for review',
|
|
211
|
-
queued: ['a', 'b', 'c'],
|
|
212
|
-
ttlMs: 120000,
|
|
213
|
-
last_action: { at: iso(NOW), kind: 'edit', detail: 'first write' },
|
|
214
|
-
}, { memoryRoot: root, now: NOW });
|
|
215
|
-
|
|
216
|
-
assert(first.ok === true && first.created === true, 'first write must create');
|
|
217
|
-
assert(first.goalTruncated === true, 'long goal must be flagged as truncated');
|
|
218
|
-
assert(first.goalChars === 90, `goalChars must be 90, got ${first.goalChars}`);
|
|
219
|
-
assert(first.goalLimit === GOAL_MAX_CHARS, 'goalLimit');
|
|
220
|
-
// Independent oracle: the literal first eight repetitions, NOT the same
|
|
221
|
-
// Array.from().slice() expression the implementation uses.
|
|
222
|
-
assert(
|
|
223
|
-
first.record.goal === '一二三四五六七八九十'.repeat(8),
|
|
224
|
-
'stored goal must be the first 80 code points (literal expectation)',
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
const file = path.join(root, '.longlive', 'live', `${sessionId}.json`);
|
|
228
|
-
assert(fs.existsSync(file), `row file must exist at ${file}`);
|
|
229
|
-
const stored = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
230
|
-
// Hard-coded field list: importing LIVE_RECORD_FIELDS would let a mutation
|
|
231
|
-
// that drops a field pass, because the expectation would move with it.
|
|
232
|
-
const DOCUMENTED_FIELDS = [
|
|
233
|
-
'schemaVersion', 'sessionId', 'pid', 'host', 'startedAt', 'updatedAt', 'heartbeatAt',
|
|
234
|
-
'ttlMs', 'state', 'turn', 'goal', 'goalUpdatedAt', 'blocked_on', 'queued', 'last_action',
|
|
235
|
-
];
|
|
236
|
-
assert(
|
|
237
|
-
JSON.stringify(Object.keys(stored)) === JSON.stringify(DOCUMENTED_FIELDS),
|
|
238
|
-
`stored row must be exactly the 15 documented fields in order, got ${JSON.stringify(Object.keys(stored))}`,
|
|
239
|
-
);
|
|
240
|
-
assert(stored.schemaVersion === LIVE_SCHEMA_VERSION, 'schemaVersion');
|
|
241
|
-
assert(stored.pid === process.pid, 'pid must be the writing process');
|
|
242
|
-
assert(stored.host === HOST, 'host must be this machine');
|
|
243
|
-
assert(stored.startedAt === iso(NOW), 'startedAt = now');
|
|
244
|
-
assert(stored.updatedAt === iso(NOW) && stored.heartbeatAt === iso(NOW), 'updatedAt/heartbeatAt = now');
|
|
245
|
-
// R3: the goal has its own clock; a heartbeat touch must never move it.
|
|
246
|
-
assert(stored.goalUpdatedAt === iso(NOW), 'goalUpdatedAt = the goal write time');
|
|
247
|
-
assert(stored.state === 'blocked', 'state');
|
|
248
|
-
assert(stored.turn === 7, 'turn');
|
|
249
|
-
assert(stored.blocked_on === 'waiting for review', 'blocked_on while blocked');
|
|
250
|
-
assert(JSON.stringify(stored.queued) === JSON.stringify(['a', 'b', 'c']), 'queued');
|
|
251
|
-
assert(stored.ttlMs === 120000, 'ttlMs');
|
|
252
|
-
assert(
|
|
253
|
-
stored.last_action.at === iso(NOW)
|
|
254
|
-
&& stored.last_action.kind === 'edit'
|
|
255
|
-
&& stored.last_action.detail === 'first write',
|
|
256
|
-
'last_action',
|
|
257
|
-
);
|
|
258
|
-
|
|
259
|
-
// Patch: only state. Everything else is preserved; blocked_on is cleared
|
|
260
|
-
// because the session is no longer blocked.
|
|
261
|
-
const second = liveUpdate({ sessionId, state: 'running' }, { memoryRoot: root, now: NOW + 5000 });
|
|
262
|
-
assert(second.created === false, 'second write is an update');
|
|
263
|
-
assert(second.blockedOnCleared === true, 'blocked_on must be cleared when state != blocked');
|
|
264
|
-
const patched = second.record;
|
|
265
|
-
assert(patched.turn === 7, 'turn preserved by patch');
|
|
266
|
-
assert(patched.goal === stored.goal, 'goal preserved by patch');
|
|
267
|
-
assert(patched.goalUpdatedAt === iso(NOW), 'goalUpdatedAt preserved by a goal-less patch');
|
|
268
|
-
assert(JSON.stringify(patched.queued) === JSON.stringify(['a', 'b', 'c']), 'queued preserved');
|
|
269
|
-
assert(patched.blocked_on === '', 'blocked_on emptied');
|
|
270
|
-
assert(patched.startedAt === iso(NOW), 'startedAt preserved across updates');
|
|
271
|
-
assert(patched.updatedAt === iso(NOW + 5000), 'updatedAt moves');
|
|
272
|
-
assert(patched.last_action.kind === 'edit', 'last_action preserved when omitted');
|
|
273
|
-
|
|
274
|
-
const got = liveGet({ sessionId }, { memoryRoot: root, now: NOW + 5000 });
|
|
275
|
-
assert(got.ok === true && got.found === true && got.status === 'ok', 'get ok');
|
|
276
|
-
assert(got.live.effectiveState === 'running', `get effectiveState running, got ${got.live.effectiveState}`);
|
|
277
|
-
assert(got.live.fileRel === '.longlive/live/s-l1.json', `fileRel, got ${got.live.fileRel}`);
|
|
278
|
-
assert(got.live.pidAlive === true && got.live.sameHost === true, 'get reports liveness');
|
|
279
|
-
|
|
280
|
-
const listed = liveList({}, { memoryRoot: root, now: NOW + 5000 });
|
|
281
|
-
assert(listed.ok === true && listed.count === 1 && listed.scanned === 1, 'list count');
|
|
282
|
-
assert(listed.entries[0].sessionId === sessionId, 'list sessionId');
|
|
283
|
-
assert(listed.entries[0].goal === stored.goal, 'list goal matches stored');
|
|
284
|
-
assert(listed.entries[0].ttlMs === 120000, 'list ttlMs');
|
|
285
|
-
assert(listed.counts.running === 1, 'list counts.running');
|
|
286
|
-
|
|
287
|
-
const names = fs.readdirSync(path.dirname(file)).sort();
|
|
288
|
-
assert(
|
|
289
|
-
JSON.stringify(names) === JSON.stringify([`${sessionId}.json`]),
|
|
290
|
-
`registry dir must hold exactly one row, got ${names.join(',')}`,
|
|
291
|
-
);
|
|
292
|
-
return 'round-trip ok; goal 90 -> 80; blocked_on cleared on unblock';
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
// L2 — atomic write: a crash half-way leaves the previous complete row.
|
|
296
|
-
runCase('L2', '原子写:写一半 / rename 失败 / 读回损坏都不留半个 JSON,旧文件仍可解析', () => {
|
|
297
|
-
const root = newRoot(TMP, 'l2');
|
|
298
|
-
const sessionId = 's-atomic';
|
|
299
|
-
liveUpdate(
|
|
300
|
-
{ sessionId, state: 'running', turn: 1, goal: 'before' },
|
|
301
|
-
{ memoryRoot: root, now: NOW },
|
|
302
|
-
);
|
|
303
|
-
const file = liveFile(root, sessionId);
|
|
304
|
-
const beforeText = fs.readFileSync(file, 'utf8');
|
|
305
|
-
const realWrite = fs.writeFileSync;
|
|
306
|
-
const realRead = fs.readFileSync;
|
|
307
|
-
|
|
308
|
-
// (a) the tmp write explodes before it completes
|
|
309
|
-
expectCode(() => withFsPatch({
|
|
310
|
-
writeFileSync: (...args) => {
|
|
311
|
-
if (String(args[0]).includes('.tmp-')) {
|
|
312
|
-
throw Object.assign(new Error('injected half write'), { code: 'EIO' });
|
|
313
|
-
}
|
|
314
|
-
return realWrite(...args);
|
|
315
|
-
},
|
|
316
|
-
}, () => liveUpdate(
|
|
317
|
-
{ sessionId, state: 'running', turn: 2 },
|
|
318
|
-
{ memoryRoot: root, now: NOW + 1000 },
|
|
319
|
-
)), 'E_LIVE_WRITE');
|
|
320
|
-
assert(fs.readFileSync(file, 'utf8') === beforeText, 'failed tmp write must leave the old row byte-identical');
|
|
321
|
-
assert(JSON.parse(fs.readFileSync(file, 'utf8')).turn === 1, 'old row still parses');
|
|
322
|
-
|
|
323
|
-
// (b) rename fails: the tmp is cleaned up and the old row survives
|
|
324
|
-
expectCode(() => withFsPatch({
|
|
325
|
-
renameSync: () => {
|
|
326
|
-
throw Object.assign(new Error('injected rename failure'), { code: 'EIO' });
|
|
327
|
-
},
|
|
328
|
-
}, () => liveUpdate(
|
|
329
|
-
{ sessionId, state: 'waiting', turn: 3 },
|
|
330
|
-
{ memoryRoot: root, now: NOW + 2000 },
|
|
331
|
-
)), 'E_LIVE_WRITE');
|
|
332
|
-
assert(fs.readFileSync(file, 'utf8') === beforeText, 'failed rename must leave the old row byte-identical');
|
|
333
|
-
assert(
|
|
334
|
-
fs.readdirSync(path.dirname(file)).filter((name) => name.includes('.tmp-')).length === 0,
|
|
335
|
-
'failed rename must not leave a tmp file behind',
|
|
336
|
-
);
|
|
337
|
-
|
|
338
|
-
// (c) post-rename read-back is damaged: the old bytes are restored and the
|
|
339
|
-
// call fails instead of reporting a write it cannot verify. Reads of the
|
|
340
|
-
// target are: readPrior (1), writeLiveText prior (2), read-back (3).
|
|
341
|
-
let targetReads = 0;
|
|
342
|
-
const readBackError = expectCode(() => withFsPatch({
|
|
343
|
-
readFileSync: (...args) => {
|
|
344
|
-
if (args[0] === file) {
|
|
345
|
-
targetReads += 1;
|
|
346
|
-
if (targetReads === 3) return '{"half": ';
|
|
347
|
-
}
|
|
348
|
-
return realRead(...args);
|
|
349
|
-
},
|
|
350
|
-
}, () => liveUpdate(
|
|
351
|
-
{ sessionId, state: 'blocked', turn: 4 },
|
|
352
|
-
{ memoryRoot: root, now: NOW + 3000 },
|
|
353
|
-
)), 'E_LIVE_WRITE');
|
|
354
|
-
assert(readBackError.code === 'E_LIVE_WRITE', 'read-back failure is E_LIVE_WRITE');
|
|
355
|
-
assert(fs.readFileSync(file, 'utf8') === beforeText, 'damaged read-back must restore the old row');
|
|
356
|
-
assert(JSON.parse(fs.readFileSync(file, 'utf8')).turn === 1, 'restored row still parses');
|
|
357
|
-
|
|
358
|
-
// and the registry is still writable afterwards
|
|
359
|
-
const recovered = liveUpdate(
|
|
360
|
-
{ sessionId, state: 'running', turn: 5 },
|
|
361
|
-
{ memoryRoot: root, now: NOW + 4000 },
|
|
362
|
-
);
|
|
363
|
-
assert(recovered.ok === true && JSON.parse(fs.readFileSync(file, 'utf8')).turn === 5, 'write recovers');
|
|
364
|
-
return 'tmp failure, rename failure and damaged read-back all keep the old complete row';
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
// L3 — real concurrent writers; the final file is one complete JSON row.
|
|
368
|
-
await runAsyncCase('L3', '并发写同一会话:4 个真进程 x 12 次,最后是一份完整 JSON', async () => {
|
|
369
|
-
const root = newRoot(TMP, 'l3');
|
|
370
|
-
const sessionId = 's-race';
|
|
371
|
-
const script = path.join(TMP, SUITE, 'l3-worker.mjs');
|
|
372
|
-
fs.writeFileSync(script, WORKER_SOURCE);
|
|
373
|
-
|
|
374
|
-
const children = [];
|
|
375
|
-
for (let index = 0; index < 4; index += 1) {
|
|
376
|
-
const out = path.join(TMP, SUITE, `l3-out-${index}.json`);
|
|
377
|
-
children.push(runChild(script, [root, sessionId, String(index), out]).then((result) => ({ ...result, out })));
|
|
378
|
-
}
|
|
379
|
-
const finished = await Promise.all(children);
|
|
380
|
-
for (const child of finished) {
|
|
381
|
-
assert(fs.existsSync(child.out), `child wrote no result (code ${child.code})`);
|
|
382
|
-
const result = JSON.parse(fs.readFileSync(child.out, 'utf8'));
|
|
383
|
-
assert(result.ok === true, `child failed (exit ${child.code}): ${JSON.stringify(result)}`);
|
|
384
|
-
assert(result.sessionId === sessionId, 'child wrote another row');
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const file = liveFile(root, sessionId);
|
|
388
|
-
const text = fs.readFileSync(file, 'utf8');
|
|
389
|
-
// JSON.parse over the WHOLE text is the interleaving test: two payloads
|
|
390
|
-
// mixed together would not parse.
|
|
391
|
-
const record = JSON.parse(text);
|
|
392
|
-
assert(record.sessionId === sessionId, 'final row sessionId');
|
|
393
|
-
// MAX_QUEUED_ITEMS caps the row: 300 offered items become 200 stored ones.
|
|
394
|
-
assert(Array.isArray(record.queued) && record.queued.length === 200, `final queued 200, got ${record.queued?.length}`);
|
|
395
|
-
assert(record.queued.every((item) => typeof item === 'string' && item.startsWith('q-')), 'queue items intact');
|
|
396
|
-
assert(record.queued.every((item) => [...item].length <= 300), 'each queued item is capped at 300 code points');
|
|
397
|
-
assert(
|
|
398
|
-
[...LIVE_RECORD_FIELDS].every((key) => key in record),
|
|
399
|
-
'final row has every field',
|
|
400
|
-
);
|
|
401
|
-
assert(text.trim().startsWith('{') && text.trim().endsWith('}'), 'file is exactly one JSON object');
|
|
402
|
-
const leftovers = fs.readdirSync(path.dirname(file)).filter((name) => name.includes('.tmp-'));
|
|
403
|
-
assert(leftovers.length === 0, `no orphan tmp files, got ${leftovers.join(',')}`);
|
|
404
|
-
return `4 children x 12 writes; final ${Buffer.byteLength(text)} bytes parse as one row`;
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
// L4 — heartbeat bands: fresh wins over everything, stale starts after 2x.
|
|
408
|
-
runCase('L4', '心跳带:ttl 内一律 running(pid 已死也是);> 2x ttl 才 stale', () => {
|
|
409
|
-
const root = newRoot(TMP, 'l4');
|
|
410
|
-
const ttl = 60000;
|
|
411
|
-
putRow(root, makeRecord({
|
|
412
|
-
sessionId: 's-boundary',
|
|
413
|
-
ttlMs: ttl,
|
|
414
|
-
heartbeatAt: iso(NOW - 2 * ttl),
|
|
415
|
-
updatedAt: iso(NOW - 2 * ttl),
|
|
416
|
-
}));
|
|
417
|
-
putRow(root, makeRecord({
|
|
418
|
-
sessionId: 's-stale',
|
|
419
|
-
ttlMs: ttl,
|
|
420
|
-
heartbeatAt: iso(NOW - 2 * ttl - 1),
|
|
421
|
-
updatedAt: iso(NOW - 2 * ttl - 1),
|
|
422
|
-
}));
|
|
423
|
-
putRow(root, makeRecord({
|
|
424
|
-
sessionId: 's-declared-stale',
|
|
425
|
-
state: 'stale',
|
|
426
|
-
heartbeatAt: iso(NOW - 10),
|
|
427
|
-
updatedAt: iso(NOW - 10),
|
|
428
|
-
}));
|
|
429
|
-
// B4: the writer's pid is not the session's; a fresh heartbeat wins even
|
|
430
|
-
// when that pid no longer exists.
|
|
431
|
-
putRow(root, makeRecord({
|
|
432
|
-
sessionId: 's-fresh-dead-pid',
|
|
433
|
-
pid: FAKE_PID,
|
|
434
|
-
heartbeatAt: iso(NOW - 10),
|
|
435
|
-
updatedAt: iso(NOW - 10),
|
|
436
|
-
}));
|
|
437
|
-
|
|
438
|
-
const listed = liveList({}, { memoryRoot: root, now: NOW });
|
|
439
|
-
const byId = Object.fromEntries(listed.entries.map((entry) => [entry.sessionId, entry]));
|
|
440
|
-
assert(byId['s-boundary'].effectiveState === 'running', `age == 2x ttl stays running, got ${byId['s-boundary'].effectiveState}`);
|
|
441
|
-
assert(byId['s-stale'].effectiveState === 'stale', 'age > 2x ttl must be stale');
|
|
442
|
-
assert(byId['s-stale'].heartbeatAgeMs === 2 * ttl + 1, 'heartbeatAgeMs reported');
|
|
443
|
-
assert(byId['s-declared-stale'].effectiveState === 'running', 'a fresh heartbeat overrides a declared stale');
|
|
444
|
-
assert(byId['s-fresh-dead-pid'].effectiveState === 'running', 'fresh heartbeat must beat a dead pid');
|
|
445
|
-
assert(byId['s-fresh-dead-pid'].pidAlive === false, 'the dead pid is still reported, just not decisive');
|
|
446
|
-
assert(listed.counts.stale === 1, `counts.stale 1, got ${listed.counts.stale}`);
|
|
447
|
-
assert(listed.counts.running === 3, `counts.running 3, got ${listed.counts.running}`);
|
|
448
|
-
|
|
449
|
-
const got = liveGet({ sessionId: 's-stale' }, { memoryRoot: root, now: NOW });
|
|
450
|
-
assert(got.live.effectiveState === 'stale', 'get must compute the same stale state');
|
|
451
|
-
return 'age = 2x ttl -> running; age = 2x ttl + 1 -> stale; fresh heartbeat beats dead pid';
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
// L5 — dead needs old heartbeat or a definitively missing same-host pid.
|
|
455
|
-
runCase('L5', 'dead 判定:>4x ttl 或同 host 死 pid;新心跳压过死 pid;跨 host 不探 pid', () => {
|
|
456
|
-
const root = newRoot(TMP, 'l5');
|
|
457
|
-
assert(pidAlive(FAKE_PID) === false, `fixture pid ${FAKE_PID} unexpectedly exists`);
|
|
458
|
-
// Eight ttl of silence: dead without any pid evidence.
|
|
459
|
-
putRow(root, makeRecord({
|
|
460
|
-
sessionId: 's-dead',
|
|
461
|
-
pid: FAKE_PID,
|
|
462
|
-
host: HOST,
|
|
463
|
-
heartbeatAt: iso(NOW - 500000),
|
|
464
|
-
updatedAt: iso(NOW - 500000),
|
|
465
|
-
}));
|
|
466
|
-
// 1.17 ttl of silence, same host, pid definitively gone: pid accelerates it.
|
|
467
|
-
putRow(root, makeRecord({
|
|
468
|
-
sessionId: 's-pid-dead',
|
|
469
|
-
pid: FAKE_PID,
|
|
470
|
-
host: HOST,
|
|
471
|
-
heartbeatAt: iso(NOW - 70000),
|
|
472
|
-
updatedAt: iso(NOW - 70000),
|
|
473
|
-
}));
|
|
474
|
-
putRow(root, makeRecord({ sessionId: 's-fresh-dead-pid', pid: FAKE_PID, host: HOST }));
|
|
475
|
-
putRow(root, makeRecord({ sessionId: 's-alive', pid: process.pid, host: HOST }));
|
|
476
|
-
putRow(root, makeRecord({
|
|
477
|
-
sessionId: 's-remote-dead',
|
|
478
|
-
pid: FAKE_PID,
|
|
479
|
-
host: REMOTE_HOST,
|
|
480
|
-
heartbeatAt: iso(NOW - 500000),
|
|
481
|
-
updatedAt: iso(NOW - 500000),
|
|
482
|
-
}));
|
|
483
|
-
|
|
484
|
-
const hidden = liveList({}, { memoryRoot: root, now: NOW });
|
|
485
|
-
assert(!hidden.entries.some((entry) => entry.sessionId === 's-dead'), 'dead row hidden by default');
|
|
486
|
-
assert(hidden.hiddenDead === 3, `hiddenDead 3, got ${hidden.hiddenDead}`);
|
|
487
|
-
assert(hidden.counts.dead === 3, `counts.dead 3, got ${hidden.counts.dead}`);
|
|
488
|
-
assert(hidden.entries.some((entry) => entry.sessionId === 's-fresh-dead-pid'), 'fresh heartbeat row stays visible');
|
|
489
|
-
|
|
490
|
-
const all = liveList({ includeDead: true }, { memoryRoot: root, now: NOW });
|
|
491
|
-
const byId = Object.fromEntries(all.entries.map((entry) => [entry.sessionId, entry]));
|
|
492
|
-
assert(byId['s-dead'].effectiveState === 'dead', '>4x ttl -> dead');
|
|
493
|
-
assert(byId['s-dead'].sameHost === true && byId['s-dead'].pidAlive === false, 'dead row liveness flags');
|
|
494
|
-
assert(byId['s-pid-dead'].effectiveState === 'dead', 'same host + missing pid -> dead before 4x ttl');
|
|
495
|
-
assert(byId['s-fresh-dead-pid'].effectiveState === 'running', 'fresh heartbeat must not be overruled by pid');
|
|
496
|
-
assert(byId['s-remote-dead'].effectiveState === 'dead', 'remote row ages by heartbeat alone');
|
|
497
|
-
assert(byId['s-remote-dead'].sameHost === false && byId['s-remote-dead'].pidAlive === null, 'remote pid must not be probed');
|
|
498
|
-
|
|
499
|
-
const got = liveGet({ sessionId: 's-dead' }, { memoryRoot: root, now: NOW });
|
|
500
|
-
assert(got.live.effectiveState === 'dead', 'get dead');
|
|
501
|
-
return 'dead by 4x ttl or same-host dead pid; fresh heartbeat wins; remote pid never probed';
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
// L6 — corrupt rows are listed, named, and never break the list.
|
|
505
|
-
runCase('L6', 'corrupt 文件不炸列表:坏 JSON 与 sessionId 不符都列为 corrupt', () => {
|
|
506
|
-
const root = newRoot(TMP, 'l6');
|
|
507
|
-
liveUpdate({ sessionId: 's-good', state: 'idle' }, { memoryRoot: root, now: NOW });
|
|
508
|
-
const dir = liveDirOf(root);
|
|
509
|
-
fs.writeFileSync(path.join(dir, 's-bad-json.json'), '{"schemaVersion": 1, "sessionId": "s-bad-json"');
|
|
510
|
-
fs.writeFileSync(
|
|
511
|
-
path.join(dir, 's-wrong-id.json'),
|
|
512
|
-
`${JSON.stringify(makeRecord({ sessionId: 'someone-else' }))}\n`,
|
|
513
|
-
);
|
|
514
|
-
fs.writeFileSync(path.join(dir, 's-orphan.tmp-123'), 'not a record');
|
|
515
|
-
|
|
516
|
-
const listed = liveList({}, { memoryRoot: root, now: NOW });
|
|
517
|
-
assert(listed.ok === true, 'list must not throw on corrupt rows');
|
|
518
|
-
assert(listed.count === 3, `3 rows (1 good + 2 corrupt), got ${listed.count}`);
|
|
519
|
-
assert(listed.counts.corrupt === 2, `counts.corrupt 2, got ${listed.counts.corrupt}`);
|
|
520
|
-
for (const id of ['s-bad-json', 's-wrong-id']) {
|
|
521
|
-
const entry = listed.entries.find((item) => item.sessionId === id);
|
|
522
|
-
assert(entry, `${id} must be listed`);
|
|
523
|
-
assert(entry.effectiveState === 'corrupt', `${id} effectiveState corrupt`);
|
|
524
|
-
assert(typeof entry.corruptReason === 'string' && entry.corruptReason.length > 0, `${id} corruptReason`);
|
|
525
|
-
assert(
|
|
526
|
-
entry.pid === null && entry.state === null && entry.last_action === null
|
|
527
|
-
&& Array.isArray(entry.queued) && entry.queued.length === 0,
|
|
528
|
-
`${id} corrupt row shape`,
|
|
529
|
-
);
|
|
530
|
-
}
|
|
531
|
-
assert(!listed.entries.some((entry) => entry.sessionId.includes('orphan')), 'tmp file is not a row');
|
|
532
|
-
|
|
533
|
-
const error = expectCode(
|
|
534
|
-
() => liveGet({ sessionId: 's-bad-json' }, { memoryRoot: root, now: NOW }),
|
|
535
|
-
'E_LIVE_CORRUPT',
|
|
536
|
-
);
|
|
537
|
-
assert(error.message.includes('s-bad-json'), `get error names the row: ${error.message}`);
|
|
538
|
-
return '2 corrupt rows listed (bad JSON + wrong sessionId); tmp ignored';
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
// L7 — prune: dry-run by default, only dead/corrupt, never cross-host.
|
|
542
|
-
runCase('L7', 'prune:默认 dry-run 零删除;confirm 只删 dead/corrupt,跨 host 与活 pid 的 stale 不动', () => {
|
|
543
|
-
const root = newRoot(TMP, 'l7');
|
|
544
|
-
const records = [
|
|
545
|
-
// Eight ttl of silence: dead by heartbeat (pid evidence unnecessary).
|
|
546
|
-
makeRecord({
|
|
547
|
-
sessionId: 's-dead',
|
|
548
|
-
pid: FAKE_PID,
|
|
549
|
-
host: HOST,
|
|
550
|
-
heartbeatAt: iso(NOW - 500000),
|
|
551
|
-
updatedAt: iso(NOW - 500000),
|
|
552
|
-
}),
|
|
553
|
-
makeRecord({ sessionId: 's-live', pid: process.pid, host: HOST }),
|
|
554
|
-
// 2.5 ttl: stale, but the same-host pid is alive, so it must be kept.
|
|
555
|
-
makeRecord({
|
|
556
|
-
sessionId: 's-stale-alive',
|
|
557
|
-
pid: process.pid,
|
|
558
|
-
host: HOST,
|
|
559
|
-
heartbeatAt: iso(NOW - 150000),
|
|
560
|
-
updatedAt: iso(NOW - 150000),
|
|
561
|
-
}),
|
|
562
|
-
makeRecord({ sessionId: 's-remote', pid: FAKE_PID, host: REMOTE_HOST }),
|
|
563
|
-
// Cross-host stale: heartbeat says stale, never dead, and never pruned.
|
|
564
|
-
makeRecord({
|
|
565
|
-
sessionId: 's-remote-stale',
|
|
566
|
-
pid: FAKE_PID,
|
|
567
|
-
host: REMOTE_HOST,
|
|
568
|
-
heartbeatAt: iso(NOW - 150000),
|
|
569
|
-
updatedAt: iso(NOW - 150000),
|
|
570
|
-
}),
|
|
571
|
-
];
|
|
572
|
-
for (const record of records) putRow(root, record);
|
|
573
|
-
const dir = liveDirOf(root);
|
|
574
|
-
const corruptFile = path.join(dir, 's-corrupt.json');
|
|
575
|
-
fs.writeFileSync(corruptFile, 'half a json {');
|
|
576
|
-
|
|
577
|
-
const before = snapshotFiles(dir);
|
|
578
|
-
assert(Object.keys(before).length === 6, '6 fixture files');
|
|
579
|
-
|
|
580
|
-
const preview = livePrune({}, { memoryRoot: root, now: NOW });
|
|
581
|
-
assert(preview.ok === true && preview.status === 'dry-run' && preview.dryRun === true, 'default is dry-run');
|
|
582
|
-
assert(preview.deleted.length === 0, 'dry-run deletes nothing');
|
|
583
|
-
assert(preview.wouldDelete.length === 2, `wouldDelete 2, got ${preview.wouldDelete.length}`);
|
|
584
|
-
const candidateIds = preview.candidates.map((item) => item.sessionId).sort();
|
|
585
|
-
assert(
|
|
586
|
-
JSON.stringify(candidateIds) === JSON.stringify(['s-corrupt', 's-dead']),
|
|
587
|
-
`candidates must be dead + corrupt, got ${JSON.stringify(candidateIds)}`,
|
|
588
|
-
);
|
|
589
|
-
assert(preview.kept.length === 4, 'dry-run kept 4');
|
|
590
|
-
assert(
|
|
591
|
-
JSON.stringify(snapshotFiles(dir)) === JSON.stringify(before),
|
|
592
|
-
'dry-run must not change a single byte',
|
|
593
|
-
);
|
|
594
|
-
|
|
595
|
-
expectCode(
|
|
596
|
-
() => livePrune({ dryRun: false }, { memoryRoot: root, now: NOW }),
|
|
597
|
-
'E_CONFIRM_REQUIRED',
|
|
598
|
-
);
|
|
599
|
-
assert(
|
|
600
|
-
JSON.stringify(snapshotFiles(dir)) === JSON.stringify(before),
|
|
601
|
-
'refused prune must not delete anything',
|
|
602
|
-
);
|
|
603
|
-
|
|
604
|
-
const real = livePrune({ confirm: LIVE_CONFIRM }, { memoryRoot: root, now: NOW });
|
|
605
|
-
assert(real.ok === true && real.status === 'pruned' && real.dryRun === false, 'confirmed prune runs');
|
|
606
|
-
assert(
|
|
607
|
-
JSON.stringify(real.deleted.map((item) => item.sessionId).sort())
|
|
608
|
-
=== JSON.stringify(['s-corrupt', 's-dead']),
|
|
609
|
-
'confirmed prune deletes exactly dead + corrupt',
|
|
610
|
-
);
|
|
611
|
-
assert(real.failed.length === 0, 'no failed deletes');
|
|
612
|
-
assert(!fs.existsSync(path.join(dir, 's-dead.json')), 'dead row removed');
|
|
613
|
-
assert(!fs.existsSync(corruptFile), 'corrupt row removed');
|
|
614
|
-
|
|
615
|
-
const after = snapshotFiles(dir);
|
|
616
|
-
for (const name of [
|
|
617
|
-
's-live.json',
|
|
618
|
-
's-stale-alive.json', // same host but pid alive: never deleted
|
|
619
|
-
's-remote.json', // cross-host fresh: never touched
|
|
620
|
-
's-remote-stale.json', // cross-host stale: never touched
|
|
621
|
-
]) {
|
|
622
|
-
assert(after[name] === before[name], `${name} must be byte-identical`);
|
|
623
|
-
}
|
|
624
|
-
assert(!('s-dead.json' in after) && !('s-corrupt.json' in after), 'only the two candidates are gone');
|
|
625
|
-
|
|
626
|
-
const again = livePrune({ confirm: LIVE_CONFIRM }, { memoryRoot: root, now: NOW });
|
|
627
|
-
assert(again.candidates.length === 0 && again.deleted.length === 0, 'second prune is a no-op');
|
|
628
|
-
|
|
629
|
-
const confirmedPreview = livePrune({ confirm: LIVE_CONFIRM, dryRun: true }, { memoryRoot: root, now: NOW });
|
|
630
|
-
assert(confirmedPreview.status === 'dry-run' && confirmedPreview.deleted.length === 0, 'dryRun:true wins over confirm');
|
|
631
|
-
return 'dry-run kept 6 files; prune removed exactly dead + corrupt; 4 kept byte-identical';
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
// L8 — strict mode keeps the "no root, no registry" contract; the derived
|
|
635
|
-
// default itself is covered by the portability suite.
|
|
636
|
-
runCase('L8', 'strictMemoryRoot:四工具全拒 E_MEMORY_ROOT_UNSET;null/字符串/数组 E_BAD_ARGUMENT', () => {
|
|
637
|
-
const strict = { strictMemoryRoot: true };
|
|
638
|
-
const calls = [
|
|
639
|
-
['update', () => liveUpdate({ sessionId: 's', state: 'running' }, strict)],
|
|
640
|
-
['list', () => liveList({}, strict)],
|
|
641
|
-
['get', () => liveGet({ sessionId: 's' }, strict)],
|
|
642
|
-
['prune', () => livePrune({}, strict)],
|
|
643
|
-
];
|
|
644
|
-
for (const [name, call] of calls) {
|
|
645
|
-
const error = expectCode(call, 'E_MEMORY_ROOT_UNSET');
|
|
646
|
-
assert(error.message.includes('memoryRoot'), `${name}: message must name memoryRoot: ${error.message}`);
|
|
647
|
-
}
|
|
648
|
-
expectCode(() => liveList({}, { memoryRoot: ' ', strictMemoryRoot: true }), 'E_MEMORY_ROOT_UNSET');
|
|
649
|
-
expectCode(
|
|
650
|
-
() => liveList({}, { memoryRoot: path.join(TMP, 'does-not-exist-live') }),
|
|
651
|
-
'E_MEMORY_ROOT_MISSING',
|
|
652
|
-
);
|
|
653
|
-
|
|
654
|
-
for (const badArgs of [null, '{"state":"running"}', ['running'], 42]) {
|
|
655
|
-
expectCode(() => liveUpdate(badArgs, strict), 'E_BAD_ARGUMENT');
|
|
656
|
-
expectCode(() => liveList(badArgs, strict), 'E_BAD_ARGUMENT');
|
|
657
|
-
expectCode(() => liveGet(badArgs, strict), 'E_BAD_ARGUMENT');
|
|
658
|
-
expectCode(() => livePrune(badArgs, strict), 'E_BAD_ARGUMENT');
|
|
659
|
-
}
|
|
660
|
-
expectCode(() => liveUpdate({ state: 'running' }, null), 'E_BAD_ARGUMENT');
|
|
661
|
-
expectCode(() => liveUpdate({ state: 'running' }, []), 'E_BAD_ARGUMENT');
|
|
662
|
-
// state is required and must be one of the five writer-declared states.
|
|
663
|
-
expectCode(() => liveUpdate({ sessionId: 's' }, { memoryRoot: TMP }), 'E_BAD_ARGUMENT');
|
|
664
|
-
expectCode(() => liveUpdate({ sessionId: 's', state: 'dead' }, { memoryRoot: TMP }), 'E_BAD_ARGUMENT');
|
|
665
|
-
expectCode(() => liveUpdate({ sessionId: 's', state: 'corrupt' }, { memoryRoot: TMP }), 'E_BAD_ARGUMENT');
|
|
666
|
-
return '4 tools refuse unset memoryRoot; null/string/array args rejected';
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
// L9 — an id that could be a path is refused, and nothing is created.
|
|
670
|
-
runCase('L9', '路径越界:../、绝对路径、反斜杠一律 E_PATH_OUTSIDE_ROOT,不建目录', () => {
|
|
671
|
-
const root = newRoot(TMP, 'l9');
|
|
672
|
-
const escapeIds = ['../evil', '..\\evil', '../../etc/passwd', 'C:\\evil', '/evil', 'a/b', 'a\\b', '..'];
|
|
673
|
-
for (const sessionId of escapeIds) {
|
|
674
|
-
expectCode(
|
|
675
|
-
() => liveUpdate({ sessionId, state: 'running' }, { memoryRoot: root }),
|
|
676
|
-
'E_PATH_OUTSIDE_ROOT',
|
|
677
|
-
);
|
|
678
|
-
expectCode(
|
|
679
|
-
() => liveGet({ sessionId }, { memoryRoot: root }),
|
|
680
|
-
'E_PATH_OUTSIDE_ROOT',
|
|
681
|
-
);
|
|
682
|
-
}
|
|
683
|
-
assert(!fs.existsSync(path.join(root, '.longlive')), 'refused ids must not create the registry dir');
|
|
684
|
-
assert(!fs.existsSync(path.join(root, '..', 'evil.json')), 'no file may appear above the root');
|
|
685
|
-
|
|
686
|
-
for (const sessionId of ['', ' ', 'bad id', 'x'.repeat(129), 'evil%2f..', '.hidden']) {
|
|
687
|
-
expectCode(
|
|
688
|
-
() => liveUpdate({ sessionId, state: 'running' }, { memoryRoot: root }),
|
|
689
|
-
'E_BAD_ARGUMENT',
|
|
690
|
-
);
|
|
691
|
-
}
|
|
692
|
-
assert(!fs.existsSync(path.join(root, '.longlive')), 'malformed ids must not create the registry dir either');
|
|
693
|
-
|
|
694
|
-
const accepted = liveUpdate({ sessionId: 's.9_ok-1', state: 'idle' }, { memoryRoot: root });
|
|
695
|
-
assert(accepted.ok === true && fs.existsSync(liveFile(root, 's.9_ok-1')), 'a legal dotted id is accepted');
|
|
696
|
-
return '8 path-like ids refused, 6 malformed ids refused, legal dotted id accepted';
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
// L10 — B2: prune re-reads and re-judges every candidate before unlinking.
|
|
700
|
-
runCase('L10', 'B2 prune 删除前重读:blocked_on 不静默丢弃,字节变化则 skipped', () => {
|
|
701
|
-
const root = newRoot(TMP, 'l10');
|
|
702
|
-
putRow(root, makeRecord({
|
|
703
|
-
sessionId: 's-dead',
|
|
704
|
-
pid: FAKE_PID,
|
|
705
|
-
host: HOST,
|
|
706
|
-
startedAt: iso(NOW - 900000),
|
|
707
|
-
queued: ['deleted-queue-1', 'deleted-queue-2'],
|
|
708
|
-
heartbeatAt: iso(NOW - 500000),
|
|
709
|
-
updatedAt: iso(NOW - 500000),
|
|
710
|
-
}));
|
|
711
|
-
putRow(root, makeRecord({
|
|
712
|
-
sessionId: 's-blocked-dead',
|
|
713
|
-
pid: FAKE_PID,
|
|
714
|
-
host: HOST,
|
|
715
|
-
state: 'blocked',
|
|
716
|
-
blocked_on: 'waiting for approval',
|
|
717
|
-
startedAt: iso(NOW - 800000),
|
|
718
|
-
queued: ['blocked-queue'],
|
|
719
|
-
heartbeatAt: iso(NOW - 500000),
|
|
720
|
-
updatedAt: iso(NOW - 500000),
|
|
721
|
-
}));
|
|
722
|
-
|
|
723
|
-
const real = livePrune({ confirm: LIVE_CONFIRM }, { memoryRoot: root, now: NOW });
|
|
724
|
-
assert(
|
|
725
|
-
JSON.stringify(real.deleted.map((item) => item.sessionId)) === JSON.stringify(['s-dead']),
|
|
726
|
-
`only the unprotected dead row may go, got ${JSON.stringify(real.deleted.map((item) => item.sessionId))}`,
|
|
727
|
-
);
|
|
728
|
-
assert(
|
|
729
|
-
real.skipped.length === 1
|
|
730
|
-
&& real.skipped[0].sessionId === 's-blocked-dead'
|
|
731
|
-
&& real.skipped[0].reason.includes('blocked_on'),
|
|
732
|
-
`blocked_on row must be skipped and visible, got ${JSON.stringify(real.skipped)}`,
|
|
733
|
-
);
|
|
734
|
-
assert(
|
|
735
|
-
JSON.stringify(real.skipped[0].queued) === JSON.stringify(['blocked-queue'])
|
|
736
|
-
&& real.skipped[0].startedAt === iso(NOW - 800000),
|
|
737
|
-
'skipped row must keep queued/startedAt in the result',
|
|
738
|
-
);
|
|
739
|
-
assert(
|
|
740
|
-
JSON.stringify(real.deleted[0].queued) === JSON.stringify(['deleted-queue-1', 'deleted-queue-2'])
|
|
741
|
-
&& real.deleted[0].startedAt === iso(NOW - 900000),
|
|
742
|
-
'deleted row must report the queued/startedAt it removed',
|
|
743
|
-
);
|
|
744
|
-
assert(fs.existsSync(liveFile(root, 's-blocked-dead')), 'blocked_on row must still exist after prune');
|
|
745
|
-
|
|
746
|
-
// Race: the row is dead at scan time, then becomes running before unlink.
|
|
747
|
-
const racedRoot = newRoot(TMP, 'l10-race');
|
|
748
|
-
putRow(racedRoot, makeRecord({
|
|
749
|
-
sessionId: 's-race',
|
|
750
|
-
pid: FAKE_PID,
|
|
751
|
-
host: HOST,
|
|
752
|
-
heartbeatAt: iso(NOW - 500000),
|
|
753
|
-
updatedAt: iso(NOW - 500000),
|
|
754
|
-
}));
|
|
755
|
-
const target = liveFile(racedRoot, 's-race');
|
|
756
|
-
const realRead = fs.readFileSync;
|
|
757
|
-
let reads = 0;
|
|
758
|
-
withFsPatch({
|
|
759
|
-
readFileSync: (file, ...rest) => {
|
|
760
|
-
if (path.resolve(String(file)) === path.resolve(target)) {
|
|
761
|
-
reads += 1;
|
|
762
|
-
if (reads === 2) {
|
|
763
|
-
const row = JSON.parse(realRead(target, 'utf8'));
|
|
764
|
-
row.heartbeatAt = iso(NOW);
|
|
765
|
-
row.updatedAt = iso(NOW);
|
|
766
|
-
row.pid = process.pid;
|
|
767
|
-
row.goal = 'changed after scan';
|
|
768
|
-
return `${JSON.stringify(row, null, 2)}\n`;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
return realRead(file, ...rest);
|
|
772
|
-
},
|
|
773
|
-
}, () => {
|
|
774
|
-
const raced = livePrune({ confirm: LIVE_CONFIRM }, { memoryRoot: racedRoot, now: NOW });
|
|
775
|
-
assert(raced.deleted.length === 0, 'a row that changed after scanning must not be deleted');
|
|
776
|
-
assert(
|
|
777
|
-
raced.skipped.some((item) => item.sessionId === 's-race' && item.reason.includes('变化')),
|
|
778
|
-
`changed row must be reported as skipped, got ${JSON.stringify(raced.skipped)}`,
|
|
779
|
-
);
|
|
780
|
-
});
|
|
781
|
-
assert(fs.existsSync(target), 'race row must survive');
|
|
782
|
-
return 'blocked_on skipped visibly; changed-after-scan row skipped';
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
// L11 — M-a: one session, one row; readers must name the canonical spelling.
|
|
786
|
-
runCase('L11', 'M-a 大小写别名:写统一小写;get 用别名报 E_SESSION_ID_NOT_CANONICAL 并给出 canonical', () => {
|
|
787
|
-
const root = newRoot(TMP, 'l11');
|
|
788
|
-
const first = liveUpdate({ sessionId: 'S-Ali', state: 'idle' }, { memoryRoot: root, now: NOW });
|
|
789
|
-
assert(first.sessionId === 's-ali', `sessionId must be canonical lowercase, got ${first.sessionId}`);
|
|
790
|
-
const dir = liveDirOf(root);
|
|
791
|
-
assert(
|
|
792
|
-
JSON.stringify(fs.readdirSync(dir)) === JSON.stringify(['s-ali.json']),
|
|
793
|
-
`one lowercase file, got ${JSON.stringify(fs.readdirSync(dir))}`,
|
|
794
|
-
);
|
|
795
|
-
for (const alias of ['S-ALI', 's-Ali']) {
|
|
796
|
-
const error = expectCode(
|
|
797
|
-
() => liveGet({ sessionId: alias }, { memoryRoot: root, now: NOW }),
|
|
798
|
-
'E_SESSION_ID_NOT_CANONICAL',
|
|
799
|
-
);
|
|
800
|
-
assert(error.canonical === 's-ali', `alias ${alias}: canonical must be s-ali, got ${error.canonical}`);
|
|
801
|
-
assert(error.message.includes('规范'), `alias ${alias}: message must explain canonical form`);
|
|
802
|
-
}
|
|
803
|
-
const got = liveGet({ sessionId: 's-ali' }, { memoryRoot: root, now: NOW });
|
|
804
|
-
assert(got.ok === true && got.sessionId === 's-ali', 'canonical id reads the row');
|
|
805
|
-
assert(liveList({}, { memoryRoot: root, now: NOW }).scanned === 1, 'aliases are one row, not several');
|
|
806
|
-
|
|
807
|
-
// A row written before canonicalisation may still carry mixed case on disk.
|
|
808
|
-
putRow(root, makeRecord({ sessionId: 'S-Legacy' }));
|
|
809
|
-
const legacy = liveGet({ sessionId: 's-legacy' }, { memoryRoot: root, now: NOW });
|
|
810
|
-
assert(legacy.ok === true && legacy.live.corrupt === false, 'mixed-case legacy row is the same session, not corrupt');
|
|
811
|
-
const legacyAlias = expectCode(
|
|
812
|
-
() => liveGet({ sessionId: 'S-Legacy' }, { memoryRoot: root, now: NOW }),
|
|
813
|
-
'E_SESSION_ID_NOT_CANONICAL',
|
|
814
|
-
);
|
|
815
|
-
assert(legacyAlias.canonical === 's-legacy', 'legacy alias reports its canonical id');
|
|
816
|
-
return 'S-Ali -> s-ali.json; aliases refused with canonical; legacy mixed-case row readable by canonical id';
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
// L12 — B3: lexical escape is not enough; realpath is the guard. A junction
|
|
820
|
-
// needs no privilege and always runs; the symlinked-row half needs a real
|
|
821
|
-
// file symlink, so it is a separate case that can honestly SKIP.
|
|
822
|
-
runCase('L12', 'B3 realpath:live 目录 junction 指根外 -> E_PATH_OUTSIDE_ROOT', () => {
|
|
823
|
-
const root = newRoot(TMP, 'l12');
|
|
824
|
-
const outside = path.join(TMP, SUITE, 'l12-outside');
|
|
825
|
-
fs.mkdirSync(outside, { recursive: true });
|
|
826
|
-
const live = path.join(root, '.longlive', 'live');
|
|
827
|
-
fs.mkdirSync(path.dirname(live), { recursive: true });
|
|
828
|
-
fs.symlinkSync(outside, live, 'junction');
|
|
829
|
-
const calls = [
|
|
830
|
-
['list', () => liveList({}, { memoryRoot: root })],
|
|
831
|
-
['update', () => liveUpdate({ sessionId: 's', state: 'idle' }, { memoryRoot: root })],
|
|
832
|
-
['get', () => liveGet({ sessionId: 's' }, { memoryRoot: root })],
|
|
833
|
-
['prune', () => livePrune({}, { memoryRoot: root })],
|
|
834
|
-
];
|
|
835
|
-
for (const [name, call] of calls) {
|
|
836
|
-
const error = expectCode(call, 'E_PATH_OUTSIDE_ROOT');
|
|
837
|
-
assert(error.message.includes('真实路径'), `${name}: message must say the real path escaped`);
|
|
838
|
-
}
|
|
839
|
-
assert(fs.readdirSync(outside).length === 0, 'junction target must stay untouched');
|
|
840
|
-
return 'junction -> 4 tools E_PATH_OUTSIDE_ROOT';
|
|
841
|
-
});
|
|
842
|
-
|
|
843
|
-
runSymlinkCase('L12-link', 'B3 realpath:符号链接行被拒且只报告,不删除(需符号链接权限)', () => {
|
|
844
|
-
const clean = newRoot(TMP, 'l12-file');
|
|
845
|
-
putRow(clean, makeRecord({ sessionId: 's-real' }));
|
|
846
|
-
const dir = liveDirOf(clean);
|
|
847
|
-
const link = path.join(dir, 's-link.json');
|
|
848
|
-
fs.symlinkSync(path.join(dir, 's-real.json'), link, 'file');
|
|
849
|
-
expectCode(() => liveGet({ sessionId: 's-link' }, { memoryRoot: clean, now: NOW }), 'E_LIVE_CORRUPT');
|
|
850
|
-
|
|
851
|
-
const preview = livePrune({}, { memoryRoot: clean, now: NOW });
|
|
852
|
-
assert(
|
|
853
|
-
preview.placeholders.some((item) => item.kind === 'symlink' && item.fileRel.endsWith('s-link.json')),
|
|
854
|
-
'prune must report the symlinked row',
|
|
855
|
-
);
|
|
856
|
-
const real = livePrune({ confirm: LIVE_CONFIRM }, { memoryRoot: clean, now: NOW });
|
|
857
|
-
assert(real.deleted.length === 0, 'a symlink is never a row candidate');
|
|
858
|
-
assert(fs.existsSync(link) && fs.existsSync(path.join(dir, 's-real.json')), 'symlink and its target survive');
|
|
859
|
-
return 'symlinked row refused and only reported; never deleted';
|
|
860
|
-
});
|
|
861
|
-
|
|
862
|
-
// L13 — M-b/M-c: orphan tmp files and empty directory placeholders.
|
|
863
|
-
runCase('L13', 'M-b/M-c prune:陈旧 tmp 与空目录 confirm 后清理;新 tmp/非空目录只报告', () => {
|
|
864
|
-
const root = newRoot(TMP, 'l13');
|
|
865
|
-
const dir = liveDirOf(root);
|
|
866
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
867
|
-
putRow(root, makeRecord({ sessionId: 's-alive', pid: process.pid }));
|
|
868
|
-
|
|
869
|
-
const oldTmp = path.join(dir, 's-old.json.tmp-999-1-aaaa');
|
|
870
|
-
fs.writeFileSync(oldTmp, 'half a row');
|
|
871
|
-
fs.utimesSync(oldTmp, new Date(NOW - 7200000), new Date(NOW - 7200000));
|
|
872
|
-
const freshTmp = path.join(dir, 's-fresh.json.tmp-999-2-bbbb');
|
|
873
|
-
fs.writeFileSync(freshTmp, 'still writing');
|
|
874
|
-
fs.utimesSync(freshTmp, new Date(NOW - 60000), new Date(NOW - 60000));
|
|
875
|
-
const emptyDir = path.join(dir, 's-empty.json');
|
|
876
|
-
fs.mkdirSync(emptyDir);
|
|
877
|
-
const nonEmptyDir = path.join(dir, 's-nonempty.json');
|
|
878
|
-
fs.mkdirSync(nonEmptyDir);
|
|
879
|
-
fs.writeFileSync(path.join(nonEmptyDir, 'child'), 'x');
|
|
880
|
-
|
|
881
|
-
const preview = livePrune({}, { memoryRoot: root, now: NOW });
|
|
882
|
-
assert(preview.tmpFiles.length === 2, `two tmp files reported, got ${preview.tmpFiles.length}`);
|
|
883
|
-
assert(preview.placeholders.length === 2, `two directory placeholders reported, got ${preview.placeholders.length}`);
|
|
884
|
-
assert(preview.wouldDelete.length === 2, `old tmp + empty dir are deletable, got ${preview.wouldDelete.length}`);
|
|
885
|
-
assert(fs.existsSync(oldTmp) && fs.existsSync(emptyDir), 'dry-run touches nothing');
|
|
886
|
-
|
|
887
|
-
const real = livePrune({ confirm: LIVE_CONFIRM }, { memoryRoot: root, now: NOW });
|
|
888
|
-
assert(!fs.existsSync(oldTmp), 'old orphan tmp removed');
|
|
889
|
-
assert(fs.existsSync(freshTmp), 'fresh tmp kept (may belong to a live writer)');
|
|
890
|
-
assert(!fs.existsSync(emptyDir), 'empty directory placeholder removed');
|
|
891
|
-
assert(fs.existsSync(path.join(nonEmptyDir, 'child')), 'non-empty directory is reported, never recursively deleted');
|
|
892
|
-
assert(
|
|
893
|
-
real.tmpFiles.some((item) => item.fileRel.endsWith('s-old.json.tmp-999-1-aaaa') && item.action === 'deleted'),
|
|
894
|
-
'deletion is visible in the result',
|
|
895
|
-
);
|
|
896
|
-
assert(
|
|
897
|
-
real.placeholders.some((item) => item.fileRel.endsWith('s-nonempty.json') && item.action === 'report'),
|
|
898
|
-
'non-empty directory is visible as a report',
|
|
899
|
-
);
|
|
900
|
-
return 'old tmp + empty dir removed on confirm; fresh tmp and non-empty dir kept and reported';
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
// L14 — M-d: bounded queue and action detail, with visible truncation flags.
|
|
904
|
-
runCase('L14', 'M-d 上限:queued 200 项、每项 300 字,last_action.detail 500 字,结果明示截断', () => {
|
|
905
|
-
const root = newRoot(TMP, 'l14');
|
|
906
|
-
const queued = Array.from({ length: 250 }, (_, index) => `q-${index}-${'y'.repeat(400)}`);
|
|
907
|
-
const written = liveUpdate({
|
|
908
|
-
sessionId: 's-caps',
|
|
909
|
-
state: 'running',
|
|
910
|
-
queued,
|
|
911
|
-
last_action: { at: iso(NOW), kind: 'edit', detail: 'z'.repeat(700) },
|
|
912
|
-
}, { memoryRoot: root, now: NOW });
|
|
913
|
-
assert(written.queuedTruncated === true, 'queued truncation must be flagged');
|
|
914
|
-
assert(written.queuedInput === 250 && written.queuedCount === 200, 'queue input/kept counts');
|
|
915
|
-
assert(written.record.queued.length === 200, `stored queue 200, got ${written.record.queued.length}`);
|
|
916
|
-
assert([...written.record.queued[0]].length === 300, 'each stored queue item is 300 code points');
|
|
917
|
-
assert(written.lastActionDetailTruncated === true, 'detail truncation must be flagged');
|
|
918
|
-
assert(written.lastActionDetailChars === 700, 'original detail length reported');
|
|
919
|
-
assert(written.record.last_action.detail.length === 500, 'stored detail is 500 chars');
|
|
920
|
-
|
|
921
|
-
const stored = JSON.parse(fs.readFileSync(liveFile(root, 's-caps'), 'utf8'));
|
|
922
|
-
assert(stored.queued.length === 200 && stored.last_action.detail.length === 500, 'on-disk row obeys the caps');
|
|
923
|
-
return 'queue 250 -> 200 x 300 chars; detail 700 -> 500; both flagged';
|
|
924
|
-
});
|
|
925
|
-
|
|
926
|
-
// L16 — cross-host corrupt rows: a host stamp is evidence of another owner.
|
|
927
|
-
runCase('L16', '跨 host corrupt:带远端 host 的损坏行不删;host 未知/本机才可清理', () => {
|
|
928
|
-
const root = newRoot(TMP, 'l16');
|
|
929
|
-
const dir = liveDirOf(root);
|
|
930
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
931
|
-
putRow(root, makeRecord({ sessionId: 's-alive', pid: process.pid }));
|
|
932
|
-
|
|
933
|
-
const remoteFile = path.join(dir, 's-corrupt-remote.json');
|
|
934
|
-
fs.writeFileSync(remoteFile, JSON.stringify({
|
|
935
|
-
schemaVersion: LIVE_SCHEMA_VERSION,
|
|
936
|
-
sessionId: 's-corrupt-remote',
|
|
937
|
-
host: REMOTE_HOST,
|
|
938
|
-
heartbeatAt: iso(NOW),
|
|
939
|
-
// state/pid/ttl are missing: this row cannot be decoded.
|
|
940
|
-
}));
|
|
941
|
-
const remoteHalf = path.join(dir, 's-corrupt-remote-half.json');
|
|
942
|
-
fs.writeFileSync(remoteHalf, `{"sessionId":"s-corrupt-remote-half","host":"${REMOTE_HOST}"`);
|
|
943
|
-
const localFile = path.join(dir, 's-corrupt-local.json');
|
|
944
|
-
fs.writeFileSync(localFile, JSON.stringify({
|
|
945
|
-
schemaVersion: LIVE_SCHEMA_VERSION,
|
|
946
|
-
sessionId: 's-corrupt-local',
|
|
947
|
-
host: HOST,
|
|
948
|
-
heartbeatAt: iso(NOW),
|
|
949
|
-
}));
|
|
950
|
-
const hostless = path.join(dir, 's-corrupt-hostless.json');
|
|
951
|
-
fs.writeFileSync(hostless, 'not json at all');
|
|
952
|
-
|
|
953
|
-
const before = snapshotFiles(dir);
|
|
954
|
-
const preview = livePrune({}, { memoryRoot: root, now: NOW });
|
|
955
|
-
const candidateIds = preview.candidates.map((item) => item.sessionId).sort();
|
|
956
|
-
assert(
|
|
957
|
-
JSON.stringify(candidateIds) === JSON.stringify(['s-corrupt-hostless', 's-corrupt-local']),
|
|
958
|
-
`only local/unknown-host corrupt rows are candidates, got ${JSON.stringify(candidateIds)}`,
|
|
959
|
-
);
|
|
960
|
-
assert(
|
|
961
|
-
preview.skipped.filter((item) => item.sessionId.startsWith('s-corrupt-remote')).length === 2,
|
|
962
|
-
'both remote corrupt rows must be reported as skipped',
|
|
963
|
-
);
|
|
964
|
-
assert(
|
|
965
|
-
preview.skipped.every((item) => !item.sessionId.startsWith('s-corrupt-remote') || item.reason.includes('跨 host')),
|
|
966
|
-
'remote corrupt skip must say why',
|
|
967
|
-
);
|
|
968
|
-
assert(JSON.stringify(snapshotFiles(dir)) === JSON.stringify(before), 'dry-run touches nothing');
|
|
969
|
-
|
|
970
|
-
const real = livePrune({ confirm: LIVE_CONFIRM }, { memoryRoot: root, now: NOW });
|
|
971
|
-
assert(
|
|
972
|
-
JSON.stringify(real.deleted.map((item) => item.sessionId).sort())
|
|
973
|
-
=== JSON.stringify(['s-corrupt-hostless', 's-corrupt-local']),
|
|
974
|
-
`deleted exactly local/unknown corrupt rows, got ${JSON.stringify(real.deleted.map((item) => item.sessionId))}`,
|
|
975
|
-
);
|
|
976
|
-
assert(fs.existsSync(remoteFile) && fs.existsSync(remoteHalf), 'remote corrupt rows survive');
|
|
977
|
-
const after = snapshotFiles(dir);
|
|
978
|
-
assert(after['s-corrupt-remote.json'] === before['s-corrupt-remote.json'], 'remote row is byte-identical');
|
|
979
|
-
assert(after['s-corrupt-remote-half.json'] === before['s-corrupt-remote-half.json'], 'remote half row is byte-identical');
|
|
980
|
-
return 'remote corrupt rows protected and reported; local/unknown corrupt rows removed';
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
// L17 — a directory on the row path must fail early and clearly. The
|
|
984
|
-
// directory half needs no privilege; the symlink half is skippable.
|
|
985
|
-
runCase('L17', '目录占位:update 抛 E_LIVE_ROW_BLOCKED;空目录 prune 后写入恢复', () => {
|
|
986
|
-
const root = newRoot(TMP, 'l17');
|
|
987
|
-
const dir = liveDirOf(root);
|
|
988
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
989
|
-
const placeholder = path.join(dir, 's-dir.json');
|
|
990
|
-
fs.mkdirSync(placeholder);
|
|
991
|
-
const error = expectCode(
|
|
992
|
-
() => liveUpdate({ sessionId: 's-dir', state: 'idle' }, { memoryRoot: root, now: NOW }),
|
|
993
|
-
'E_LIVE_ROW_BLOCKED',
|
|
994
|
-
);
|
|
995
|
-
assert(error.message.includes('目录') && error.message.includes(placeholder), 'error must name the directory');
|
|
996
|
-
|
|
997
|
-
const preview = livePrune({}, { memoryRoot: root, now: NOW });
|
|
998
|
-
assert(
|
|
999
|
-
preview.placeholders.some((item) => item.kind === 'directory' && item.fileRel.endsWith('s-dir.json')),
|
|
1000
|
-
'empty directory is reported as a placeholder',
|
|
1001
|
-
);
|
|
1002
|
-
livePrune({ confirm: LIVE_CONFIRM }, { memoryRoot: root, now: NOW });
|
|
1003
|
-
assert(!fs.existsSync(placeholder), 'empty directory placeholder removed');
|
|
1004
|
-
const written = liveUpdate({ sessionId: 's-dir', state: 'idle' }, { memoryRoot: root, now: NOW });
|
|
1005
|
-
assert(written.ok === true && fs.existsSync(liveFile(root, 's-dir')), 'write succeeds after the placeholder is gone');
|
|
1006
|
-
return 'update refused a directory with a named error; prune cleared the empty dir; write recovered';
|
|
1007
|
-
});
|
|
1008
|
-
|
|
1009
|
-
runSymlinkCase('L17-link', '符号链接占位:update 抛 E_LIVE_ROW_BLOCKED 并按名字指出链接(需符号链接权限)', () => {
|
|
1010
|
-
const root = newRoot(TMP, 'l17-link');
|
|
1011
|
-
const dir = liveDirOf(root);
|
|
1012
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1013
|
-
putRow(root, makeRecord({ sessionId: 's-real', pid: process.pid }));
|
|
1014
|
-
const link = path.join(dir, 's-link.json');
|
|
1015
|
-
fs.symlinkSync(path.join(dir, 's-real.json'), link, 'file');
|
|
1016
|
-
const linkError = expectCode(
|
|
1017
|
-
() => liveUpdate({ sessionId: 's-link', state: 'idle' }, { memoryRoot: root, now: NOW }),
|
|
1018
|
-
'E_LIVE_ROW_BLOCKED',
|
|
1019
|
-
);
|
|
1020
|
-
assert(linkError.message.includes('符号链接'), 'symlink row must be refused by name');
|
|
1021
|
-
return 'update refused the symlink row with a named error';
|
|
1022
|
-
});
|
|
1023
|
-
}
|