mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 20:34:30 +00:00
656 lines
24 KiB
TypeScript
656 lines
24 KiB
TypeScript
#!/usr/bin/env npx tsx
|
||
/**
|
||
* seed-v2 — 通过 v2 API 把历史对话灌入 memory-tencentdb gateway。
|
||
*
|
||
* 与老 `src/cli/commands/seed.ts`(v1,import 核心 runtime 直跑)相比:
|
||
* - 纯 HTTP 客户端,不 import 任何 plugin 内部模块
|
||
* - 通过 `/v2/conversation/add` 写 L0 + 让 gateway 自动调度 L1/L2/L3
|
||
* - 通过轮询 `/v2/pipeline/status` 的 `busy` 标量做节流
|
||
* - 既适用于 standalone,也适用于 service 模式(同一份代码)
|
||
*
|
||
* 阻塞节奏(对齐老 `seed-runtime.executeSeed`):
|
||
* - per-round 写入 conversation/add 后立即继续
|
||
* - 每 N 轮(--every-n / SEED_EVERY_N)等一次 busy=false(stable polls)
|
||
* - 每个 session 末尾再等一次
|
||
* - 全部跑完后做 final drain
|
||
*
|
||
* @example
|
||
* # 用 npm script 入口
|
||
* npm run seed-v2 -- --input ./fixtures/minimal.json
|
||
*
|
||
* # 或直接 tsx
|
||
* npx tsx scripts/seed-v2/seed-v2.ts --input fixture.json --endpoint http://127.0.0.1:18420
|
||
*
|
||
* # 关键参数
|
||
* --input <file> 必选,fixture JSON 路径
|
||
* --endpoint <url> gateway 地址(默认 http://127.0.0.1:18420)
|
||
* --service-id <id> x-tdai-service-id(默认 default)
|
||
* --api-key <key> Bearer key(默认 standalone-e2e)
|
||
* --every-n <n> 每 N 轮 wait 一次 busy=false(默认 5)
|
||
* --max-wait-ms <ms> 单次 wait 最长时间(默认 600000=10 分钟)
|
||
* --no-final-wait 写完最后一批不等 cascade,立即退出
|
||
* --dry-run 只打印计划不实际请求
|
||
* --quiet 静默模式
|
||
*/
|
||
|
||
import { readFileSync, existsSync } from "node:fs";
|
||
import { resolve as resolvePath } from "node:path";
|
||
import { parseArgs } from "node:util";
|
||
import dayjs from "dayjs";
|
||
|
||
import { validateAndNormalizeRaw, fillTimestamps, SeedValidationError } from "./input-validate.js";
|
||
import type { NormalizedInput } from "./input-types.js";
|
||
|
||
// ============================
|
||
// Logger (timestamped console wrappers)
|
||
// ============================
|
||
|
||
/**
|
||
* Full ISO 8601 local-timezone timestamp, e.g. "2026-05-21T15:03:00.123+08:00".
|
||
* Aligned with the standalone gateway's console logger so seed-v2 lines and
|
||
* gateway lines can be merged/sorted by timestamp during incident review.
|
||
*/
|
||
function ts(): string {
|
||
return dayjs().format("YYYY-MM-DDTHH:mm:ss.SSSZ");
|
||
}
|
||
|
||
const log = (msg: string): void => { console.log(`${ts()} ${msg}`); };
|
||
const warn = (msg: string): void => { console.warn(`${ts()} ${msg}`); };
|
||
const err = (msg: string): void => { console.error(`${ts()} ${msg}`); };
|
||
|
||
// Truncate long content to a single-line preview safe for logs.
|
||
function preview(s: string, max = 80): string {
|
||
const oneLine = s.replace(/\s+/g, " ").trim();
|
||
return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max)}...`;
|
||
}
|
||
|
||
// ============================
|
||
// CLI args + env fallback
|
||
// ============================
|
||
|
||
interface CliOptions {
|
||
input: string;
|
||
endpoint: string;
|
||
apiKey: string;
|
||
serviceId: string;
|
||
everyN: number;
|
||
pollMs: number;
|
||
stableRounds: number;
|
||
maxWaitMs: number; // per every-N / per-session-tail wait timeout (L1 only)
|
||
finalMaxWaitMs: number; // final all-layer drain wait timeout (L1+L2+L3)
|
||
finalWait: boolean;
|
||
dryRun: boolean;
|
||
quiet: boolean;
|
||
// Input validation / normalization options (parity with old seed CLI)
|
||
sessionKey?: string; // fallback for sessions missing sessionKey
|
||
strictRoundRole: boolean; // each round must have user + assistant
|
||
autoFillTimestamps: boolean; // when all-missing, fill globally-monotonic ts
|
||
}
|
||
|
||
function parseCli(argv: string[]): CliOptions {
|
||
const { values } = parseArgs({
|
||
args: argv,
|
||
options: {
|
||
input: { type: "string", short: "i" },
|
||
endpoint: { type: "string", short: "e" },
|
||
"api-key": { type: "string" },
|
||
"service-id": { type: "string", short: "s" },
|
||
"every-n": { type: "string", short: "n" },
|
||
"poll-ms": { type: "string" },
|
||
"stable-rounds": { type: "string" },
|
||
"max-wait-ms": { type: "string" },
|
||
"final-max-wait-ms": { type: "string" },
|
||
"no-final-wait": { type: "boolean" },
|
||
"dry-run": { type: "boolean" },
|
||
"session-key": { type: "string" },
|
||
"strict-round-role": { type: "boolean" },
|
||
"no-auto-fill-timestamps": { type: "boolean" },
|
||
quiet: { type: "boolean", short: "q" },
|
||
help: { type: "boolean", short: "h" },
|
||
},
|
||
allowPositionals: false,
|
||
strict: false,
|
||
});
|
||
|
||
if (values.help) {
|
||
printHelp();
|
||
process.exit(0);
|
||
}
|
||
|
||
const input = (values.input as string | undefined) ?? process.argv[2];
|
||
if (!input) {
|
||
console.error("error: --input <fixture.json> is required");
|
||
printHelp();
|
||
process.exit(2);
|
||
}
|
||
|
||
const intOpt = (key: string, env: string, def: number): number => {
|
||
const raw = (values[key] as string | undefined) ?? process.env[env];
|
||
if (raw == null || raw === "") return def;
|
||
const n = parseInt(raw, 10);
|
||
return Number.isFinite(n) ? n : def;
|
||
};
|
||
|
||
return {
|
||
input,
|
||
endpoint: (values.endpoint as string) ?? process.env.SEED_ENDPOINT ?? "http://127.0.0.1:18420",
|
||
apiKey: (values["api-key"] as string) ?? process.env.SEED_API_KEY ?? "standalone-e2e",
|
||
serviceId: (values["service-id"] as string) ?? process.env.SEED_SERVICE_ID ?? "default",
|
||
everyN: intOpt("every-n", "SEED_EVERY_N", 5),
|
||
pollMs: intOpt("poll-ms", "SEED_POLL_MS", 500),
|
||
stableRounds: intOpt("stable-rounds", "SEED_STABLE_ROUNDS", 2),
|
||
maxWaitMs: intOpt("max-wait-ms", "SEED_MAX_WAIT_MS", 180_000),
|
||
finalMaxWaitMs: intOpt("final-max-wait-ms", "SEED_FINAL_MAX_WAIT_MS", 600_000),
|
||
finalWait: !values["no-final-wait"],
|
||
dryRun: Boolean(values["dry-run"]),
|
||
quiet: Boolean(values.quiet) || process.env.SEED_VERBOSE === "0",
|
||
sessionKey: (values["session-key"] as string | undefined) ?? process.env.SEED_FALLBACK_SESSION_KEY,
|
||
strictRoundRole: Boolean(values["strict-round-role"]) || process.env.SEED_STRICT_ROUND_ROLE === "1",
|
||
autoFillTimestamps: !values["no-auto-fill-timestamps"] && process.env.SEED_AUTO_FILL_TIMESTAMPS !== "0",
|
||
};
|
||
}
|
||
|
||
function printHelp(): void {
|
||
console.log(`
|
||
seed-v2 — feed historical conversations into memory-tencentdb via v2 API
|
||
|
||
Usage:
|
||
seed-v2 --input <fixture.json> [options]
|
||
|
||
Required:
|
||
-i, --input <file> Fixture JSON file (Format A: { sessions: [...] })
|
||
|
||
Options:
|
||
-e, --endpoint <url> Gateway URL (default: http://127.0.0.1:18420)
|
||
--api-key <key> Bearer key (default: standalone-e2e)
|
||
-s, --service-id <id> x-tdai-service-id (default: default)
|
||
-n, --every-n <n> Wait for busy=false every N rounds per session (default: 5)
|
||
--poll-ms <ms> Status poll interval (default: 500)
|
||
--stable-rounds <n> Consecutive idle polls before considered stable (default: 2)
|
||
--max-wait-ms <ms> Per-batch wait timeout, L1 only (default: 180000 = 3 min)
|
||
--final-max-wait-ms <ms> Final drain wait timeout, ALL layers L1+L2+L3 (default: 600000 = 10 min)
|
||
--no-final-wait Skip final cascade drain wait
|
||
--dry-run Print plan, no requests
|
||
--session-key <key> Fallback sessionKey for sessions missing one
|
||
--strict-round-role Each round must contain user + assistant
|
||
--no-auto-fill-timestamps Don't auto-fill missing timestamps (will error if all missing)
|
||
-q, --quiet Quiet mode (env SEED_VERBOSE=0)
|
||
-h, --help Show this help
|
||
|
||
Fixture JSON (Format A):
|
||
{
|
||
"sessions": [
|
||
{
|
||
"sessionKey": "user-001",
|
||
"sessionId": "user-001",
|
||
"conversations": [
|
||
[
|
||
{ "role": "user", "content": "..." },
|
||
{ "role": "assistant", "content": "..." }
|
||
]
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
Exit codes:
|
||
0 success
|
||
1 seed or wait failed
|
||
2 fixture / config error
|
||
`.trim());
|
||
}
|
||
|
||
// ============================
|
||
// HTTP helper
|
||
// ============================
|
||
|
||
interface ApiResponse<T = unknown> {
|
||
code: number;
|
||
message: string;
|
||
request_id: string;
|
||
data?: T;
|
||
}
|
||
|
||
interface ApiCallResult<T = unknown> {
|
||
body: ApiResponse<T>;
|
||
httpStatus: number;
|
||
durationMs: number;
|
||
}
|
||
|
||
class SeedClient {
|
||
private callSeq = 0;
|
||
constructor(private opts: CliOptions) {}
|
||
|
||
async post<T = unknown>(path: string, body: unknown): Promise<ApiCallResult<T>> {
|
||
this.callSeq++;
|
||
const url = `${this.opts.endpoint}/v2/${path}`;
|
||
const startedAt = Date.now();
|
||
const resp = await fetch(url, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Authorization": `Bearer ${this.opts.apiKey}`,
|
||
"x-tdai-service-id": this.opts.serviceId,
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
const durationMs = Date.now() - startedAt;
|
||
const text = await resp.text();
|
||
let parsed: ApiResponse<T>;
|
||
try {
|
||
parsed = JSON.parse(text) as ApiResponse<T>;
|
||
} catch {
|
||
throw new Error(`non-JSON response from /v2/${path}: ${resp.status} ${text.slice(0, 200)}`);
|
||
}
|
||
return { body: parsed, httpStatus: resp.status, durationMs };
|
||
}
|
||
}
|
||
|
||
// ============================
|
||
// Fixture loading + validation (delegates to input-validate.ts)
|
||
// ============================
|
||
|
||
/**
|
||
* Load fixture from disk → parse JSON → validate → normalize. Returns the
|
||
* NormalizedInput shape used downstream (sessions[].rounds[].messages[]),
|
||
* with timestamps either preserved, mixed (rejected), or auto-filled to
|
||
* globally-monotonic epoch ms (parity with v1 fillTimestamps).
|
||
*
|
||
* On validation failure throws SeedValidationError; caller should print and exit 2.
|
||
*/
|
||
function loadAndValidate(opts: CliOptions): NormalizedInput {
|
||
const abs = resolvePath(opts.input);
|
||
if (!existsSync(abs)) {
|
||
throw new SeedValidationError([{ stage: "file", message: `Fixture not found: ${abs}` }]);
|
||
}
|
||
const text = readFileSync(abs, "utf8").trim();
|
||
if (!text) {
|
||
throw new SeedValidationError([{ stage: "file", message: `Fixture is empty: ${abs}` }]);
|
||
}
|
||
let raw: unknown;
|
||
try {
|
||
raw = JSON.parse(text);
|
||
} catch (err) {
|
||
throw new SeedValidationError([{
|
||
stage: "file",
|
||
message: `JSON parse error: ${err instanceof Error ? err.message : String(err)}`,
|
||
}]);
|
||
}
|
||
return validateAndNormalizeRaw(raw, {
|
||
sessionKey: opts.sessionKey,
|
||
strictRoundRole: opts.strictRoundRole,
|
||
autoFillTimestamps: opts.autoFillTimestamps,
|
||
});
|
||
}
|
||
|
||
// ============================
|
||
// Status polling
|
||
// ============================
|
||
|
||
interface LayerStatus {
|
||
queued: number;
|
||
running: number;
|
||
queued_sessions: string[];
|
||
running_sessions: string[];
|
||
idle: boolean;
|
||
}
|
||
|
||
interface StatusData {
|
||
l1: LayerStatus;
|
||
l2: LayerStatus;
|
||
l3: LayerStatus;
|
||
}
|
||
|
||
async function pollStatus(client: SeedClient): Promise<StatusData> {
|
||
const r = await client.post<StatusData>("pipeline/status", {});
|
||
if (r.body.code !== 0 || !r.body.data) {
|
||
throw new Error(`/v2/pipeline/status failed: HTTP ${r.httpStatus} code=${r.body.code} msg=${r.body.message}`);
|
||
}
|
||
return r.body.data;
|
||
}
|
||
|
||
/**
|
||
* Wait until the requested layers are idle for `stableRounds` consecutive polls.
|
||
*
|
||
* @param waitMode "l1" → only wait for L1 to drain (mid-batch / per-session);
|
||
* L2/L3 cascade may still run in background.
|
||
* "all" → wait for L1+L2+L3 all idle (final drain after all
|
||
* rounds dispatched).
|
||
*
|
||
* On timeout: log a `[wait] done reason=timeout` line and return (do not
|
||
* throw). Data is already on disk and the gateway keeps cooking; callers
|
||
* decide whether the next batch can proceed.
|
||
*
|
||
* Logging:
|
||
* - `[wait] enter` once at the start (label, mode, budget).
|
||
* - `[poll]` per status snapshot, throttled: first poll, OR queue/running
|
||
* signature changed, OR 5s heartbeat. Long stable waits stay quiet,
|
||
* stalls keep showing progress.
|
||
* - `[wait] done` once at exit with reason=stable|timeout, elapsed, polls.
|
||
*/
|
||
async function waitForBusyFalse(
|
||
client: SeedClient,
|
||
opts: CliOptions,
|
||
label: string,
|
||
waitMode: "l1" | "all",
|
||
maxWaitMs: number,
|
||
): Promise<void> {
|
||
const startTime = Date.now();
|
||
let consecutiveIdle = 0;
|
||
let lastSnapshot: StatusData | null = null;
|
||
let lastLogAtMs = 0;
|
||
let pollCount = 0;
|
||
const HEARTBEAT_INTERVAL_MS = 5000;
|
||
|
||
log(`[seed-v2] [wait] enter label="${label}" mode=${waitMode} maxWaitMs=${maxWaitMs} stableRounds=${opts.stableRounds} pollMs=${opts.pollMs}`);
|
||
|
||
// Format a compact one-line snapshot for logging.
|
||
const fmt = (s: StatusData): string => {
|
||
const layer = (name: string, l: LayerStatus) => {
|
||
const extras: string[] = [];
|
||
if (l.running_sessions.length > 0) extras.push(`running=${JSON.stringify(l.running_sessions)}`);
|
||
if (l.queued_sessions.length > 0) extras.push(`queued=${JSON.stringify(l.queued_sessions)}`);
|
||
const tail = extras.length > 0 ? ` ${extras.join(" ")}` : "";
|
||
return `${name}(q=${l.queued} r=${l.running} idle=${l.idle}${tail})`;
|
||
};
|
||
return [layer("L1", s.l1), layer("L2", s.l2), layer("L3", s.l3)].join(" ");
|
||
};
|
||
|
||
// Stable signature for change-detection (only fields that matter for idle).
|
||
const sig = (s: StatusData): string =>
|
||
`${s.l1.queued}/${s.l1.running}|${s.l2.queued}/${s.l2.running}|${s.l3.queued}/${s.l3.running}`;
|
||
let lastSig = "";
|
||
|
||
while (true) {
|
||
const elapsed = Date.now() - startTime;
|
||
if (elapsed > maxWaitMs) {
|
||
const last = lastSnapshot ? fmt(lastSnapshot) : "no-snapshot";
|
||
warn(
|
||
`[seed-v2] [wait] done label="${label}" mode=${waitMode} elapsed=${(elapsed / 1000).toFixed(1)}s ` +
|
||
`polls=${pollCount} reason=timeout last=${last}`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const s = await pollStatus(client);
|
||
pollCount++;
|
||
lastSnapshot = s;
|
||
const isIdle = waitMode === "l1"
|
||
? s.l1.idle
|
||
: (s.l1.idle && s.l2.idle && s.l3.idle);
|
||
|
||
if (isIdle) consecutiveIdle++;
|
||
else consecutiveIdle = 0;
|
||
|
||
// Decide whether to log this poll.
|
||
const now = Date.now();
|
||
const currentSig = sig(s);
|
||
const changed = currentSig !== lastSig;
|
||
const heartbeat = now - lastLogAtMs >= HEARTBEAT_INTERVAL_MS;
|
||
const isFirstPoll = lastLogAtMs === 0;
|
||
const willBeStable = isIdle && consecutiveIdle >= opts.stableRounds;
|
||
|
||
if (!opts.quiet && (isFirstPoll || changed || heartbeat || willBeStable)) {
|
||
const mark = isIdle ? "idle" : "busy";
|
||
const stable = willBeStable ? " ✓ STABLE" : "";
|
||
log(
|
||
`[seed-v2] [poll] ${label} t+${(elapsed / 1000).toFixed(1)}s ${mark} ${fmt(s)} ` +
|
||
`consecutiveIdle=${consecutiveIdle}${stable}`,
|
||
);
|
||
lastLogAtMs = now;
|
||
lastSig = currentSig;
|
||
}
|
||
|
||
if (willBeStable) {
|
||
log(
|
||
`[seed-v2] [wait] done label="${label}" mode=${waitMode} elapsed=${(elapsed / 1000).toFixed(1)}s ` +
|
||
`polls=${pollCount} reason=stable`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
await new Promise((r) => setTimeout(r, opts.pollMs));
|
||
}
|
||
}
|
||
|
||
// ============================
|
||
// Conversation/add per round
|
||
// ============================
|
||
|
||
interface AddRoundContext {
|
||
sessionIndex: number; // 1-based among non-empty sessions
|
||
totalSessions: number;
|
||
sessionKey: string;
|
||
sessionId: string;
|
||
roundIndex: number; // 1-based within this session
|
||
totalRoundsInSession: number;
|
||
cumRounds: number; // 1-based across whole fixture
|
||
totalRounds: number;
|
||
cumMessagesAfter: number; // includes this round (post-success)
|
||
totalMessages: number;
|
||
}
|
||
|
||
async function addRound(
|
||
client: SeedClient,
|
||
ctx: AddRoundContext,
|
||
messages: { role: string; content: string; timestamp: number }[],
|
||
quiet: boolean,
|
||
): Promise<number> {
|
||
// After validateAndNormalizeRaw + fillTimestamps, every message has a positive
|
||
// epoch-ms `timestamp` (or 0 if user explicitly opted out via
|
||
// --no-auto-fill-timestamps). v2 conversation/add schema requires ISO 8601 string.
|
||
const payload = messages.map((m) => ({
|
||
role: m.role,
|
||
content: m.content,
|
||
timestamp: new Date(m.timestamp || Date.now()).toISOString(),
|
||
}));
|
||
|
||
const roles = payload.map((p) => p.role).join(",");
|
||
const firstTs = payload[0]?.timestamp ?? "n/a";
|
||
const lastTs = payload[payload.length - 1]?.timestamp ?? "n/a";
|
||
|
||
if (!quiet) {
|
||
log(
|
||
`[seed-v2] [post] session ${ctx.sessionIndex}/${ctx.totalSessions} round ${ctx.roundIndex}/${ctx.totalRoundsInSession} ` +
|
||
`(${ctx.sessionKey}) → POST /v2/conversation/add session_id="${ctx.sessionId}" ` +
|
||
`msgs=${payload.length} roles=[${roles}] firstTs=${firstTs} lastTs=${lastTs}`,
|
||
);
|
||
for (let i = 0; i < payload.length; i++) {
|
||
const m = payload[i]!;
|
||
log(`[seed-v2] [post] ${m.role}[${i}]: "${preview(m.content)}"`);
|
||
}
|
||
}
|
||
|
||
const r = await client.post<{ accepted_ids: string[]; total_count: number }>("conversation/add", {
|
||
session_id: ctx.sessionId,
|
||
messages: payload,
|
||
});
|
||
|
||
const ok = r.body.code === 0 && r.body.data;
|
||
if (!ok) {
|
||
err(
|
||
`[seed-v2] [post] session ${ctx.sessionIndex}/${ctx.totalSessions} round ${ctx.roundIndex}/${ctx.totalRoundsInSession} ` +
|
||
`(${ctx.sessionKey}) ✗ HTTP ${r.httpStatus} code=${r.body.code} msg="${r.body.message}" in ${r.durationMs}ms`,
|
||
);
|
||
throw new Error(`/v2/conversation/add failed for ${ctx.sessionId}: HTTP ${r.httpStatus} code=${r.body.code} msg=${r.body.message}`);
|
||
}
|
||
|
||
const accepted = r.body.data!.accepted_ids.length;
|
||
if (!quiet) {
|
||
log(
|
||
`[seed-v2] [post] session ${ctx.sessionIndex}/${ctx.totalSessions} round ${ctx.roundIndex}/${ctx.totalRoundsInSession} ` +
|
||
`(${ctx.sessionKey}) ✓ HTTP ${r.httpStatus} code=0 accepted=${accepted} in ${r.durationMs}ms ` +
|
||
`(cum: rounds=${ctx.cumRounds}/${ctx.totalRounds} msgs=${ctx.cumMessagesAfter}/${ctx.totalMessages})`,
|
||
);
|
||
}
|
||
return accepted;
|
||
}
|
||
|
||
// ============================
|
||
// Main
|
||
// ============================
|
||
|
||
async function main(): Promise<void> {
|
||
const opts = parseCli(process.argv.slice(2));
|
||
const client = new SeedClient(opts);
|
||
|
||
log(
|
||
`[seed-v2] endpoint=${opts.endpoint} serviceId=${opts.serviceId} apiKey=${opts.apiKey ? "***" : "(none)"}`,
|
||
);
|
||
log(
|
||
`[seed-v2] cadence: everyN=${opts.everyN} stableRounds=${opts.stableRounds} pollMs=${opts.pollMs} ` +
|
||
`maxWaitMs=${opts.maxWaitMs}(L1-only) finalMaxWaitMs=${opts.finalMaxWaitMs}(all-layers) finalWait=${opts.finalWait}`,
|
||
);
|
||
|
||
// Load + validate + normalize fixture (Layer 2-6 of v1 seed: format detect /
|
||
// session+round+message validation / timestamp consistency check / auto-fill).
|
||
let normalized: NormalizedInput;
|
||
try {
|
||
normalized = loadAndValidate(opts);
|
||
} catch (e) {
|
||
if (e instanceof SeedValidationError) {
|
||
err(`[seed-v2] ${e.message}`);
|
||
} else {
|
||
err(`[seed-v2] fixture load failed: ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
process.exit(2);
|
||
}
|
||
const sessions = normalized.sessions;
|
||
const { totalRounds, totalMessages, hasTimestamps } = normalized;
|
||
log(
|
||
`[seed-v2] fixture=${opts.input} sessions=${sessions.length} rounds=${totalRounds} ` +
|
||
`messages=${totalMessages} hasTimestamps=${hasTimestamps}` +
|
||
(!hasTimestamps && opts.autoFillTimestamps ? " (auto-filled)" : "") +
|
||
` autoFill=${opts.autoFillTimestamps} strictRoundRole=${opts.strictRoundRole}`,
|
||
);
|
||
|
||
// Session manifest preview (first 3 + count).
|
||
const previewSessions = sessions.slice(0, 3)
|
||
.map((s) => `${s.sessionKey}(${s.rounds.length}r)`).join(" ");
|
||
const moreSessions = sessions.length > 3 ? ` ... (+${sessions.length - 3} more)` : "";
|
||
log(`[seed-v2] sessions: ${previewSessions}${moreSessions}`);
|
||
|
||
if (opts.dryRun) {
|
||
log("[seed-v2] DRY RUN — exiting without making any requests");
|
||
for (const s of sessions) {
|
||
log(`[seed-v2] would add: sessionKey=${s.sessionKey} sessionId=${s.sessionId} rounds=${s.rounds.length}`);
|
||
}
|
||
process.exit(0);
|
||
}
|
||
|
||
// Pre-flight: status接口 must respond
|
||
let preflight: StatusData;
|
||
try {
|
||
preflight = await pollStatus(client);
|
||
} catch (e) {
|
||
err(`[seed-v2] pre-flight /v2/pipeline/status failed: ${(e as Error).message}`);
|
||
err(`[seed-v2] gateway up at ${opts.endpoint}? deployMode=standalone? stateBackend configured?`);
|
||
process.exit(2);
|
||
}
|
||
log(
|
||
`[seed-v2] pre-flight ok l1.idle=${preflight.l1.idle} ` +
|
||
`(l1 q=${preflight.l1.queued} r=${preflight.l1.running}, ` +
|
||
`l2 q=${preflight.l2.queued} r=${preflight.l2.running}, ` +
|
||
`l3 q=${preflight.l3.queued} r=${preflight.l3.running})`,
|
||
);
|
||
|
||
// Per-session, per-round seeding (timestamps now come from normalized input,
|
||
// not synthesized per-call — global monotonic ordering already guaranteed
|
||
// by validateAndNormalizeRaw + fillTimestamps).
|
||
const startMs = Date.now();
|
||
let roundsDone = 0;
|
||
let messagesDone = 0;
|
||
|
||
for (let si = 0; si < sessions.length; si++) {
|
||
const session = sessions[si]!;
|
||
const sessionKey = session.sessionKey;
|
||
const sessionId = session.sessionId; // always present after normalization
|
||
|
||
if (session.rounds.length === 0) {
|
||
warn(`[seed-v2] session ${si + 1}/${sessions.length} key="${sessionKey}" has no rounds, skipping`);
|
||
continue;
|
||
}
|
||
|
||
const sessionStartMs = Date.now();
|
||
let sessionMsgs = 0;
|
||
log(
|
||
`[seed-v2] session ${si + 1}/${sessions.length} START key="${sessionKey}" id="${sessionId}" ` +
|
||
`rounds=${session.rounds.length}`,
|
||
);
|
||
|
||
for (let ri = 0; ri < session.rounds.length; ri++) {
|
||
const round = session.rounds[ri]!;
|
||
if (round.messages.length === 0) continue;
|
||
|
||
const accepted = await addRound(
|
||
client,
|
||
{
|
||
sessionIndex: si + 1,
|
||
totalSessions: sessions.length,
|
||
sessionKey,
|
||
sessionId,
|
||
roundIndex: ri + 1,
|
||
totalRoundsInSession: session.rounds.length,
|
||
cumRounds: roundsDone + 1,
|
||
totalRounds,
|
||
cumMessagesAfter: messagesDone + round.messages.length,
|
||
totalMessages,
|
||
},
|
||
round.messages,
|
||
opts.quiet,
|
||
);
|
||
roundsDone++;
|
||
messagesDone += accepted;
|
||
sessionMsgs += accepted;
|
||
|
||
// Old seed-runtime parity: every N rounds in this session, wait for L1 idle.
|
||
const roundInSession = ri + 1;
|
||
if (roundInSession % opts.everyN === 0) {
|
||
await waitForBusyFalse(client, opts, `every-${opts.everyN}@${sessionKey}#${roundInSession}`, "l1", opts.maxWaitMs);
|
||
}
|
||
}
|
||
|
||
// Per-session tail wait (matches old seed-runtime per-session waitForL1Idle).
|
||
await waitForBusyFalse(client, opts, `session-tail@${sessionKey}`, "l1", opts.maxWaitMs);
|
||
|
||
const sessionDur = (Date.now() - sessionStartMs) / 1000;
|
||
log(
|
||
`[seed-v2] session ${si + 1}/${sessions.length} END key="${sessionKey}" ` +
|
||
`rounds=${session.rounds.length} msgs=${sessionMsgs} duration=${sessionDur.toFixed(1)}s`,
|
||
);
|
||
}
|
||
|
||
// Final wait: drain any cascade L2/L3 work (best-effort).
|
||
if (opts.finalWait) {
|
||
log(
|
||
`[seed-v2] all rounds dispatched, final drain — waiting for L1+L2+L3 all idle ` +
|
||
`(max ${(opts.finalMaxWaitMs / 1000).toFixed(0)}s)...`,
|
||
);
|
||
await waitForBusyFalse(client, opts, "final", "all", opts.finalMaxWaitMs);
|
||
} else {
|
||
log(`[seed-v2] all rounds dispatched, --no-final-wait set, exiting immediately`);
|
||
}
|
||
|
||
const durationMs = Date.now() - startMs;
|
||
log(
|
||
`[seed-v2] done sessions=${sessions.length} rounds=${roundsDone}/${totalRounds} ` +
|
||
`msgs=${messagesDone}/${totalMessages} duration=${(durationMs / 1000).toFixed(1)}s`,
|
||
);
|
||
|
||
// Final status snapshot for downstream verification.
|
||
const final = await pollStatus(client);
|
||
const allIdle = final.l1.idle && final.l2.idle && final.l3.idle;
|
||
log(
|
||
`[seed-v2] final status: l1.idle=${final.l1.idle} l2.idle=${final.l2.idle} l3.idle=${final.l3.idle} ` +
|
||
`(l1 q=${final.l1.queued} r=${final.l1.running}, ` +
|
||
`l2 q=${final.l2.queued} r=${final.l2.running}, ` +
|
||
`l3 q=${final.l3.queued} r=${final.l3.running})` +
|
||
(allIdle ? " ✓ all clean" : " ⚠ residual work — gateway will keep cooking after exit"),
|
||
);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
err(`[seed-v2] FATAL: ${e instanceof Error ? e.stack : String(e)}`);
|
||
process.exit(1);
|
||
});
|