@openchambery/web 1.19.15 → 1.19.17-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AssistantView-xdL1EvpR.js → AssistantView-WtjtyUyg.js} +1 -1
- package/dist/assets/{DiagramView-DPgkYU-_.js → DiagramView-hIHOl44C.js} +1 -1
- package/dist/assets/MarkdownRenderer-Ym-7pIpr.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-BB1Q1RWk.js → MarkdownRendererImpl-CDnlh1Dp.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-BjQrUIcq.js → MarkstreamRendererImpl-4acJU3sf.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-Cyzs6cuR.js → MobileDetailNavigation-6fa0eI33.js} +1 -1
- package/dist/assets/{MobileShareBridge-A-Z6jMUn.js → MobileShareBridge-BkJMD_0e.js} +1 -1
- package/dist/assets/MobileSurface-D_6LtVFN.js +2 -0
- package/dist/assets/{MultiRunWindow-Cg5x-kJV.js → MultiRunWindow-2cqeffjI.js} +1 -1
- package/dist/assets/{SettingsView-CYrTHDdp.js → SettingsView-C5sFMDDr.js} +1 -1
- package/dist/assets/{SettingsWindow-CF-0Zgxr.js → SettingsWindow-Bdv6Aksi.js} +1 -1
- package/dist/assets/{TerminalView-CShou18D.js → TerminalView-CMkOh0DS.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BvmxlNOa.js → ToolOutputDialog-CnJIHSL_.js} +1 -1
- package/dist/assets/{appThemeRegistry-iqSTUpPj.js → appThemeRegistry-y_GyQ3ct.js} +1 -1
- package/dist/assets/{fileMentionTouchSelection-CddGH2aH.js → fileMentionTouchSelection-DpvBv5_N.js} +1 -1
- package/dist/assets/{gitApi-BwuaHHRQ.js → gitApi-m5nvxxzw.js} +1 -1
- package/dist/assets/{insertionBoundaries-og6NBgdn.js → insertionBoundaries-BPmqh_Tr.js} +1 -1
- package/dist/assets/{main-VD-QBM47.js → main-B21k2oEx.js} +2 -2
- package/dist/assets/{main-31g3JiQx.js → main-D_QwFeXJ.js} +4 -4
- package/dist/assets/{miniChat-DwCMTamY.js → miniChat-Bp8luloI.js} +2 -2
- package/dist/assets/{mobile-C7q1yowe.js → mobile-BfgTp3XO.js} +2 -2
- package/dist/assets/{multirun-DcN5T0lN.js → multirun-DVHbCRKI.js} +1 -1
- package/dist/assets/{projectMeta-moNJYCK4.js → projectMeta-DRIkQQ-o.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-DVkgKA6A.js → renderElectronMiniChatApp-DQM5xlcU.js} +2 -2
- package/dist/assets/{renderMobileApp-BgEXZhCO.js → renderMobileApp-CkvkhgAh.js} +4 -4
- package/dist/assets/{runtimeConfig-B49cxtgI.js → runtimeConfig-kj6kuVyP.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-9K_Dj1qR.js → runtimeEndpointReset-CgaiwIrw.js} +1 -1
- package/dist/assets/{sessionLookup-m3h8c4xR.js → sessionLookup-D-SPo12C.js} +1 -1
- package/dist/assets/{useAppFontEffects-DRFi4TCE.js → useAppFontEffects-Cx1Q-PF_.js} +5 -5
- package/dist/assets/{useDesktopWindowControlsLayout-BdNR3zMN.js → useDesktopWindowControlsLayout-Cju9H0m2.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-COoPja_2.js → useEffectiveDirectory-8QrZ__Q1.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-Cn0-ozUB.js → useMobileNavigationStore-BZYNB0CE.js} +1 -1
- package/dist/assets/{useSessionAutoCleanup-XwgyzsEd.js → useSessionAutoCleanup-CKKtjnuX.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-GgcqEoKY.js → useWorkerHighlightedLines-DzP14_K3.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/mini-chat.html +2 -2
- package/dist/mobile.html +2 -2
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +11 -1
- package/server/lib/assistants/contact-store.js +18 -4
- package/server/lib/assistants/contact-tools.js +44 -0
- package/server/lib/assistants/contact-tools.test.js +2 -0
- package/server/lib/assistants/harness.js +2 -2
- package/server/lib/assistants/memory.http.test.js +204 -0
- package/server/lib/assistants/memory.js +124 -0
- package/server/lib/assistants/memory.test.js +159 -0
- package/server/lib/assistants/service.js +9 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +10 -0
- package/server/lib/scheduled-tasks/run-history-store.js +32 -6
- package/server/lib/scheduled-tasks/run-history-store.test.js +74 -0
- package/server/lib/scheduled-tasks/runtime.js +21 -5
- package/server/lib/scheduled-tasks/runtime.test.js +71 -0
- package/dist/assets/MarkdownRenderer-CP7UVZdr.js +0 -2
- package/dist/assets/MobileSurface-DjDa20gx.js +0 -2
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { getContactAssistantContextBoundary, getContactContextBoundary, getContactGeneration } from './contact-store.js';
|
|
3
|
+
|
|
4
|
+
const SCAN_LIMIT = 200;
|
|
5
|
+
const fail = (code) => { throw Object.assign(new Error(code), { code }); };
|
|
6
|
+
const paramsObject = (value, keys) => {
|
|
7
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((key) => !keys.includes(key))) fail('validation_error');
|
|
8
|
+
};
|
|
9
|
+
const integer = (value, fallback, min, max) => {
|
|
10
|
+
if (value === undefined) return fallback;
|
|
11
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) fail('validation_error');
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
const time = (value) => {
|
|
15
|
+
if (value === undefined) return null;
|
|
16
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T.+(?:Z|[+-]\d{2}:\d{2})$/u.test(value)) fail('validation_error');
|
|
17
|
+
const result = Date.parse(value);
|
|
18
|
+
if (!Number.isFinite(result)) fail('validation_error');
|
|
19
|
+
return result;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Project only authored text, ordered exactly as the contact transcript.
|
|
23
|
+
// Attachment bodies, URLs, peer DMs, diagnostics and tool payloads never leave this reader.
|
|
24
|
+
const textRows = (count) => `
|
|
25
|
+
WITH text_parts AS (
|
|
26
|
+
SELECT message_id, json_extract(part_json, '$.text') AS text
|
|
27
|
+
FROM assistant_contact_part
|
|
28
|
+
WHERE message_id IN (${Array(count).fill('?').join(',')})
|
|
29
|
+
AND json_extract(part_json, '$.type') = 'text'
|
|
30
|
+
AND substr(json_extract(part_json, '$.text'), 1, 10) <> 'oc.settle.'
|
|
31
|
+
ORDER BY message_id, ordinal, part_id
|
|
32
|
+
), texts AS (
|
|
33
|
+
SELECT message_id, group_concat(text, '') AS text FROM text_parts GROUP BY message_id
|
|
34
|
+
)`;
|
|
35
|
+
|
|
36
|
+
/** One contact turn's read-only view, fenced again against live memory state on every call. */
|
|
37
|
+
export function createContactMemoryReader(db, assistantID, { beforeOrdinal = null, assertAvailable = () => {} } = {}) {
|
|
38
|
+
const boundary = getContactContextBoundary(db, assistantID);
|
|
39
|
+
const assistantBoundary = getContactAssistantContextBoundary(db, assistantID);
|
|
40
|
+
const generation = getContactGeneration(db, assistantID);
|
|
41
|
+
const ceiling = Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) AS value FROM assistant_contact_message WHERE assistant_id=?').get(assistantID).value);
|
|
42
|
+
const eligible = (row) => row.role !== 'peer' && ['user', 'assistant'].includes(row.role)
|
|
43
|
+
&& row.status === 'complete' && !row.from_assistant_id
|
|
44
|
+
&& (row.role !== 'assistant' || row.ordinal > assistantBoundary)
|
|
45
|
+
&& (row.role !== 'user' || beforeOrdinal === null || row.ordinal < beforeOrdinal);
|
|
46
|
+
const check = () => {
|
|
47
|
+
assertAvailable();
|
|
48
|
+
if (getContactGeneration(db, assistantID) !== generation || getContactContextBoundary(db, assistantID) !== boundary
|
|
49
|
+
|| getContactAssistantContextBoundary(db, assistantID) !== assistantBoundary) fail('memory_scope_changed');
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
search(params = {}) {
|
|
53
|
+
check();
|
|
54
|
+
paramsObject(params, ['query', 'from', 'to', 'limit', 'cursor']);
|
|
55
|
+
const query = params.query === undefined ? '' : params.query;
|
|
56
|
+
if (typeof query !== 'string' || query.length > 256) fail('validation_error');
|
|
57
|
+
const from = time(params.from);
|
|
58
|
+
const to = time(params.to);
|
|
59
|
+
if (from !== null && to !== null && from > to) fail('validation_error');
|
|
60
|
+
const limit = integer(params.limit, 10, 1, 20);
|
|
61
|
+
const scope = createHash('sha256').update(JSON.stringify([assistantID, boundary, assistantBoundary, generation, ceiling, beforeOrdinal, query, from, to])).digest('hex');
|
|
62
|
+
let cursor = null;
|
|
63
|
+
if (params.cursor !== undefined) {
|
|
64
|
+
if (typeof params.cursor !== 'string' || params.cursor.length > 2048 || !/^[A-Za-z0-9_-]+$/u.test(params.cursor)) fail('invalid_memory_cursor');
|
|
65
|
+
try { cursor = JSON.parse(Buffer.from(params.cursor, 'base64url').toString('utf8')); } catch { fail('invalid_memory_cursor'); }
|
|
66
|
+
if (cursor?.v !== 1 || cursor.scope !== scope || !Number.isSafeInteger(cursor.ordinal)
|
|
67
|
+
|| cursor.ordinal <= boundary || cursor.ordinal > ceiling || typeof cursor.messageID !== 'string'
|
|
68
|
+
|| !cursor.messageID || cursor.messageID.length > 512) fail('invalid_memory_cursor');
|
|
69
|
+
}
|
|
70
|
+
const rows = db.prepare(`SELECT message_id, role, created_at, ordinal, status, from_assistant_id
|
|
71
|
+
FROM assistant_contact_message WHERE assistant_id=? AND ordinal>? AND ordinal<=?
|
|
72
|
+
${cursor ? 'AND (ordinal, message_id) < (?, ?)' : ''}
|
|
73
|
+
ORDER BY ordinal DESC, message_id DESC LIMIT ?`).all(
|
|
74
|
+
assistantID, boundary, ceiling, ...(cursor ? [cursor.ordinal, cursor.messageID] : []), SCAN_LIMIT + 1,
|
|
75
|
+
);
|
|
76
|
+
const candidates = rows.slice(0, SCAN_LIMIT);
|
|
77
|
+
const ids = candidates.filter((row) => eligible(row)
|
|
78
|
+
&& (from === null || row.created_at >= from) && (to === null || row.created_at <= to)).map((row) => row.message_id);
|
|
79
|
+
const snippets = ids.length === 0 ? [] : db.prepare(`${textRows(ids.length)}
|
|
80
|
+
SELECT message_id, substr(text, max(instr(lower(text), lower(?)) - 80, 1), 400) AS snippet,
|
|
81
|
+
max(instr(lower(text), lower(?)) - 81, 0) AS snippet_offset, length(text) AS total_chars
|
|
82
|
+
FROM texts WHERE length(trim(text)) > 0 AND (? = '' OR instr(lower(text), lower(?)) > 0)
|
|
83
|
+
`).all(...ids, query, query, query, query);
|
|
84
|
+
const byID = new Map(snippets.map((row) => [row.message_id, row]));
|
|
85
|
+
const matches = [];
|
|
86
|
+
let scanned = 0;
|
|
87
|
+
for (const row of candidates) {
|
|
88
|
+
scanned += 1;
|
|
89
|
+
const snippet = byID.get(row.message_id);
|
|
90
|
+
if (snippet) matches.push({
|
|
91
|
+
messageID: row.message_id, role: row.role, createdAt: row.created_at,
|
|
92
|
+
snippet: snippet.snippet, offset: snippet.snippet_offset, totalChars: snippet.total_chars,
|
|
93
|
+
});
|
|
94
|
+
if (matches.length === limit) break;
|
|
95
|
+
}
|
|
96
|
+
const last = candidates[scanned - 1];
|
|
97
|
+
const hasMore = scanned < rows.length;
|
|
98
|
+
const nextCursor = hasMore && last ? Buffer.from(JSON.stringify({
|
|
99
|
+
v: 1, scope, ordinal: last.ordinal, messageID: last.message_id,
|
|
100
|
+
})).toString('base64url') : null;
|
|
101
|
+
return { matches, nextCursor, complete: nextCursor === null, scanned };
|
|
102
|
+
},
|
|
103
|
+
read(params = {}) {
|
|
104
|
+
check();
|
|
105
|
+
paramsObject(params, ['messageID', 'offset', 'maxChars']);
|
|
106
|
+
if (typeof params.messageID !== 'string' || !params.messageID || params.messageID.length > 512) fail('validation_error');
|
|
107
|
+
const offset = integer(params.offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
108
|
+
const maxChars = integer(params.maxChars, 4000, 1, 8000);
|
|
109
|
+
const row = db.prepare(`SELECT message_id, role, created_at, ordinal, status, from_assistant_id
|
|
110
|
+
FROM assistant_contact_message WHERE assistant_id=? AND message_id=? AND ordinal>? AND ordinal<=?`).get(
|
|
111
|
+
assistantID, params.messageID, boundary, ceiling,
|
|
112
|
+
);
|
|
113
|
+
if (!row || !eligible(row)) fail('memory_not_found');
|
|
114
|
+
const content = db.prepare(`${textRows(1)} SELECT substr(text, ?, ?) AS text, length(text) AS total_chars FROM texts`).get(row.message_id, offset + 1, maxChars);
|
|
115
|
+
if (!content || !content.total_chars) fail('memory_not_found');
|
|
116
|
+
if (offset > content.total_chars) fail('validation_error');
|
|
117
|
+
const nextOffset = offset + maxChars < content.total_chars ? offset + maxChars : null;
|
|
118
|
+
return {
|
|
119
|
+
messageID: row.message_id, role: row.role, createdAt: row.created_at,
|
|
120
|
+
text: content.text, offset, totalChars: content.total_chars, nextOffset, complete: nextOffset === null,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { createContactMemoryReader } from './memory.js';
|
|
4
|
+
import { bumpContactGeneration, clearContactMemory, contactHistoryForLlm, deleteContactMessages, ensureContactSchema, insertContactMessage } from './contact-store.js';
|
|
5
|
+
|
|
6
|
+
const Database = createRequire(import.meta.url)('better-sqlite3');
|
|
7
|
+
const databases = [];
|
|
8
|
+
afterEach(() => { while (databases.length) databases.pop().close(); });
|
|
9
|
+
const fixture = () => {
|
|
10
|
+
const db = new Database(':memory:');
|
|
11
|
+
databases.push(db);
|
|
12
|
+
ensureContactSchema(db);
|
|
13
|
+
let ordinal = 0;
|
|
14
|
+
const insert = (messageID, text, { assistantID = 'a', role = 'user', turnID = messageID, ...extra } = {}) => {
|
|
15
|
+
insertContactMessage(db, {
|
|
16
|
+
messageID, assistantID, role, turnID, ordinal: ++ordinal, createdAt: 1_700_000_000_000 + ordinal,
|
|
17
|
+
parts: [{ type: 'text', text }], ...extra,
|
|
18
|
+
});
|
|
19
|
+
return ordinal;
|
|
20
|
+
};
|
|
21
|
+
return { db, insert };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('contact memory boundaries', () => {
|
|
25
|
+
it('forgets a preceding turn late reply while preserving queued users after clear-memory admission', () => {
|
|
26
|
+
const { db, insert } = fixture();
|
|
27
|
+
insert('prior', 'Old decision');
|
|
28
|
+
const clearing = insert('clearing', 'Clear memory');
|
|
29
|
+
insert('queued', 'New topic');
|
|
30
|
+
insert('late-prior', 'Old decision details', { role: 'assistant', turnID: 'prior' });
|
|
31
|
+
clearContactMemory(db, 'a', { upToOrdinal: clearing });
|
|
32
|
+
insert('reset-confirm', 'Ready for a new topic', { role: 'assistant', turnID: 'clearing' });
|
|
33
|
+
const reader = createContactMemoryReader(db, 'a');
|
|
34
|
+
expect(reader.search().matches.map((m) => m.messageID)).toEqual(['reset-confirm', 'queued']);
|
|
35
|
+
expect(() => reader.read({ messageID: 'late-prior' })).toThrow('memory_not_found');
|
|
36
|
+
expect(contactHistoryForLlm(db, 'a').map((m) => m.content)).toEqual(['New topic', 'Ready for a new topic']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('scopes exact reads, search and cursors to one assistant and rejects target overrides', () => {
|
|
40
|
+
const { db, insert } = fixture();
|
|
41
|
+
insert('a1', 'needle own'); insert('a2', 'needle own later');
|
|
42
|
+
insert('b1', 'needle other', { assistantID: 'b' });
|
|
43
|
+
const a = createContactMemoryReader(db, 'a');
|
|
44
|
+
const b = createContactMemoryReader(db, 'b');
|
|
45
|
+
expect(a.search({ query: 'needle' }).matches.map((m) => m.messageID)).toEqual(['a2', 'a1']);
|
|
46
|
+
expect(() => a.read({ messageID: 'b1' })).toThrow('memory_not_found');
|
|
47
|
+
expect(() => a.search({ assistantID: 'b' })).toThrow('validation_error');
|
|
48
|
+
expect(() => b.search({ query: 'needle', cursor: a.search({ query: 'needle', limit: 1 }).nextCursor })).toThrow('invalid_memory_cursor');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('bounds scans and exposes empty partial pages until an older match is reached', () => {
|
|
52
|
+
const { db, insert } = fixture();
|
|
53
|
+
insert('old', 'rare decision');
|
|
54
|
+
for (let i = 0; i < 410; i++) insert(`filler-${i}`, 'ordinary');
|
|
55
|
+
const reader = createContactMemoryReader(db, 'a');
|
|
56
|
+
let page = reader.search({ query: 'rare' });
|
|
57
|
+
expect(page).toMatchObject({ matches: [], complete: false, scanned: 200 });
|
|
58
|
+
page = reader.search({ query: 'rare', cursor: page.nextCursor });
|
|
59
|
+
expect(page).toMatchObject({ matches: [], complete: false, scanned: 200 });
|
|
60
|
+
page = reader.search({ query: 'rare', cursor: page.nextCursor });
|
|
61
|
+
expect(page.matches.map((m) => m.messageID)).toEqual(['old']);
|
|
62
|
+
expect(page.complete).toBe(true);
|
|
63
|
+
expect(page.scanned).toBe(11);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('paginates matching rows without gaps and returns literal, dated snippets', () => {
|
|
67
|
+
const { db, insert } = fixture();
|
|
68
|
+
for (let i = 0; i < 7; i++) insert(`match-${i}`, `100%_literal ${i}`);
|
|
69
|
+
const reader = createContactMemoryReader(db, 'a');
|
|
70
|
+
const ids = [];
|
|
71
|
+
let cursor;
|
|
72
|
+
do {
|
|
73
|
+
const page = reader.search({ query: '%_', limit: 2, ...(cursor ? { cursor } : {}) });
|
|
74
|
+
ids.push(...page.matches.map((m) => m.messageID));
|
|
75
|
+
cursor = page.nextCursor;
|
|
76
|
+
} while (cursor);
|
|
77
|
+
expect(ids).toEqual(['match-6', 'match-5', 'match-4', 'match-3', 'match-2', 'match-1', 'match-0']);
|
|
78
|
+
const page = reader.search({ from: new Date(1_700_000_000_003).toISOString(), to: new Date(1_700_000_000_004).toISOString() });
|
|
79
|
+
expect(page.matches.map((m) => m.messageID)).toEqual(['match-3', 'match-2']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('reads long Unicode messages by character offsets, including a match beyond the first chunk', () => {
|
|
83
|
+
const { db, insert } = fixture();
|
|
84
|
+
const text = '你好🙂'.repeat(2000) + 'rare ending';
|
|
85
|
+
insert('long', text);
|
|
86
|
+
const reader = createContactMemoryReader(db, 'a');
|
|
87
|
+
const found = reader.search({ query: 'rare' }).matches[0];
|
|
88
|
+
expect(found.snippet).toContain('rare ending');
|
|
89
|
+
expect(found.offset).toBeGreaterThan(4000);
|
|
90
|
+
let offset = 0;
|
|
91
|
+
let recovered = '';
|
|
92
|
+
do {
|
|
93
|
+
const page = reader.read({ messageID: 'long', offset, maxChars: 997 });
|
|
94
|
+
recovered += page.text;
|
|
95
|
+
offset = page.nextOffset;
|
|
96
|
+
} while (offset !== null);
|
|
97
|
+
expect(recovered).toBe(text);
|
|
98
|
+
expect(() => reader.read({ messageID: 'long', offset: 99999 })).toThrow('validation_error');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('invalidates captured readers and cursors on clear and wipe, retaining visible history only for clear', () => {
|
|
102
|
+
const { db, insert } = fixture();
|
|
103
|
+
insert('old-1', 'old'); insert('old-2', 'old');
|
|
104
|
+
const reader = createContactMemoryReader(db, 'a');
|
|
105
|
+
const cursor = reader.search({ limit: 1 }).nextCursor;
|
|
106
|
+
clearContactMemory(db, 'a');
|
|
107
|
+
expect(() => reader.search({ cursor })).toThrow('memory_scope_changed');
|
|
108
|
+
expect(() => reader.read({ messageID: 'old-1' })).toThrow('memory_scope_changed');
|
|
109
|
+
const fresh = createContactMemoryReader(db, 'a');
|
|
110
|
+
expect(fresh.search().matches).toEqual([]);
|
|
111
|
+
expect(() => fresh.read({ messageID: 'old-1' })).toThrow('memory_not_found');
|
|
112
|
+
expect(() => fresh.search({ cursor })).toThrow('invalid_memory_cursor');
|
|
113
|
+
deleteContactMessages(db, 'a'); bumpContactGeneration(db, 'a');
|
|
114
|
+
expect(() => fresh.search()).toThrow('memory_scope_changed');
|
|
115
|
+
expect(createContactMemoryReader(db, 'a').search().matches).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('excludes peers, errors, internal markers, attachment bodies and later queued admissions', () => {
|
|
119
|
+
const { db, insert } = fixture();
|
|
120
|
+
insert('visible', 'visible');
|
|
121
|
+
const current = insert('current', 'current question');
|
|
122
|
+
insert('queued', 'queued secret');
|
|
123
|
+
insert('error', 'error secret', { role: 'assistant', status: 'error' });
|
|
124
|
+
insert('peer', 'peer secret', { role: 'peer', fromAssistantID: 'b' });
|
|
125
|
+
insert('internal', 'oc.settle.complete', { role: 'assistant' });
|
|
126
|
+
insert('file', '', { parts: [{ type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,c2VjcmV0', filename: 'private.txt' }] });
|
|
127
|
+
const reader = createContactMemoryReader(db, 'a', { beforeOrdinal: current });
|
|
128
|
+
insert('future', 'future secret');
|
|
129
|
+
expect(reader.search().matches.map((m) => m.messageID)).toEqual(['visible']);
|
|
130
|
+
for (const id of ['current', 'queued', 'error', 'peer', 'internal', 'file', 'future']) expect(() => reader.read({ messageID: id })).toThrow('memory_not_found');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('rejects malformed arguments and changed-filter cursors while database failures remain errors', () => {
|
|
134
|
+
const { db, insert } = fixture();
|
|
135
|
+
insert('one', 'needle'); insert('two', 'needle');
|
|
136
|
+
const reader = createContactMemoryReader(db, 'a');
|
|
137
|
+
for (const params of [{ limit: 21 }, { query: 1 }, { from: 'yesterday' }, { cursor: '{}' }, { cursor: 'bnVsbA' }]) expect(() => reader.search(params)).toThrow();
|
|
138
|
+
const cursor = reader.search({ query: 'needle', limit: 1 }).nextCursor;
|
|
139
|
+
expect(() => reader.search({ query: 'different', cursor })).toThrow('invalid_memory_cursor');
|
|
140
|
+
db.exec('DROP TABLE assistant_contact_part');
|
|
141
|
+
expect(() => reader.search()).toThrow();
|
|
142
|
+
expect(() => reader.read({ messageID: 'one' })).toThrow();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('migrates existing boundaries and rolls both watermarks back on failed transactions', () => {
|
|
146
|
+
const { db, insert } = fixture();
|
|
147
|
+
insert('old', 'old text');
|
|
148
|
+
db.exec('ALTER TABLE assistant_contact_context_boundary DROP COLUMN assistant_after_ordinal');
|
|
149
|
+
db.prepare('INSERT INTO assistant_contact_context_boundary VALUES (?,?,?)').run('a', 1, 1);
|
|
150
|
+
ensureContactSchema(db);
|
|
151
|
+
insert('new', 'new text');
|
|
152
|
+
const reader = createContactMemoryReader(db, 'a');
|
|
153
|
+
expect(reader.search().matches.map((m) => m.messageID)).toEqual(['new']);
|
|
154
|
+
db.exec('BEGIN');
|
|
155
|
+
clearContactMemory(db, 'a');
|
|
156
|
+
db.exec('ROLLBACK');
|
|
157
|
+
expect(reader.search().matches.map((m) => m.messageID)).toEqual(['new']);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
|
6
6
|
import { validAssistantDeliveryParts } from '../assistant-delivery-parts.js';
|
|
7
7
|
import { reduceBackfillState } from './history-state.js';
|
|
8
|
+
import { createContactMemoryReader } from './memory.js';
|
|
8
9
|
import { getWorktrees as defaultListWorktrees } from '../git/service.js';
|
|
9
10
|
import { contactCardIdentity, parseContactCard, parseContactPart } from './cards.js';
|
|
10
11
|
import {
|
|
@@ -2240,7 +2241,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2240
2241
|
history,
|
|
2241
2242
|
{ budgetBytes: CONTACT_ATTACHMENT_TURN_BUDGET_BYTES },
|
|
2242
2243
|
);
|
|
2244
|
+
const memory = createContactMemoryReader(db, assistantID, { assertAvailable: () => editable(assistantID) });
|
|
2243
2245
|
const tools = createContactTools({
|
|
2246
|
+
searchMemory: memory.search,
|
|
2247
|
+
readMemory: memory.read,
|
|
2244
2248
|
assignWork: (params) => assignWork(live, params),
|
|
2245
2249
|
watchSession: (params) => watchSessionWork(live, params),
|
|
2246
2250
|
stopSession: (params) => stopSessionWork(live, params),
|
|
@@ -2570,7 +2574,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2570
2574
|
(Array.isArray(executionParts) ? executionParts : []).filter((part) => part?.type === 'file'),
|
|
2571
2575
|
);
|
|
2572
2576
|
const turnAttachmentScope = attachmentScopeKey(turnFileParts);
|
|
2577
|
+
const memory = createContactMemoryReader(db, row.assistant_id, {
|
|
2578
|
+
beforeOrdinal, assertAvailable: () => editable(row.assistant_id),
|
|
2579
|
+
});
|
|
2573
2580
|
const tools = createContactTools({
|
|
2581
|
+
searchMemory: memory.search,
|
|
2582
|
+
readMemory: memory.read,
|
|
2574
2583
|
assignWork: (params) => assignWork(row, params),
|
|
2575
2584
|
watchSession: (params) => watchSessionWork(row, params),
|
|
2576
2585
|
stopSession: (params) => stopSessionWork(row, params),
|
|
@@ -25,6 +25,8 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
|
|
|
25
25
|
- Independent SQLite file `scheduled-task-runs.sqlite` (WAL)
|
|
26
26
|
- Keyset-paginated list of run records
|
|
27
27
|
- On open, converges leftover `running` rows to `error` with an interrupted message
|
|
28
|
+
- Scheduled runs claim `(project_id, task_id, slot_at)` so multiple OpenChamber
|
|
29
|
+
processes sharing a data dir cannot occupy the same fire slot
|
|
28
30
|
|
|
29
31
|
- `packages/web/server/lib/scheduled-tasks/routes.js`
|
|
30
32
|
- Global scheduled task list endpoint with per-project partial-result handling
|
|
@@ -101,6 +103,11 @@ Persisted columns:
|
|
|
101
103
|
| `started_at` | Epoch ms |
|
|
102
104
|
| `finished_at` | Nullable epoch ms |
|
|
103
105
|
| `duration_ms` | Nullable |
|
|
106
|
+
| `slot_at` | Scheduled occupancy key (the armed `nextRunAt`). Null for manual runs. List DTO omits this column. |
|
|
107
|
+
|
|
108
|
+
Partial unique index `scheduled_task_run_scheduled_slot` on
|
|
109
|
+
`(project_id, task_id, slot_at)` where `trigger = 'scheduled' AND slot_at IS NOT NULL`.
|
|
110
|
+
A second `startRun` for the same slot throws `SCHEDULED_SLOT_CLAIMED`.
|
|
104
111
|
|
|
105
112
|
Indexes support `started_at DESC, run_id DESC` listing with project/task filters.
|
|
106
113
|
|
|
@@ -168,6 +175,9 @@ failure preserves tasks from completed projects and adds that project ID to
|
|
|
168
175
|
Every actual run (timer or manual):
|
|
169
176
|
|
|
170
177
|
1. Allocate a unique `runID` and persist `status=running` in the history store.
|
|
178
|
+
Timer fires pass the armed `nextRunAt` as `slotAt`. A duplicate scheduled
|
|
179
|
+
slot claim skips the run: no session, no task-state write, no notification.
|
|
180
|
+
The losing scheduler arms the next occurrence and returns `{ skipped: true }`.
|
|
171
181
|
2. Persist task state `lastStatus=running`.
|
|
172
182
|
3. Create a per-run `AbortController` and race `runTaskWithWatchdog` against
|
|
173
183
|
`maxRunDurationMs` (default 2 hours). Only this watchdog timeout aborts
|
|
@@ -8,6 +8,8 @@ const require = createRequire(import.meta.url);
|
|
|
8
8
|
const DEFAULT_LIMIT = 20;
|
|
9
9
|
const MAX_LIMIT = 100;
|
|
10
10
|
const INTERRUPTED_ERROR = 'Run interrupted by process restart';
|
|
11
|
+
export const SCHEDULED_SLOT_CLAIMED_CODE = 'SCHEDULED_SLOT_CLAIMED';
|
|
12
|
+
const SQLITE_UNIQUE_CODES = new Set(['SQLITE_CONSTRAINT', 'SQLITE_CONSTRAINT_UNIQUE']);
|
|
11
13
|
|
|
12
14
|
const asNonEmptyString = (value) => {
|
|
13
15
|
if (typeof value !== 'string') {
|
|
@@ -105,6 +107,18 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
|
|
|
105
107
|
ON scheduled_task_run (project_id, task_id, started_at DESC, run_id DESC);
|
|
106
108
|
`);
|
|
107
109
|
|
|
110
|
+
const hasSlotAt = db.prepare(`
|
|
111
|
+
SELECT 1 AS ok FROM pragma_table_info('scheduled_task_run') WHERE name = 'slot_at'
|
|
112
|
+
`).get();
|
|
113
|
+
if (!hasSlotAt) {
|
|
114
|
+
db.exec('ALTER TABLE scheduled_task_run ADD COLUMN slot_at INTEGER');
|
|
115
|
+
}
|
|
116
|
+
db.exec(`
|
|
117
|
+
CREATE UNIQUE INDEX IF NOT EXISTS scheduled_task_run_scheduled_slot
|
|
118
|
+
ON scheduled_task_run (project_id, task_id, slot_at)
|
|
119
|
+
WHERE trigger = 'scheduled' AND slot_at IS NOT NULL
|
|
120
|
+
`);
|
|
121
|
+
|
|
108
122
|
const now = () => Math.trunc(clock());
|
|
109
123
|
|
|
110
124
|
const convergeInterruptedRuns = () => {
|
|
@@ -137,17 +151,29 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
|
|
|
137
151
|
const trigger = record.trigger === 'manual' ? 'manual' : 'scheduled';
|
|
138
152
|
const directory = asNonEmptyString(record.directory);
|
|
139
153
|
const startedAt = asFiniteInteger(record.startedAt) ?? now();
|
|
154
|
+
const slotAt = trigger === 'scheduled'
|
|
155
|
+
? asFiniteInteger(record.slotAt ?? record.slot_at)
|
|
156
|
+
: null;
|
|
140
157
|
|
|
141
158
|
if (!projectId || !taskId) {
|
|
142
159
|
throw new Error('projectId and taskId are required');
|
|
143
160
|
}
|
|
144
161
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
162
|
+
try {
|
|
163
|
+
db.prepare(`
|
|
164
|
+
INSERT INTO scheduled_task_run (
|
|
165
|
+
run_id, project_id, task_id, task_name, trigger, status,
|
|
166
|
+
session_id, directory, error, started_at, finished_at, duration_ms, slot_at
|
|
167
|
+
) VALUES (?, ?, ?, ?, ?, 'running', NULL, ?, NULL, ?, NULL, NULL, ?)
|
|
168
|
+
`).run(id, projectId, taskId, taskName, trigger, directory, startedAt, slotAt);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (slotAt !== null && SQLITE_UNIQUE_CODES.has(error?.code)) {
|
|
171
|
+
const claimed = new Error('scheduled slot already claimed');
|
|
172
|
+
claimed.code = SCHEDULED_SLOT_CLAIMED_CODE;
|
|
173
|
+
throw claimed;
|
|
174
|
+
}
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
151
177
|
|
|
152
178
|
return rowToDto(db.prepare('SELECT * FROM scheduled_task_run WHERE run_id = ?').get(id));
|
|
153
179
|
};
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
6
|
import { createScheduledTaskRunHistoryStore } from './run-history-store.js';
|
|
6
7
|
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
|
|
7
10
|
const directories = [];
|
|
8
11
|
|
|
9
12
|
const createTempDbPath = () => {
|
|
@@ -26,6 +29,7 @@ const startSample = (store, overrides = {}) => store.startRun({
|
|
|
26
29
|
trigger: overrides.trigger ?? 'scheduled',
|
|
27
30
|
directory: overrides.directory ?? '/tmp/project-a',
|
|
28
31
|
startedAt: overrides.startedAt,
|
|
32
|
+
slotAt: overrides.slotAt,
|
|
29
33
|
});
|
|
30
34
|
|
|
31
35
|
describe('scheduled task run history store', () => {
|
|
@@ -160,4 +164,74 @@ describe('scheduled task run history store', () => {
|
|
|
160
164
|
expect(() => store.listRuns({ limit: -3 })).toThrow(/limit/i);
|
|
161
165
|
store.close();
|
|
162
166
|
});
|
|
167
|
+
|
|
168
|
+
it('lets only one scheduled startRun occupy a slot, including across store handles', () => {
|
|
169
|
+
const dbPath = createTempDbPath();
|
|
170
|
+
const first = createScheduledTaskRunHistoryStore({ dbPath });
|
|
171
|
+
startSample(first, { id: 'run-a', slotAt: 1_700_000_000_000 });
|
|
172
|
+
|
|
173
|
+
expect(() => startSample(first, { id: 'run-b', slotAt: 1_700_000_000_000 })).toThrow(
|
|
174
|
+
/already claimed/,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const second = createScheduledTaskRunHistoryStore({ dbPath });
|
|
178
|
+
expect(() => startSample(second, { id: 'run-c', slotAt: 1_700_000_000_000 })).toThrow(
|
|
179
|
+
/already claimed/,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
startSample(first, { id: 'run-next-slot', slotAt: 1_700_000_000_000 + 60_000 });
|
|
183
|
+
startSample(first, {
|
|
184
|
+
id: 'run-manual',
|
|
185
|
+
trigger: 'manual',
|
|
186
|
+
slotAt: 1_700_000_000_000,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(first.listRuns({}).runs.map((run) => run.id).sort()).toEqual([
|
|
190
|
+
'run-a',
|
|
191
|
+
'run-manual',
|
|
192
|
+
'run-next-slot',
|
|
193
|
+
].sort());
|
|
194
|
+
|
|
195
|
+
first.close();
|
|
196
|
+
second.close();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('adds slot occupancy to databases created before slot_at existed', () => {
|
|
200
|
+
const dbPath = createTempDbPath();
|
|
201
|
+
const Database = require('better-sqlite3');
|
|
202
|
+
const legacy = new Database(dbPath);
|
|
203
|
+
legacy.exec(`
|
|
204
|
+
CREATE TABLE scheduled_task_run (
|
|
205
|
+
run_id TEXT PRIMARY KEY NOT NULL,
|
|
206
|
+
project_id TEXT NOT NULL,
|
|
207
|
+
task_id TEXT NOT NULL,
|
|
208
|
+
task_name TEXT NOT NULL,
|
|
209
|
+
trigger TEXT NOT NULL,
|
|
210
|
+
status TEXT NOT NULL,
|
|
211
|
+
session_id TEXT,
|
|
212
|
+
directory TEXT,
|
|
213
|
+
error TEXT,
|
|
214
|
+
started_at INTEGER NOT NULL,
|
|
215
|
+
finished_at INTEGER,
|
|
216
|
+
duration_ms INTEGER
|
|
217
|
+
);
|
|
218
|
+
`);
|
|
219
|
+
legacy.prepare(`
|
|
220
|
+
INSERT INTO scheduled_task_run (
|
|
221
|
+
run_id, project_id, task_id, task_name, trigger, status,
|
|
222
|
+
session_id, directory, error, started_at, finished_at, duration_ms
|
|
223
|
+
) VALUES ('legacy-run', 'project-a', 'task-1', 'Morning Sync', 'scheduled',
|
|
224
|
+
'success', NULL, '/tmp/project-a', NULL, 50, 60, 10)
|
|
225
|
+
`).run();
|
|
226
|
+
legacy.close();
|
|
227
|
+
|
|
228
|
+
const store = createScheduledTaskRunHistoryStore({ dbPath });
|
|
229
|
+
expect(store.listRuns({}).runs).toHaveLength(1);
|
|
230
|
+
startSample(store, { id: 'run-a', slotAt: 1_700_000_000_000 });
|
|
231
|
+
expect(() => startSample(store, { id: 'run-b', slotAt: 1_700_000_000_000 })).toThrow(
|
|
232
|
+
/already claimed/,
|
|
233
|
+
);
|
|
234
|
+
expect(store.listRuns({}).runs.map((run) => run.id)).toEqual(['run-a', 'legacy-run']);
|
|
235
|
+
store.close();
|
|
236
|
+
});
|
|
163
237
|
});
|
|
@@ -3,6 +3,7 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
|
|
3
3
|
import { DateTime } from 'luxon';
|
|
4
4
|
import parser from 'cron-parser';
|
|
5
5
|
import { expandSnippets } from '../opencode/snippets.js';
|
|
6
|
+
import { SCHEDULED_SLOT_CLAIMED_CODE } from './run-history-store.js';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_GLOBAL_CONCURRENCY = 4;
|
|
8
9
|
const DEFAULT_PROJECT_CONCURRENCY = 2;
|
|
@@ -535,7 +536,7 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
535
536
|
if (!task || !task.enabled) {
|
|
536
537
|
return;
|
|
537
538
|
}
|
|
538
|
-
queueTaskRun(projectID, taskID, 'scheduled');
|
|
539
|
+
queueTaskRun(projectID, taskID, 'scheduled', nextRunAt);
|
|
539
540
|
pumpQueue();
|
|
540
541
|
}, boundedDelay);
|
|
541
542
|
|
|
@@ -669,13 +670,13 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
669
670
|
}
|
|
670
671
|
};
|
|
671
672
|
|
|
672
|
-
const queueTaskRun = (projectID, taskID, reason) => {
|
|
673
|
+
const queueTaskRun = (projectID, taskID, reason, slotAt) => {
|
|
673
674
|
const taskKey = buildTaskKey(projectID, taskID);
|
|
674
675
|
if (queuedTaskKeys.has(taskKey) || runningTaskKeys.has(taskKey)) {
|
|
675
676
|
return;
|
|
676
677
|
}
|
|
677
678
|
queuedTaskKeys.add(taskKey);
|
|
678
|
-
queue.push({ projectID, taskID, reason });
|
|
679
|
+
queue.push({ projectID, taskID, reason, slotAt });
|
|
679
680
|
};
|
|
680
681
|
|
|
681
682
|
const canRunTask = (projectID) => {
|
|
@@ -1231,7 +1232,7 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
1231
1232
|
}
|
|
1232
1233
|
};
|
|
1233
1234
|
|
|
1234
|
-
const runTask = async (projectID, taskID, reason) => {
|
|
1235
|
+
const runTask = async (projectID, taskID, reason, slotAt) => {
|
|
1235
1236
|
const taskMap = tasksByProject.get(projectID);
|
|
1236
1237
|
const task = taskMap?.get(taskID);
|
|
1237
1238
|
if (!task || !task.enabled) {
|
|
@@ -1264,6 +1265,9 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
1264
1265
|
if (runHistoryStore && typeof runHistoryStore.startRun === 'function') {
|
|
1265
1266
|
try {
|
|
1266
1267
|
runID = crypto.randomUUID();
|
|
1268
|
+
const resolvedSlotAt = reason === 'scheduled' && Number.isFinite(slotAt)
|
|
1269
|
+
? Math.trunc(slotAt)
|
|
1270
|
+
: undefined;
|
|
1267
1271
|
runHistoryStore.startRun({
|
|
1268
1272
|
id: runID,
|
|
1269
1273
|
projectId: projectID,
|
|
@@ -1272,8 +1276,20 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
1272
1276
|
trigger: reason === 'manual' ? 'manual' : 'scheduled',
|
|
1273
1277
|
directory: projectPath,
|
|
1274
1278
|
startedAt: runStartedAt,
|
|
1279
|
+
...(resolvedSlotAt !== undefined ? { slotAt: resolvedSlotAt } : {}),
|
|
1275
1280
|
});
|
|
1276
1281
|
} catch (error) {
|
|
1282
|
+
if (error?.code === SCHEDULED_SLOT_CLAIMED_CODE) {
|
|
1283
|
+
const nextRunAt = computeNextRunAt(task, Date.now());
|
|
1284
|
+
if (task.enabled && Number.isFinite(nextRunAt)) {
|
|
1285
|
+
scheduleTask(projectID, taskID, nextRunAt);
|
|
1286
|
+
}
|
|
1287
|
+
logger.info?.('[ScheduledTasks] skipped duplicate scheduled slot', {
|
|
1288
|
+
projectID,
|
|
1289
|
+
taskID,
|
|
1290
|
+
});
|
|
1291
|
+
return { ok: false, skipped: true };
|
|
1292
|
+
}
|
|
1277
1293
|
errorMessage = safeErrorMessage(error);
|
|
1278
1294
|
runID = null;
|
|
1279
1295
|
logger.warn?.('[ScheduledTasks] failed to start run history', {
|
|
@@ -1547,7 +1563,7 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
1547
1563
|
queuedTaskKeys.delete(taskKey);
|
|
1548
1564
|
consumed = true;
|
|
1549
1565
|
|
|
1550
|
-
void runTask(item.projectID, item.taskID, item.reason).finally(() => {
|
|
1566
|
+
void runTask(item.projectID, item.taskID, item.reason, item.slotAt).finally(() => {
|
|
1551
1567
|
pumpQueue();
|
|
1552
1568
|
});
|
|
1553
1569
|
}
|