import assert from 'node:assert/strict' import { mkdtemp, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { labelsMatch, normalizeLabels, normalizePersistHistory, normalizeProgress, normalizeReport, normalizeWatch, projectJobState, readProgressSnapshot, selectJobsByWatch, } from '../lib/monitor.js' import { createJobRecord, emptyState, listHistoryPage, pruneRuns } from '../lib/store.js' import { claimOccurrence, publicJob } from '../lib/fire.js' import { createHostService } from '../lib/host.js' import { cronToolDefinitions } from '../lib/tools.js' test('labelsMatch any/all', () => { const job = { role: 'worker', task: 'theory', owner: 'alice' } assert.equal(labelsMatch(job, { role: 'worker', task: 'theory' }, 'all'), true) assert.equal(labelsMatch(job, { role: 'worker', owner: 'bob' }, 'all'), false) assert.equal(labelsMatch(job, { role: 'worker', owner: 'bob' }, 'any'), true) }) test('normalize watch / progress / report / persist_history', () => { assert.deepEqual(normalizeWatch({ taskId: 'abc', timeout_min: 30 }).taskId, 'abc') assert.equal(normalizeWatch({ labels: { task: 'theory' }, match: 'any' }).match, 'any') assert.throws(() => normalizeWatch({}), { code: 'INVALID_WATCH' }) const progress = normalizeProgress({ channel: { file: '进度.json' }, metrics: [{ key: 'done', label: '已完成', unit: '个' }], }) assert.equal(progress.channel.kind, 'file') assert.equal(progress.metrics[0].key, 'done') assert.equal(normalizeReport({ mode: 'periodic', delivery: 'im', interval_min: 5 }).intervalMin, 5) assert.equal(normalizePersistHistory('forever').kind, 'forever') assert.equal(normalizePersistHistory('retain:50').limit, 50) assert.equal(normalizePersistHistory('archive:https://example/archive').endpoint, 'https://example/archive') }) test('projectJobState maps run statuses', () => { const job = { id: 'j1', enabled: true, timeoutMinutes: 10, schedule: { kind: 'cron', expr: '0 * * * *', timezone: 'Asia/Shanghai' }, createdAt: 1, updatedAt: 1, } assert.equal(projectJobState(job, []).state, 'idle') assert.equal(projectJobState(job, [{ id: 'r1', status: 'queued', stateEnteredAt: 10 }]).state, 'pending') assert.equal(projectJobState(job, [{ id: 'r1', status: 'running', stateEnteredAt: 10 }]).state, 'running') assert.equal(projectJobState({ ...job, enabled: false }, []).state, 'paused') const stuck = projectJobState( job, [{ id: 'r1', status: 'running', stateEnteredAt: Date.now() - 2 * 60 * 60_000 }], Date.now(), ) assert.equal(stuck.stuck, true) }) test('createJobRecord stores monitor fields; publicJob exposes state', () => { const state = emptyState() const now = Date.parse('2026-09-10T07:00:00.000Z') const job = createJobRecord({ name: 'worker', prompt: 'work', schedule: { kind: 'at', at: new Date(now + 60_000).toISOString(), timezone: 'Asia/Shanghai' }, labels: { role: 'worker', task: 'theory' }, progress: { channel: { file: 'p.json' }, metrics: [{ key: 'done' }] }, persist_history: 'forever', }, state, now) assert.deepEqual(job.labels, { role: 'worker', task: 'theory' }) assert.equal(job.persistHistory.kind, 'forever') assert.equal(job.progress.channel.file, 'p.json') const view = publicJob(job, []) assert.equal(view.state, 'idle') assert.deepEqual(view.labels, job.labels) }) test('label overlap skips second worker', () => { const now = Date.parse('2026-09-10T07:00:00.000Z') let state = emptyState() const a = createJobRecord({ name: 'w1', prompt: 'a', schedule: { kind: 'at', at: new Date(now + 60_000).toISOString(), timezone: 'Asia/Shanghai' }, labels: { role: 'worker', task: 'theory' }, }, state, now) const b = createJobRecord({ name: 'w2', prompt: 'b', schedule: { kind: 'at', at: new Date(now + 60_000).toISOString(), timezone: 'Asia/Shanghai' }, labels: { role: 'worker', task: 'theory' }, }, state, now) state = { ...state, jobs: [a, b], runs: [{ id: 'run-a', jobId: a.id, status: 'running', scheduledAt: now, actualAt: now, stateEnteredAt: now, }] } const claimed = claimOccurrence(state, b.id, now, 'run-now', state.settings) assert.equal(claimed.decision.action, 'skip') assert.equal(claimed.decision.reason, 'label_overlap') }) test('pruneRuns forever keeps all terminals for that job', () => { const jobs = [{ id: 'j1', persistHistory: { kind: 'forever' }, }, { id: 'j2', persistHistory: { kind: 'retain', limit: 10 }, }] const runs = [] for (let i = 0; i < 30; i++) { runs.push({ id: `a${i}`, jobId: 'j1', status: 'succeeded', actualAt: i }) runs.push({ id: `b${i}`, jobId: 'j2', status: 'succeeded', actualAt: i }) } const pruned = pruneRuns(runs, 10, jobs) assert.equal(pruned.filter((r) => r.jobId === 'j1').length, 30) assert.equal(pruned.filter((r) => r.jobId === 'j2').length, 10) }) test('listHistoryPage paginates', () => { const state = { runs: [ { id: 'r3', jobId: 'j', status: 'succeeded', actualAt: 30 }, { id: 'r2', jobId: 'j', status: 'succeeded', actualAt: 20 }, { id: 'r1', jobId: 'j', status: 'succeeded', actualAt: 10 }, ], } const page1 = listHistoryPage(state, 'j', { limit: 2 }) assert.equal(page1.runs.length, 2) assert.equal(page1.nextCursor, 'r2') const page2 = listHistoryPage(state, 'j', { limit: 2, cursor: page1.nextCursor }) assert.equal(page2.runs.length, 1) assert.equal(page2.runs[0].id, 'r1') }) test('cron_query / cron_progress / cron_runs tools', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-mon-')) t.after(() => rm(dir, { recursive: true, force: true })) const progressPath = join(dir, 'progress.json') await writeFile(progressPath, JSON.stringify({ taskId: 'x', state: 'running', updatedAt: '2026-09-10T15:00:00+08:00', metrics: { total: 10, done: 3, percent: 30 }, }), 'utf8') const service = createHostService({ filePath: join(dir, 'store.json'), now: () => Date.parse('2026-09-10T07:00:00.000Z'), sessionPort: { async createAndPrompt() { return { sessionId: 's1', status: 'succeeded', summary: 'ok' } }, }, }) const tools = Object.fromEntries(cronToolDefinitions(service).map((row) => [row.name, row])) const created = await tools.cron_create.execute({ name: 'theory-worker', prompt: 'do theory', after_minutes: 5, labels: { role: 'worker', task: 'theory' }, progress: { channel: { file: progressPath }, metrics: [{ key: 'done' }, { key: 'total' }] }, persist_history: 'forever', cwd: dir, }, { agent: { session: { header: { cwd: dir } } } }) assert.equal(created.job.labels.role, 'worker') assert.equal(created.job.state, 'idle') const watcher = await tools.cron_create.execute({ name: 'theory-watch', prompt: 'watch workers via cron_query', expr: '*/5 * * * *', watch: { labels: { task: 'theory' }, match: 'all' }, report: { mode: 'on_complete', delivery: 'none' }, cwd: dir, }, { agent: { session: { header: { cwd: dir } } } }) assert.equal(watcher.job.watch.labels.task, 'theory') const queried = await tools.cron_query.execute({ labels: { task: 'theory' }, match: 'all', decode_progress: true, }, {}) assert.ok(queried.count >= 1) assert.ok(queried.items.some((item) => item.progress?.metrics?.done === 3)) const listed = await tools.cron_list.execute({ labels: { role: 'worker' }, state: 'idle' }, {}) assert.ok(listed.jobs.some((job) => job.id === created.job.id)) await service.dispatchRun(created.job.id, 'run-now') const runs = await tools.cron_runs.execute({ task_id: created.job.id, limit: 10 }, {}) assert.ok(runs.runs.length >= 1) assert.ok(runs.runs[0].stateEnteredAt) const progress = await tools.cron_progress.execute({ task_id: created.job.id }, {}) assert.equal(progress.snapshots[0].progress.metrics.done, 3) assert.deepEqual( selectJobsByWatch([created.job, watcher.job], { labels: { task: 'theory' } }).map((j) => j.name), ['theory-worker'], ) assert.deepEqual( selectJobsByWatch([created.job, watcher.job], watcher.job.watch).map((j) => j.name), ['theory-worker'], ) assert.deepEqual(normalizeLabels({ a: 1 }), { a: '1' }) assert.ok(await readProgressSnapshot(created.job)) })