mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(desktop): preserve glued automation history records (#6344)
* fix(desktop): preserve glued automation history records * fix(desktop): refine automation history recovery Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * test(desktop): cover automation history rewrite normalization Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * fix(desktop): preserve recovered history before stray brace * test(desktop): assert history rewrite after stray suffix --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
parent
be0b0749c1
commit
47f62a466c
2 changed files with 226 additions and 7 deletions
|
|
@ -149,6 +149,139 @@ describe('history-store', () => {
|
|||
expect(entries).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should preserve nested and 3+ glued object records while rewriting the file', async () => {
|
||||
const gluedLine =
|
||||
JSON.stringify(makeEntry('a1', 2)) +
|
||||
JSON.stringify({ ...makeEntry('a1', 3), nested: { value: true } }) +
|
||||
JSON.stringify(makeEntry('a1', 4));
|
||||
const lines = [
|
||||
JSON.stringify(makeEntry('a1', 1)),
|
||||
gluedLine,
|
||||
'not-json{{{',
|
||||
'null',
|
||||
'42',
|
||||
'[]',
|
||||
];
|
||||
writeFileSync(
|
||||
join(tempDir, AUTOMATIONS_HISTORY_FILE),
|
||||
lines.join('\n') + '\n',
|
||||
);
|
||||
|
||||
await compactAutomationHistory(tempDir, 20, 1000);
|
||||
|
||||
const entries = readHistory(tempDir);
|
||||
expect(entries.map((entry) => entry.ts)).toEqual([1, 2, 3, 4]);
|
||||
expect(entries[2]!.nested).toEqual({ value: true });
|
||||
|
||||
const rewritten = readFileSync(join(tempDir, AUTOMATIONS_HISTORY_FILE), 'utf-8');
|
||||
expect(rewritten).not.toContain('}{');
|
||||
expect(rewritten.trim().split('\n')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should preserve complete glued records before trailing garbage', async () => {
|
||||
const first = makeEntry('a1', 1);
|
||||
const second = makeEntry('a1', 2);
|
||||
writeFileSync(
|
||||
join(tempDir, AUTOMATIONS_HISTORY_FILE),
|
||||
JSON.stringify(first) + JSON.stringify(second) + 'partial-write\n',
|
||||
);
|
||||
|
||||
await compactAutomationHistory(tempDir, 20, 1000);
|
||||
|
||||
const rewritten = readFileSync(join(tempDir, AUTOMATIONS_HISTORY_FILE), 'utf-8');
|
||||
expect(rewritten).not.toContain('}garbage');
|
||||
expect(rewritten.trim().split('\n')).toHaveLength(2);
|
||||
|
||||
const entries = readHistory(tempDir);
|
||||
expect(entries.map((entry) => entry.ts)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should recover valid objects before a stray closing brace', async () => {
|
||||
const first = makeEntry('a1', 1);
|
||||
const second = makeEntry('a1', 2);
|
||||
writeFileSync(
|
||||
join(tempDir, AUTOMATIONS_HISTORY_FILE),
|
||||
JSON.stringify(first) + '}garbage\n' + JSON.stringify(second) + '\n',
|
||||
);
|
||||
|
||||
await compactAutomationHistory(tempDir, 20, 1000);
|
||||
|
||||
const entries = readHistory(tempDir);
|
||||
expect(entries.map((entry) => entry.ts)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should rewrite glued records when recovered count matches physical line count', async () => {
|
||||
const first = makeEntry('a1', 1);
|
||||
const second = makeEntry('a1', 2);
|
||||
writeFileSync(
|
||||
join(tempDir, AUTOMATIONS_HISTORY_FILE),
|
||||
JSON.stringify(first) + JSON.stringify(second) + '\nnot-json{{{\n',
|
||||
);
|
||||
|
||||
await compactAutomationHistory(tempDir, 20, 1000);
|
||||
|
||||
const rewritten = readFileSync(join(tempDir, AUTOMATIONS_HISTORY_FILE), 'utf-8');
|
||||
expect(rewritten).not.toContain('}{');
|
||||
expect(rewritten.trim().split('\n')).toHaveLength(2);
|
||||
|
||||
const entries = readHistory(tempDir);
|
||||
expect(entries.map((entry) => entry.ts)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should rewrite non-object JSON history lines out of the file', async () => {
|
||||
writeFileSync(
|
||||
join(tempDir, AUTOMATIONS_HISTORY_FILE),
|
||||
[
|
||||
JSON.stringify(makeEntry('a1', 1)),
|
||||
'null',
|
||||
'42',
|
||||
'[]',
|
||||
JSON.stringify(makeEntry('a1', 2)),
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
|
||||
await compactAutomationHistory(tempDir, 20, 1000);
|
||||
|
||||
const rewritten = readFileSync(join(tempDir, AUTOMATIONS_HISTORY_FILE), 'utf-8');
|
||||
expect(rewritten).not.toContain('null');
|
||||
expect(rewritten).not.toContain('42');
|
||||
expect(rewritten).not.toContain('[]');
|
||||
expect(rewritten.trim().split('\n')).toHaveLength(2);
|
||||
|
||||
const entries = readHistory(tempDir);
|
||||
expect(entries.map((entry) => entry.ts)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should skip invalid recovered object segments while preserving valid glued records', async () => {
|
||||
const first = makeEntry('a1', 1);
|
||||
const second = makeEntry('a1', 2);
|
||||
writeFileSync(
|
||||
join(tempDir, AUTOMATIONS_HISTORY_FILE),
|
||||
JSON.stringify(first) + '{"id":}' + JSON.stringify(second) + '\n',
|
||||
);
|
||||
|
||||
await compactAutomationHistory(tempDir, 20, 1000);
|
||||
|
||||
const entries = readHistory(tempDir);
|
||||
expect(entries.map((entry) => entry.ts)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should preserve braces inside glued record strings', async () => {
|
||||
const first = { ...makeEntry('a1', 1), error: 'failed with {"x":1}' };
|
||||
const second = { ...makeEntry('a1', 2), error: 'escaped quote: \\"' };
|
||||
writeFileSync(
|
||||
join(tempDir, AUTOMATIONS_HISTORY_FILE),
|
||||
JSON.stringify(first) + JSON.stringify(second) + '\n',
|
||||
);
|
||||
|
||||
await compactAutomationHistory(tempDir, 20, 1000);
|
||||
|
||||
const entries = readHistory(tempDir);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]!.error).toBe(first.error);
|
||||
expect(entries[1]!.error).toBe(second.error);
|
||||
});
|
||||
|
||||
it('should no-op when file does not exist', async () => {
|
||||
// Should not throw
|
||||
await compactAutomationHistory(tempDir, 20, 1000);
|
||||
|
|
|
|||
|
|
@ -140,12 +140,98 @@ async function runCompaction(
|
|||
// Pure compaction algorithm — shared by sync and async paths
|
||||
// ============================================================================
|
||||
|
||||
function recoverHistoryObjectsFromLine(line: string): string[] {
|
||||
const recovered: string[] = [];
|
||||
let depth = 0;
|
||||
let start = -1;
|
||||
let cursor = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line.charAt(i);
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '{') {
|
||||
if (depth === 0 && line.slice(cursor, i).trim() !== '') {
|
||||
return recovered;
|
||||
}
|
||||
if (depth === 0) start = i;
|
||||
depth++;
|
||||
} else if (char === '}') {
|
||||
depth--;
|
||||
if (depth === 0 && start >= 0) {
|
||||
recovered.push(line.slice(start, i + 1));
|
||||
cursor = i + 1;
|
||||
start = -1;
|
||||
} else if (depth < 0) {
|
||||
return recovered;
|
||||
}
|
||||
} else if (depth === 0 && char.trim() !== '') {
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
|
||||
return recovered;
|
||||
}
|
||||
|
||||
function isValidHistoryObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getHistoryObjectId(value: Record<string, unknown>): string {
|
||||
return typeof value.id === 'string' ? value.id : '';
|
||||
}
|
||||
|
||||
function parseHistoryLine(line: string): Array<{ raw: string; id: string }> {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (isValidHistoryObject(parsed)) {
|
||||
return [{ raw: line, id: getHistoryObjectId(parsed) }];
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
const recovered = recoverHistoryObjectsFromLine(line);
|
||||
const entries: Array<{ raw: string; id: string }> = [];
|
||||
for (const raw of recovered) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (isValidHistoryObject(parsed)) {
|
||||
entries.push({ raw, id: getHistoryObjectId(parsed) });
|
||||
}
|
||||
} catch {
|
||||
// Skip only this recovered segment; keep any other valid objects.
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply two-tier retention to JSONL content:
|
||||
* 1. Per-automation cap: keep last `maxPerMatcher` entries per automation ID
|
||||
* 2. Global cap: keep last `maxTotal` entries overall
|
||||
*
|
||||
* Also drops malformed JSON lines.
|
||||
* Also drops malformed JSON lines, while preserving balanced object records
|
||||
* from glued history lines before rewriting the file.
|
||||
*
|
||||
* Returns the compacted output string, or `null` if no compaction was needed.
|
||||
*/
|
||||
|
|
@ -159,13 +245,13 @@ function compactEntries(
|
|||
|
||||
// Parse all lines, dropping malformed ones
|
||||
const entries: Array<{ raw: string; id: string }> = [];
|
||||
let needsRewrite = false;
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
entries.push({ raw: line, id: parsed.id ?? '' });
|
||||
} catch {
|
||||
// Drop malformed lines
|
||||
const parsedEntries = parseHistoryLine(line);
|
||||
if (parsedEntries.length !== 1 || parsedEntries[0]?.raw !== line) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
entries.push(...parsedEntries);
|
||||
}
|
||||
|
||||
// Track original line count (including malformed) for dirty-check
|
||||
|
|
@ -198,7 +284,7 @@ function compactEntries(
|
|||
trimmed = trimmed.slice(-maxTotal);
|
||||
}
|
||||
|
||||
if (trimmed.length === originalLineCount) return null;
|
||||
if (!needsRewrite && trimmed.length === originalLineCount) return null;
|
||||
|
||||
return trimmed.map(e => e.raw).join('\n') + '\n';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue