@yeaft/webchat-agent 1.0.648 → 1.0.650
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/connection/index.js +3 -0
- package/connection/message-router.js +6 -1
- package/index.js +1 -1
- package/local-runtime/server/db/connection.js +5 -2
- package/local-runtime/server/db/yeaft-project-db.js +2 -0
- package/local-runtime/server/db/yeaft-session-db.js +4 -1
- package/local-runtime/server/handlers/agent-output.js +48 -8
- package/local-runtime/server/handlers/client-conversation.js +14 -2
- package/local-runtime/server/handlers/client-scheduled-tasks.js +121 -0
- package/local-runtime/server/session-catalog.js +1 -0
- package/local-runtime/server/ws-client.js +1 -0
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +507 -322
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/spreadsheet-worker.js +2 -0
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/workbench/file-ops.js +47 -15
- package/workbench/utils.js +8 -0
- package/yeaft/sessions/scheduled-task-definition.js +74 -0
- package/yeaft/sessions/scheduled-task-scheduler.js +257 -0
- package/yeaft/sessions/session-crud.js +5 -0
- package/yeaft/sessions/session-store.js +18 -10
- package/yeaft/web-bridge.js +100 -20
|
Binary file
|
package/package.json
CHANGED
package/workbench/file-ops.js
CHANGED
|
@@ -12,6 +12,26 @@ export const MAX_WORKBENCH_PREVIEW_BYTES = 20 * 1024 * 1024;
|
|
|
12
12
|
export const WORKBENCH_FILE_CHUNK_BYTES = 1024 * 1024;
|
|
13
13
|
export const WORKBENCH_VIDEO_CHUNK_BYTES = 1024 * 1024;
|
|
14
14
|
|
|
15
|
+
// Unknown extensions may be binary. Never open lossy UTF-8 or control bytes in
|
|
16
|
+
// an editable buffer: saving that buffer could irreversibly corrupt the file.
|
|
17
|
+
export function isBinaryFileContent(buffer) {
|
|
18
|
+
if (buffer.some(byte => byte === 0 || (byte < 32 && ![9, 10, 12, 13].includes(byte)))) return true;
|
|
19
|
+
try {
|
|
20
|
+
new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
|
21
|
+
return false;
|
|
22
|
+
} catch {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertPreviewSize(size) {
|
|
28
|
+
if (size <= MAX_WORKBENCH_PREVIEW_BYTES) return;
|
|
29
|
+
const error = new Error(`File is too large to transfer (${(size / 1024 / 1024).toFixed(1)} MB). The file limit is 20 MB.`);
|
|
30
|
+
error.code = 'FILE_PREVIEW_TOO_LARGE';
|
|
31
|
+
error.details = { sizeBytes: size, limitBytes: MAX_WORKBENCH_PREVIEW_BYTES };
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
async function canonicalWorkspaceReadPath(filePath, workDir, errorMessage = 'File is outside the active workspace.') {
|
|
16
36
|
const canonicalRoot = await realpath(resolve(workDir));
|
|
17
37
|
const canonicalFile = await realpath(resolveAndValidatePath(filePath, canonicalRoot));
|
|
@@ -199,20 +219,33 @@ export async function handleReadFile(msg) {
|
|
|
199
219
|
error.code = 'VIDEO_STREAM_REQUIRED';
|
|
200
220
|
throw error;
|
|
201
221
|
}
|
|
202
|
-
|
|
203
|
-
|
|
222
|
+
let mimeType = BINARY_EXTENSIONS[ext];
|
|
223
|
+
const fileStat = await stat(resolved);
|
|
224
|
+
if (!fileStat.isFile()) throw new Error('Path is not a regular file.');
|
|
225
|
+
if (mimeType) assertPreviewSize(fileStat.size);
|
|
226
|
+
// Keep the existing text-file size behavior. Unknown binary files are
|
|
227
|
+
// classified from a bounded prefix before allocating their full contents.
|
|
228
|
+
if (!mimeType) {
|
|
229
|
+
const handle = await open(resolved, 'r');
|
|
230
|
+
try {
|
|
231
|
+
const sample = Buffer.alloc(Math.min(fileStat.size, 8192));
|
|
232
|
+
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
|
|
233
|
+
// Do not mistake a UTF-8 character split at the sample boundary for
|
|
234
|
+
// binary. Streaming decoding still rejects invalid bytes within it.
|
|
235
|
+
const prefix = sample.subarray(0, bytesRead);
|
|
236
|
+
let invalidUtf8 = false;
|
|
237
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(prefix, { stream: bytesRead < fileStat.size }); }
|
|
238
|
+
catch { invalidUtf8 = true; }
|
|
239
|
+
if (invalidUtf8 || prefix.some(byte => byte === 0 || (byte < 32 && ![9, 10, 12, 13].includes(byte)))) {
|
|
240
|
+
mimeType = 'application/octet-stream';
|
|
241
|
+
assertPreviewSize(fileStat.size);
|
|
242
|
+
}
|
|
243
|
+
} finally { await handle.close(); }
|
|
244
|
+
}
|
|
245
|
+
const buffer = await readFile(resolved);
|
|
246
|
+
if (!mimeType && isBinaryFileContent(buffer)) mimeType = 'application/octet-stream';
|
|
204
247
|
if (mimeType) {
|
|
205
|
-
|
|
206
|
-
if (fileStat.size > MAX_WORKBENCH_PREVIEW_BYTES) {
|
|
207
|
-
const error = new Error(`File is too large to transfer (${(fileStat.size / 1024 / 1024).toFixed(1)} MB). The file limit is 20 MB.`);
|
|
208
|
-
error.code = 'FILE_PREVIEW_TOO_LARGE';
|
|
209
|
-
error.details = {
|
|
210
|
-
sizeBytes: fileStat.size,
|
|
211
|
-
limitBytes: MAX_WORKBENCH_PREVIEW_BYTES,
|
|
212
|
-
};
|
|
213
|
-
throw error;
|
|
214
|
-
}
|
|
215
|
-
const buffer = await readFile(resolved);
|
|
248
|
+
assertPreviewSize(buffer.length);
|
|
216
249
|
console.log('[Agent] Sending binary file_content:', { filePath: resolved, size: buffer.length, mimeType, conversationId });
|
|
217
250
|
await sendBinaryFile(msg, {
|
|
218
251
|
conversationId,
|
|
@@ -223,8 +256,7 @@ export async function handleReadFile(msg) {
|
|
|
223
256
|
requestedFilePath: filePath,
|
|
224
257
|
}, buffer, mimeType);
|
|
225
258
|
} else {
|
|
226
|
-
|
|
227
|
-
const content = await readFile(resolved, 'utf-8');
|
|
259
|
+
const content = buffer.toString('utf-8');
|
|
228
260
|
|
|
229
261
|
// 检测语言
|
|
230
262
|
const langMap = {
|
package/workbench/utils.js
CHANGED
|
@@ -30,6 +30,14 @@ export function validateGitPath(filePath) {
|
|
|
30
30
|
// Binary file extensions → MIME type mapping
|
|
31
31
|
export const BINARY_EXTENSIONS = {
|
|
32
32
|
'.pdf': 'application/pdf',
|
|
33
|
+
'.doc': 'application/msword',
|
|
34
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4',
|
|
35
|
+
'.flac': 'audio/flac', '.aac': 'audio/aac',
|
|
36
|
+
// Unsupported formats are downloads, never decoded into the text editor.
|
|
37
|
+
...Object.fromEntries(['.zip', '.gz', '.bz2', '.xz', '.7z', '.rar', '.tar',
|
|
38
|
+
'.exe', '.dll', '.so', '.dylib', '.wasm', '.bin', '.sqlite', '.sqlite3', '.db',
|
|
39
|
+
'.woff', '.woff2', '.ttf', '.otf', '.avif', '.heic', '.tif', '.tiff']
|
|
40
|
+
.map(ext => [ext, 'application/octet-stream'])),
|
|
33
41
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
34
42
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
35
43
|
'.xls': 'application/vnd.ms-excel',
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { initialOccurrence, nextOccurrence, normalizeRecurrence, validateScheduleTimestamp } from '../work-center/recurrence.js';
|
|
3
|
+
|
|
4
|
+
const FIELDS = ['prompt', 'enabled', 'notify', 'trigger'];
|
|
5
|
+
|
|
6
|
+
function canonical(value) {
|
|
7
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
8
|
+
if (value && typeof value === 'object') {
|
|
9
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function configKey(task) {
|
|
15
|
+
return JSON.stringify(canonical({ prompt: task.prompt, enabled: task.enabled,
|
|
16
|
+
notify: task.notify !== false, trigger: task.trigger }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Legacy definitions get a stable config-only revision without a read-time disk write. */
|
|
20
|
+
export function scheduledTaskRevision(task) {
|
|
21
|
+
if (typeof task._revision === 'string' && task._revision) return task._revision;
|
|
22
|
+
return `legacy:${createHash('sha256').update(configKey(task)).digest('hex')}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Validate external task definitions only; runtime fields are never writable over the wire. */
|
|
26
|
+
export function normalizeScheduledTask(value, previous = null) {
|
|
27
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('task must be an object');
|
|
28
|
+
if (Object.keys(value).some(key => !FIELDS.includes(key))) throw new Error('Unknown or read-only task field');
|
|
29
|
+
const input = previous ? { ...publicScheduledTask(previous), ...value } : value;
|
|
30
|
+
if (typeof input.prompt !== 'string' || !input.prompt.trim() || input.prompt.length > 65536) {
|
|
31
|
+
throw new Error('task.prompt must be non-empty text of at most 65536 characters');
|
|
32
|
+
}
|
|
33
|
+
if (typeof input.enabled !== 'boolean') throw new Error('task.enabled must be a boolean');
|
|
34
|
+
if (input.notify !== undefined && typeof input.notify !== 'boolean') throw new Error('task.notify must be a boolean');
|
|
35
|
+
const trigger = input.trigger;
|
|
36
|
+
if (!trigger || typeof trigger !== 'object' || Array.isArray(trigger)) throw new Error('Invalid task.trigger');
|
|
37
|
+
let normalizedTrigger;
|
|
38
|
+
if (trigger.type === 'manual') {
|
|
39
|
+
if (Object.keys(trigger).some(key => key !== 'type')) throw new Error('Unknown manual trigger field');
|
|
40
|
+
normalizedTrigger = { type: 'manual' };
|
|
41
|
+
} else if (trigger.type === 'schedule') {
|
|
42
|
+
if (Object.keys(trigger).some(key => !['type', 'scheduledFor', 'recurrence'].includes(key))) throw new Error('Unknown schedule trigger field');
|
|
43
|
+
normalizedTrigger = { type: 'schedule', scheduledFor: validateScheduleTimestamp(trigger.scheduledFor),
|
|
44
|
+
recurrence: normalizeRecurrence(trigger.recurrence) };
|
|
45
|
+
} else throw new Error('Invalid task.trigger type');
|
|
46
|
+
const next = { prompt: input.prompt.trim(), enabled: input.enabled, notify: input.notify ?? true,
|
|
47
|
+
trigger: normalizedTrigger, nextRunAt: null, lastRun: previous?.lastRun ?? null };
|
|
48
|
+
const sameTrigger = previous && JSON.stringify(previous.trigger) === JSON.stringify(next.trigger);
|
|
49
|
+
if (sameTrigger) {
|
|
50
|
+
next._scheduledRuns = previous._scheduledRuns || 0;
|
|
51
|
+
next.nextRunAt = previous.nextRunAt;
|
|
52
|
+
} else {
|
|
53
|
+
next.nextRunAt = next.trigger.type === 'schedule'
|
|
54
|
+
? initialOccurrence(next.trigger.recurrence, next.trigger.scheduledFor) : null;
|
|
55
|
+
}
|
|
56
|
+
if (next.trigger.recurrence?.endsAt != null && next.nextRunAt > next.trigger.recurrence.endsAt) next.nextRunAt = null;
|
|
57
|
+
next._revision = previous && configKey(previous) === configKey(next) ? scheduledTaskRevision(previous) : randomUUID();
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Explicit, idempotent projection keeps durable scheduler bookkeeping out of Session snapshots. */
|
|
62
|
+
export function publicScheduledTask(task) {
|
|
63
|
+
return { prompt: task.prompt, enabled: task.enabled, notify: task.notify !== false,
|
|
64
|
+
trigger: structuredClone(task.trigger), nextRunAt: task.nextRunAt ?? null,
|
|
65
|
+
lastRun: task.lastRun ? structuredClone(task.lastRun) : null,
|
|
66
|
+
revision: typeof task.revision === 'string' && !task._revision ? task.revision : scheduledTaskRevision(task) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function advanceScheduledTask(task, after) {
|
|
70
|
+
const recurrence = task.trigger.recurrence;
|
|
71
|
+
if (!recurrence || (recurrence.maxRuns != null && (task._scheduledRuns || 0) >= recurrence.maxRuns)) return null;
|
|
72
|
+
const next = nextOccurrence(recurrence, after);
|
|
73
|
+
return next != null && (recurrence.endsAt == null || next <= recurrence.endsAt) ? next : null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { lstatSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { validateSessionId } from './ids.js';
|
|
5
|
+
import { loadSessionMeta, SESSION_META_FILE } from './session-store.js';
|
|
6
|
+
import { writeAtomic } from '../storage/atomic.js';
|
|
7
|
+
import { advanceScheduledTask, normalizeScheduledTask, publicScheduledTask } from './scheduled-task-definition.js';
|
|
8
|
+
|
|
9
|
+
// Internal-only ingress fence. Browser JSON cannot supply or bypass this hook.
|
|
10
|
+
export const SCHEDULED_TASK_DISPATCH_GUARD = Symbol('scheduled-task-dispatch-guard');
|
|
11
|
+
const POLL_MS = 1000;
|
|
12
|
+
|
|
13
|
+
/** Instance-owned scheduler. All metadata transitions are synchronous, fresh-read,
|
|
14
|
+
* atomic replacements; no openSession handle can create a deleted directory.
|
|
15
|
+
* dispatch only accepts/routes input. isActive owns the full Session lifecycle,
|
|
16
|
+
* including fanout, route-forward, pending classifications and model task reentry.
|
|
17
|
+
*/
|
|
18
|
+
export class ScheduledTaskScheduler {
|
|
19
|
+
constructor({ yeaftDir, dispatch, isActive, canDispatch = () => true, onChange = () => {}, notify = () => {}, now = Date.now,
|
|
20
|
+
warn = error => console.warn('[ScheduledTasks]', error?.message || error) }) {
|
|
21
|
+
this.yeaftDir = resolve(yeaftDir);
|
|
22
|
+
this.dispatch = dispatch;
|
|
23
|
+
this.isActive = isActive;
|
|
24
|
+
this.canDispatch = canDispatch;
|
|
25
|
+
this.waitingForContext = new Set();
|
|
26
|
+
this.onChange = onChange;
|
|
27
|
+
this.notify = notify;
|
|
28
|
+
this.now = now;
|
|
29
|
+
this.warn = warn;
|
|
30
|
+
this.runs = new Map();
|
|
31
|
+
this.retryAfter = new Map();
|
|
32
|
+
this.timer = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
read(sessionId) {
|
|
36
|
+
if (!validateSessionId(sessionId).ok) throw new Error('Invalid Session id');
|
|
37
|
+
const root = join(this.yeaftDir, 'sessions');
|
|
38
|
+
const dir = join(root, sessionId);
|
|
39
|
+
const path = join(dir, SESSION_META_FILE);
|
|
40
|
+
try {
|
|
41
|
+
const rootStat = lstatSync(root), stat = lstatSync(dir), fileStat = lstatSync(path);
|
|
42
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || !stat.isDirectory()
|
|
43
|
+
|| stat.isSymbolicLink() || !fileStat.isFile() || fileStat.isSymbolicLink()) throw new Error('Invalid Session path');
|
|
44
|
+
const meta = loadSessionMeta(dir);
|
|
45
|
+
if (!meta || meta.id !== sessionId) throw new Error('Session not found');
|
|
46
|
+
return { meta, path, identity: `${stat.dev}:${stat.ino}:${meta.createdAt}` };
|
|
47
|
+
} catch { throw new Error('Session not found or unsafe Session path'); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
ids() {
|
|
51
|
+
const root = join(this.yeaftDir, 'sessions');
|
|
52
|
+
try {
|
|
53
|
+
const stat = lstatSync(root);
|
|
54
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return [];
|
|
55
|
+
return readdirSync(root).filter(id => validateSessionId(id).ok);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code !== 'ENOENT') this.warn(error);
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
save(row) {
|
|
63
|
+
// Recheck identity rather than creating directories if deletion/recreation won.
|
|
64
|
+
if (this.read(row.meta.id).identity !== row.identity) throw new Error('Session changed');
|
|
65
|
+
row.meta.metadataUpdatedAt = new Date(this.now()).toISOString();
|
|
66
|
+
writeAtomic(row.path, `${JSON.stringify(row.meta, null, 2)}\n`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
changed() {
|
|
70
|
+
try { this.onChange(); } catch (error) { this.warn(error); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
snapshot(meta) {
|
|
74
|
+
return { ...meta, ...(meta.task ? { task: publicScheduledTask(meta.task) } : {}) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Compare the caller's config revision against fresh metadata, never runtime state. */
|
|
78
|
+
update(sessionId, patch, expectedRevision) {
|
|
79
|
+
const row = this.read(sessionId);
|
|
80
|
+
if (!row.meta.task) throw new Error('Session has no task');
|
|
81
|
+
if (typeof expectedRevision !== 'string' || expectedRevision !== row.meta.task._revision) {
|
|
82
|
+
throw Object.assign(new Error('Task changed; refresh before editing'), { code: 'task_conflict' });
|
|
83
|
+
}
|
|
84
|
+
row.meta.task = normalizeScheduledTask(patch, row.meta.task);
|
|
85
|
+
this.save(row);
|
|
86
|
+
this.changed();
|
|
87
|
+
return this.snapshot(row.meta);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
start() {
|
|
91
|
+
if (this.timer) return this;
|
|
92
|
+
for (const id of this.ids()) {
|
|
93
|
+
try { this.recover(this.read(id), true); } catch (error) { this.backoff(id, error); }
|
|
94
|
+
}
|
|
95
|
+
this.timer = setInterval(() => { this.tick().catch(error => this.warn(error)); }, POLL_MS);
|
|
96
|
+
this.timer.unref?.();
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
recover(row, skipMissed = false) {
|
|
101
|
+
const task = row.meta.task;
|
|
102
|
+
if (!task) return;
|
|
103
|
+
let dirty = false, interrupted = false;
|
|
104
|
+
// Never replay a persisted running attempt: effects may already have occurred.
|
|
105
|
+
if (task.lastRun?.status === 'running') {
|
|
106
|
+
// A newer edited trigger or a manual run must not consume a future one-shot.
|
|
107
|
+
if (task.lastRun.trigger === 'schedule' && task.trigger.type === 'schedule'
|
|
108
|
+
&& !task.trigger.recurrence && task.nextRunAt != null && task.nextRunAt <= task.lastRun.startedAt) task.nextRunAt = null;
|
|
109
|
+
task.lastRun = { ...task.lastRun, status: 'interrupted', finishedAt: this.now(), error: 'Agent restarted during execution' };
|
|
110
|
+
interrupted = true;
|
|
111
|
+
dirty = true;
|
|
112
|
+
}
|
|
113
|
+
// Recurring downtime is skipped, including an occurrence exactly at boot.
|
|
114
|
+
if (skipMissed && this.canDispatch(row.meta.id) && task.trigger.type === 'schedule' && task.trigger.recurrence
|
|
115
|
+
&& task.nextRunAt != null && task.nextRunAt <= this.now()) {
|
|
116
|
+
task.nextRunAt = advanceScheduledTask(task, this.now());
|
|
117
|
+
dirty = true;
|
|
118
|
+
}
|
|
119
|
+
if (dirty) { this.save(row); this.changed(); }
|
|
120
|
+
if (interrupted && task.notify !== false) {
|
|
121
|
+
try { this.notify({ type: 'scheduled_task_notification', sessionId: row.meta.id, runId: task.lastRun.id,
|
|
122
|
+
status: 'interrupted', taskName: row.meta.name, error: task.lastRun.error }); } catch (error) { this.warn(error); }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
stop() {
|
|
127
|
+
clearInterval(this.timer);
|
|
128
|
+
this.timer = null;
|
|
129
|
+
this.runs.clear();
|
|
130
|
+
this.waitingForContext.clear();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
backoff(id, error) {
|
|
134
|
+
this.retryAfter.set(id, this.now() + 30_000);
|
|
135
|
+
this.warn(error);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
assertReady(sessionId) {
|
|
139
|
+
if (!this.canDispatch(sessionId)) {
|
|
140
|
+
throw Object.assign(new Error('Waiting for authoritative Project context from Server; retry after synchronization'),
|
|
141
|
+
{ code: 'project_context_unavailable' });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
run(sessionId, trigger = 'manual') {
|
|
146
|
+
const row = this.read(sessionId), task = row.meta.task;
|
|
147
|
+
if (!task) throw new Error('Session has no task');
|
|
148
|
+
this.assertReady(sessionId);
|
|
149
|
+
if (this.runs.has(sessionId) || task.lastRun?.status === 'running' || this.isActive(sessionId)) throw new Error('Session is busy or task is already running');
|
|
150
|
+
if (trigger === 'schedule' && (!task.enabled || task.trigger.type !== 'schedule'
|
|
151
|
+
|| task.nextRunAt == null || task.nextRunAt > this.now())) throw new Error('Task is no longer due');
|
|
152
|
+
// Validate persisted definitions without trusting externally edited runtime fields.
|
|
153
|
+
normalizeScheduledTask({ prompt: task.prompt, enabled: task.enabled, notify: task.notify, trigger: task.trigger });
|
|
154
|
+
const run = { id: randomUUID(), status: 'running', trigger, startedAt: this.now(), finishedAt: null };
|
|
155
|
+
task.lastRun = run;
|
|
156
|
+
if (trigger === 'schedule') {
|
|
157
|
+
task._scheduledRuns = (task._scheduledRuns || 0) + 1;
|
|
158
|
+
task.nextRunAt = advanceScheduledTask(task, this.now());
|
|
159
|
+
}
|
|
160
|
+
this.save(row); // A durable claim must precede any execution side effect.
|
|
161
|
+
const active = { id: run.id, identity: row.identity, revision: task._revision, routed: false, terminal: false, error: null, interrupted: false };
|
|
162
|
+
this.runs.set(sessionId, active);
|
|
163
|
+
const guard = () => {
|
|
164
|
+
this.assertReady(sessionId);
|
|
165
|
+
const fresh = this.read(sessionId);
|
|
166
|
+
if (fresh.identity !== active.identity || fresh.meta.task?.lastRun?.id !== active.id
|
|
167
|
+
|| fresh.meta.task?._revision !== active.revision) throw new Error('Task changed before dispatch');
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
guard();
|
|
171
|
+
const result = this.dispatch({ type: 'yeaft_session_send', sessionId, id: run.id, text: task.prompt,
|
|
172
|
+
[SCHEDULED_TASK_DISPATCH_GUARD]: guard });
|
|
173
|
+
Promise.resolve(result).then(() => { active.routed = true; }, error => {
|
|
174
|
+
active.error = error?.message || String(error); active.routed = true;
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
active.error = error?.message || String(error); active.routed = true;
|
|
178
|
+
}
|
|
179
|
+
this.changed();
|
|
180
|
+
return this.snapshot(row.meta);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
observe(sessionId, event) {
|
|
184
|
+
const active = this.runs.get(sessionId);
|
|
185
|
+
if (!active) return;
|
|
186
|
+
if (event.type === 'error') active.error = event.message || event.error?.message || String(event.error || 'Execution failed');
|
|
187
|
+
if (event.type === 'vp_turn_end') {
|
|
188
|
+
active.terminal = true;
|
|
189
|
+
if (event.reason === 'errored') active.error = event.detail?.message || active.error || 'Execution failed';
|
|
190
|
+
if (event.reason === 'aborted') active.interrupted = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
finish(id, active) {
|
|
195
|
+
let row;
|
|
196
|
+
try { row = this.read(id); } catch { this.runs.delete(id); return; }
|
|
197
|
+
const task = row.meta.task;
|
|
198
|
+
if (row.identity !== active.identity || task?.lastRun?.id !== active.id) { this.runs.delete(id); return; }
|
|
199
|
+
const status = active.error ? 'failed' : active.interrupted ? 'interrupted' : active.terminal ? 'completed' : 'failed';
|
|
200
|
+
const error = active.error || (status === 'failed' ? 'Session produced no terminal execution' : null);
|
|
201
|
+
task.lastRun = { ...task.lastRun, status, finishedAt: this.now(), ...(error ? { error } : {}) };
|
|
202
|
+
// Completion never consumes or moves a schedule, especially for manual runs.
|
|
203
|
+
// Missed recurring occurrences belong to tick's explicit busy-Session policy.
|
|
204
|
+
this.save(row);
|
|
205
|
+
this.runs.delete(id);
|
|
206
|
+
this.changed();
|
|
207
|
+
if (task.notify !== false) {
|
|
208
|
+
try { this.notify({ type: 'scheduled_task_notification', sessionId: id, runId: active.id, status,
|
|
209
|
+
taskName: row.meta.name, ...(error ? { error } : {}) }); } catch (error) { this.warn(error); }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async tick() {
|
|
214
|
+
// Inspect due occurrences before retiring tracked runs: an occurrence elapsed
|
|
215
|
+
// during a manual run is busy/missed, even if its runtime just became idle.
|
|
216
|
+
// Manual run/finish themselves never change nextRunAt or _scheduledRuns.
|
|
217
|
+
for (const id of this.ids()) {
|
|
218
|
+
if ((this.retryAfter.get(id) || 0) > this.now()) continue;
|
|
219
|
+
try {
|
|
220
|
+
const row = this.read(id), task = row.meta.task;
|
|
221
|
+
const tracked = this.runs.get(id);
|
|
222
|
+
if (tracked && (row.identity !== tracked.identity || task?.lastRun?.id !== tracked.id)) this.runs.delete(id);
|
|
223
|
+
// Retry boot recovery after a transient atomic-write failure.
|
|
224
|
+
if (task?.lastRun?.status === 'running' && !this.runs.has(id)) { this.recover(row, true); continue; }
|
|
225
|
+
if (task?.enabled && task.trigger.type === 'schedule' && task.nextRunAt != null && task.nextRunAt <= this.now()) {
|
|
226
|
+
if (!this.canDispatch(id)) {
|
|
227
|
+
if (!this.waitingForContext.has(id)) {
|
|
228
|
+
this.waitingForContext.add(id);
|
|
229
|
+
this.warn(new Error(`Session ${id}: waiting for authoritative Project context from Server`));
|
|
230
|
+
}
|
|
231
|
+
continue; // Unknown is not 'no Project': do not claim, fail or consume the occurrence.
|
|
232
|
+
}
|
|
233
|
+
this.waitingForContext.delete(id);
|
|
234
|
+
const busy = this.runs.has(id) || this.isActive(id);
|
|
235
|
+
// One minute of grace tolerates timer jitter. Older recurring
|
|
236
|
+
// occurrences (or ones blocked by an active Session) are missed, not queued.
|
|
237
|
+
if (task.trigger.recurrence && (busy || this.now() - task.nextRunAt > 60_000)) {
|
|
238
|
+
task.nextRunAt = advanceScheduledTask(task, this.now());
|
|
239
|
+
this.save(row);
|
|
240
|
+
this.changed();
|
|
241
|
+
} else if (!busy) this.run(id, 'schedule');
|
|
242
|
+
}
|
|
243
|
+
} catch (error) { this.backoff(id, error); }
|
|
244
|
+
}
|
|
245
|
+
for (const [id, active] of this.runs) {
|
|
246
|
+
if ((this.retryAfter.get(id) || 0) > this.now()) continue;
|
|
247
|
+
try {
|
|
248
|
+
// Drop removed/archived/replaced Sessions even if an old engine stays alive.
|
|
249
|
+
const fresh = this.read(id);
|
|
250
|
+
if (fresh.identity !== active.identity || fresh.meta.task?.lastRun?.id !== active.id) { this.runs.delete(id); continue; }
|
|
251
|
+
if (active.routed && !this.isActive(id)) this.finish(id, active);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
try { this.read(id); this.backoff(id, error); } catch { this.runs.delete(id); }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -52,6 +52,7 @@ import { isAbsolute, join, resolve } from 'path';
|
|
|
52
52
|
import {
|
|
53
53
|
openSession, createSession, loadSessionMeta,
|
|
54
54
|
} from './session-store.js';
|
|
55
|
+
import { normalizeScheduledTask, publicScheduledTask } from './scheduled-task-definition.js';
|
|
55
56
|
import { addVp as rosterAdd, removeVp as rosterRemove, setDefaultVp } from './roster.js';
|
|
56
57
|
import { seedDefaultSession, DEFAULT_SESSION_ID } from './seed-default.js';
|
|
57
58
|
import { nextSessionId, validateVpId, isReservedVpId } from './ids.js';
|
|
@@ -511,6 +512,8 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
|
|
|
511
512
|
*/
|
|
512
513
|
export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
513
514
|
const input = spec || {};
|
|
515
|
+
// Task validation precedes discovery repair and every filesystem mutation.
|
|
516
|
+
if (input.task !== undefined) normalizeScheduledTask(input.task);
|
|
514
517
|
const normalizedWorkDir = normalizeWorkDir(input.workDir);
|
|
515
518
|
const workspaceKey = canonicalWorkspaceKey(normalizedWorkDir);
|
|
516
519
|
ensureSessionManifestReady(yeaftDir);
|
|
@@ -553,6 +556,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
|
553
556
|
|
|
554
557
|
const handle = createSession(root, {
|
|
555
558
|
id, name, roster, defaultVpId, workDir: normalizedWorkDir, workspaceKey,
|
|
559
|
+
...(input.task !== undefined ? { task: input.task } : {}),
|
|
556
560
|
});
|
|
557
561
|
const meta = handle.getMeta();
|
|
558
562
|
handle.close();
|
|
@@ -998,6 +1002,7 @@ export function snapshotSessions(yeaftDir) {
|
|
|
998
1002
|
// render the effective model without re-querying.
|
|
999
1003
|
for (const meta of byId.values()) {
|
|
1000
1004
|
meta.config = loadSessionConfig(yeaftDir, meta.id);
|
|
1005
|
+
if (meta.task) meta.task = publicScheduledTask(meta.task);
|
|
1001
1006
|
}
|
|
1002
1007
|
return Array.from(byId.values()).sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
|
|
1003
1008
|
}
|
|
@@ -30,6 +30,8 @@ import { join } from 'path';
|
|
|
30
30
|
import { writeAtomic, openLog } from '../storage/index.js';
|
|
31
31
|
import { nextMsgId, isReservedVpId, ReservedVpIdError, validateVpId, InvalidVpIdError } from './ids.js';
|
|
32
32
|
|
|
33
|
+
import { normalizeScheduledTask, scheduledTaskRevision } from './scheduled-task-definition.js';
|
|
34
|
+
|
|
33
35
|
export const SESSION_META_FILE = 'session.json';
|
|
34
36
|
export const LEGACY_GROUP_META_FILE = 'group.json';
|
|
35
37
|
const MESSAGES_DIR = 'messages';
|
|
@@ -48,12 +50,6 @@ export function openSession(sessionsRoot, sessionId) {
|
|
|
48
50
|
const dir = join(sessionsRoot, sessionId);
|
|
49
51
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
50
52
|
|
|
51
|
-
let meta = loadSessionMeta(dir);
|
|
52
|
-
if (!meta) {
|
|
53
|
-
// Fresh session — caller must call createSession() next; we return a handle
|
|
54
|
-
// with meta=null so createSession() can write the initial file.
|
|
55
|
-
}
|
|
56
|
-
|
|
57
53
|
const messagesDir = join(dir, MESSAGES_DIR);
|
|
58
54
|
if (!existsSync(messagesDir)) mkdirSync(messagesDir, { recursive: true });
|
|
59
55
|
const log = openLog(messagesDir);
|
|
@@ -61,12 +57,21 @@ export function openSession(sessionsRoot, sessionId) {
|
|
|
61
57
|
return {
|
|
62
58
|
dir,
|
|
63
59
|
id: sessionId,
|
|
64
|
-
/**
|
|
65
|
-
getMeta() { return
|
|
66
|
-
/**
|
|
60
|
+
/** Read fresh: coordinators may retain this handle across external CRUD. */
|
|
61
|
+
getMeta() { return loadSessionMeta(dir); },
|
|
62
|
+
/**
|
|
63
|
+
* Save Session metadata atomically. After creation, the scheduler exclusively
|
|
64
|
+
* owns task definitions/runtime. Preserve its fresh on-disk value even when
|
|
65
|
+
* a caller retained an older metadata snapshot across a run or task edit.
|
|
66
|
+
*/
|
|
67
67
|
saveMeta(next) {
|
|
68
68
|
validateMeta(next);
|
|
69
|
-
|
|
69
|
+
const current = loadSessionMeta(dir);
|
|
70
|
+
const meta = structuredClone(next);
|
|
71
|
+
if (current) {
|
|
72
|
+
if (Object.hasOwn(current, 'task')) meta.task = current.task;
|
|
73
|
+
else delete meta.task;
|
|
74
|
+
}
|
|
70
75
|
writeAtomic(join(dir, SESSION_META_FILE), JSON.stringify(meta, null, 2));
|
|
71
76
|
},
|
|
72
77
|
/**
|
|
@@ -121,6 +126,7 @@ export function openSession(sessionsRoot, sessionId) {
|
|
|
121
126
|
*/
|
|
122
127
|
export function createSession(sessionsRoot, spec) {
|
|
123
128
|
if (!spec || !spec.id) throw new Error('createSession: spec.id required');
|
|
129
|
+
const task = spec.task === undefined ? undefined : normalizeScheduledTask(spec.task);
|
|
124
130
|
const h = openSession(sessionsRoot, spec.id);
|
|
125
131
|
if (h.getMeta()) {
|
|
126
132
|
throw new Error(`session ${spec.id} already exists`);
|
|
@@ -138,6 +144,7 @@ export function createSession(sessionsRoot, spec) {
|
|
|
138
144
|
}
|
|
139
145
|
const meta = {
|
|
140
146
|
id: spec.id,
|
|
147
|
+
...(task ? { task } : {}),
|
|
141
148
|
name: spec.name || spec.id,
|
|
142
149
|
roster,
|
|
143
150
|
defaultVpId: spec.defaultVpId || null,
|
|
@@ -168,6 +175,7 @@ export function loadSessionMeta(dir) {
|
|
|
168
175
|
if (typeof parsed.announcement !== 'string') parsed.announcement = '';
|
|
169
176
|
if (typeof parsed.workDir !== 'string') parsed.workDir = '';
|
|
170
177
|
if (typeof parsed.workspaceKey !== 'string') parsed.workspaceKey = '';
|
|
178
|
+
if (parsed.task) parsed.task._revision = scheduledTaskRevision(parsed.task);
|
|
171
179
|
return parsed;
|
|
172
180
|
} catch {
|
|
173
181
|
return null;
|