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:
VectorPeak 2026-07-06 07:53:49 +08:00 committed by GitHub
parent be0b0749c1
commit 47f62a466c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 226 additions and 7 deletions

View file

@ -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);

View file

@ -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';
}