feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
@@ -0,0 +1,579 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
AutomationDefinitionCreateInput,
|
||||
AutomationRecurringSchedule,
|
||||
AutomationRunClaimInput,
|
||||
AutomationSchedule
|
||||
} from '../src/shared/automation'
|
||||
import { AutomationRepository, AutomationRepositoryError } from '../src/main/automations/repository'
|
||||
import {
|
||||
automationFrequency,
|
||||
getNextOccurrence,
|
||||
getOccurrencePlan,
|
||||
validateAutomationSchedule
|
||||
} from '../src/main/automations/schedule'
|
||||
import {
|
||||
AutomationScheduler,
|
||||
AutomationSchedulerError
|
||||
} from '../src/main/automations/scheduler'
|
||||
import {
|
||||
automationFormAt,
|
||||
describeSchedule,
|
||||
formToInput,
|
||||
formToPatch,
|
||||
formToSchedule,
|
||||
type AutomationFormState
|
||||
} from '../src/renderer/src/automations/automation-model'
|
||||
|
||||
let passed = 0
|
||||
|
||||
async function test(name: string, operation: () => Promise<void> | void): Promise<void> {
|
||||
await operation()
|
||||
passed += 1
|
||||
console.log(`PASS ${name}`)
|
||||
}
|
||||
|
||||
function sequenceIds(): () => string {
|
||||
let value = 0
|
||||
return () => `test-${++value}`
|
||||
}
|
||||
|
||||
function onceSchedule(scheduledAt: string, timeZone = 'UTC'): AutomationSchedule {
|
||||
return { type: 'once', scheduledAt, timeZone }
|
||||
}
|
||||
|
||||
function recurring(
|
||||
frequency: AutomationRecurringSchedule['frequency'],
|
||||
rrule: string,
|
||||
anchorAt: string,
|
||||
timeZone = 'UTC'
|
||||
): AutomationRecurringSchedule {
|
||||
return { type: 'recurring', frequency, rrule, anchorAt, timeZone }
|
||||
}
|
||||
|
||||
function input(
|
||||
schedule: AutomationSchedule,
|
||||
overrides: Partial<AutomationDefinitionCreateInput> = {}
|
||||
): AutomationDefinitionCreateInput {
|
||||
return {
|
||||
name: 'Automation test',
|
||||
prompt: 'Run the regression task.',
|
||||
schedule,
|
||||
execution: { modelId: 'model-test', permissionMode: 'default-sandbox', thinkingLevel: 'medium' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-automation-regression-'))
|
||||
|
||||
try {
|
||||
await test('validates once plus six recurring frequency classes', () => {
|
||||
const schedules: Array<[AutomationSchedule, string]> = [
|
||||
[onceSchedule('2026-01-01T10:00:00.000Z'), 'once'],
|
||||
[recurring('interval', 'FREQ=HOURLY;INTERVAL=3', '2026-01-01T10:00:00.000Z'), 'interval'],
|
||||
[recurring('daily', 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', '2026-01-01T14:00:00.000Z'), 'daily'],
|
||||
[recurring('weekly', 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE;BYHOUR=9;BYMINUTE=0', '2026-01-05T14:00:00.000Z'), 'weekly'],
|
||||
[recurring('biweekly', 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;BYHOUR=9;BYMINUTE=0', '2026-01-05T14:00:00.000Z'), 'biweekly'],
|
||||
[recurring('monthly', 'FREQ=MONTHLY;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0', '2026-01-31T14:00:00.000Z'), 'monthly'],
|
||||
[recurring('yearly', 'FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0', '2026-12-31T14:00:00.000Z'), 'yearly']
|
||||
]
|
||||
for (const [schedule, expected] of schedules) {
|
||||
validateAutomationSchedule(schedule)
|
||||
assert.equal(automationFrequency(schedule), expected)
|
||||
}
|
||||
assert.throws(
|
||||
() => validateAutomationSchedule(recurring('biweekly', 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO', '2026-01-05T14:00:00.000Z')),
|
||||
/INTERVAL=2/
|
||||
)
|
||||
assert.throws(
|
||||
() => validateAutomationSchedule(onceSchedule('2026-01-01T10:00:00.000Z', 'Mars/Olympus')),
|
||||
/IANA/
|
||||
)
|
||||
})
|
||||
|
||||
await test('serializes all seven editor frequency classes into valid schedules', () => {
|
||||
const now = new Date('2026-01-01T00:00:00.000Z')
|
||||
const base = automationFormAt(undefined, now)
|
||||
const forms: AutomationFormState[] = [
|
||||
{ ...base, frequency: 'once', onceAt: '2026-01-02T10:00' },
|
||||
{ ...base, frequency: 'interval', intervalHours: '3', weekdays: [] },
|
||||
{ ...base, frequency: 'daily', hour: '9', minute: '15' },
|
||||
{ ...base, frequency: 'weekly', weekdays: ['MO', 'FR'], hour: '9', minute: '15' },
|
||||
{ ...base, frequency: 'biweekly', weekdays: ['WE'], hour: '9', minute: '15' },
|
||||
{ ...base, frequency: 'monthly', monthDays: '1,15,31', hour: '9', minute: '15' },
|
||||
{ ...base, frequency: 'yearly', month: '2', day: '29', hour: '9', minute: '15' }
|
||||
]
|
||||
const expected = ['once', 'interval', 'daily', 'weekly', 'biweekly', 'monthly', 'yearly']
|
||||
for (const [index, form] of forms.entries()) {
|
||||
const schedule = formToSchedule(form, now)
|
||||
validateAutomationSchedule(schedule)
|
||||
assert.equal(automationFrequency(schedule), expected[index])
|
||||
assert.match(describeSchedule(schedule), /每|单次/)
|
||||
const input = formToInput(form, now)
|
||||
validateAutomationSchedule(input.schedule)
|
||||
const patch = formToPatch(form, now)
|
||||
assert.deepEqual(patch.schedule, input.schedule)
|
||||
}
|
||||
})
|
||||
|
||||
await test('editing preserves a recurring anchor and explicit validity clearing maps to null', () => {
|
||||
const schedule = recurring(
|
||||
'biweekly',
|
||||
'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;BYHOUR=9;BYMINUTE=0',
|
||||
'2026-01-05T14:00:00.000Z'
|
||||
)
|
||||
const definition: AutomationDefinition = {
|
||||
id: 'anchor-task',
|
||||
name: 'Anchor task',
|
||||
prompt: 'Keep the recurrence phase.',
|
||||
status: 'ACTIVE',
|
||||
schedule,
|
||||
execution: {},
|
||||
validFrom: '2026-01-05T14:00:00.000Z',
|
||||
revision: 2,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-02T00:00:00.000Z'
|
||||
}
|
||||
const form = automationFormAt(definition, new Date('2026-06-01T00:00:00.000Z'))
|
||||
assert.equal(form.anchorAt, schedule.anchorAt)
|
||||
const editedSchedule = formToSchedule(form)
|
||||
assert.equal(editedSchedule.type, 'recurring')
|
||||
assert.equal(editedSchedule.type === 'recurring' ? editedSchedule.anchorAt : undefined, schedule.anchorAt)
|
||||
const patch = formToPatch({ ...form, validFrom: '', validUntil: '' })
|
||||
assert.equal(patch.validFrom, null)
|
||||
assert.equal(patch.validUntil, null)
|
||||
})
|
||||
|
||||
await test('keeps daily wall time across a DST transition', () => {
|
||||
const schedule = recurring(
|
||||
'daily',
|
||||
'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
'2026-03-07T14:00:00.000Z',
|
||||
'America/New_York'
|
||||
)
|
||||
assert.equal(
|
||||
getNextOccurrence(schedule, new Date('2026-03-07T14:00:00.000Z')),
|
||||
'2026-03-08T13:00:00.000Z'
|
||||
)
|
||||
})
|
||||
|
||||
await test('skips nonexistent month days and preserves leap-day cadence', () => {
|
||||
const monthly = recurring(
|
||||
'monthly',
|
||||
'FREQ=MONTHLY;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0',
|
||||
'2026-01-31T09:00:00.000Z'
|
||||
)
|
||||
assert.equal(
|
||||
getNextOccurrence(monthly, new Date('2026-01-31T09:00:00.000Z')),
|
||||
'2026-03-31T09:00:00.000Z'
|
||||
)
|
||||
const yearly = recurring(
|
||||
'yearly',
|
||||
'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29;BYHOUR=9;BYMINUTE=0',
|
||||
'2024-02-29T09:00:00.000Z'
|
||||
)
|
||||
assert.equal(
|
||||
getNextOccurrence(yearly, new Date('2024-02-29T09:00:00.000Z')),
|
||||
'2028-02-29T09:00:00.000Z'
|
||||
)
|
||||
})
|
||||
|
||||
await test('returns only the latest due occurrence and the next future occurrence', () => {
|
||||
const schedule = recurring('interval', 'FREQ=HOURLY;INTERVAL=1', '2026-01-01T00:00:00.000Z')
|
||||
assert.deepEqual(
|
||||
getOccurrencePlan(schedule, null, new Date('2026-01-01T03:30:00.000Z')),
|
||||
{ dueAt: '2026-01-01T03:00:00.000Z', nextAt: '2026-01-01T04:00:00.000Z' }
|
||||
)
|
||||
assert.equal(
|
||||
getOccurrencePlan(schedule, '2026-01-01T03:00:00.000Z', new Date('2026-01-01T03:30:00.000Z')).dueAt,
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
await test('persists definitions and runs through one reloadable store', async () => {
|
||||
const file = join(root, 'repository.json')
|
||||
let now = new Date('2026-01-01T09:00:00.000Z')
|
||||
const repository = new AutomationRepository(file, {
|
||||
now: () => now,
|
||||
createId: sequenceIds()
|
||||
})
|
||||
const created = await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
|
||||
now = new Date('2026-01-01T09:01:00.000Z')
|
||||
const updated = await repository.updateDefinition(created.id, { name: 'Updated automation' })
|
||||
assert.equal(updated.revision, 2)
|
||||
await repository.setEnabled(created.id, false)
|
||||
const reloaded = new AutomationRepository(file)
|
||||
const definitions = await reloaded.listDefinitions()
|
||||
assert.equal(definitions.length, 1)
|
||||
assert.equal(definitions[0]?.name, 'Updated automation')
|
||||
assert.equal(definitions[0]?.status, 'PAUSED')
|
||||
assert.equal(definitions[0]?.revision, 3)
|
||||
})
|
||||
|
||||
await test('deduplicates scheduled claims and rejects overlapping runs', async () => {
|
||||
const file = join(root, 'claims.json')
|
||||
const repository = new AutomationRepository(file, {
|
||||
now: () => new Date('2026-01-01T09:00:00.000Z'),
|
||||
createId: sequenceIds()
|
||||
})
|
||||
const definition = await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
|
||||
const first = await repository.claimRun({
|
||||
automationId: definition.id,
|
||||
trigger: 'scheduled',
|
||||
scheduledFor: '2026-01-01T10:00:00.000Z'
|
||||
})
|
||||
const duplicate = await repository.claimRun({
|
||||
automationId: definition.id,
|
||||
trigger: 'scheduled',
|
||||
scheduledFor: '2026-01-01T10:00:00.000Z'
|
||||
})
|
||||
assert.equal(first.created, true)
|
||||
assert.equal(duplicate.created, false)
|
||||
assert.equal(duplicate.run.id, first.run.id)
|
||||
await assert.rejects(
|
||||
repository.claimRun({
|
||||
automationId: definition.id,
|
||||
trigger: 'manual',
|
||||
scheduledFor: '2026-01-01T10:01:00.000Z'
|
||||
}),
|
||||
(error: unknown) => error instanceof AutomationRepositoryError && error.code === 'ACTIVE_RUN'
|
||||
)
|
||||
await repository.markRunRunning(first.run.id)
|
||||
const attached = await repository.attachRunSession(first.run.id, {
|
||||
sessionId: 'session-attached',
|
||||
runId: 'chat-attached'
|
||||
})
|
||||
assert.equal(attached.sessionId, 'session-attached')
|
||||
assert.equal(attached.runId, 'chat-attached')
|
||||
await assert.rejects(
|
||||
repository.deleteDefinition(definition.id),
|
||||
(error: unknown) => error instanceof AutomationRepositoryError && error.code === 'ACTIVE_RUN'
|
||||
)
|
||||
const finished = await repository.finishRun(first.run.id, { status: 'succeeded' })
|
||||
assert.equal(finished.status, 'succeeded')
|
||||
assert.equal(finished.sessionId, 'session-attached')
|
||||
assert.equal(finished.runId, 'chat-attached')
|
||||
})
|
||||
|
||||
await test('serializes concurrent manual claims to one winner', async () => {
|
||||
const repository = new AutomationRepository(join(root, 'concurrent.json'), {
|
||||
now: () => new Date('2026-01-01T09:00:00.000Z'),
|
||||
createId: sequenceIds()
|
||||
})
|
||||
const definition = await repository.createDefinition(input(onceSchedule('2027-01-01T09:00:00.000Z')))
|
||||
const settled = await Promise.allSettled([
|
||||
repository.claimRun({ automationId: definition.id, trigger: 'manual', scheduledFor: '2026-01-01T09:01:00.000Z' }),
|
||||
repository.claimRun({ automationId: definition.id, trigger: 'manual', scheduledFor: '2026-01-01T09:02:00.000Z' })
|
||||
])
|
||||
assert.equal(settled.filter((result) => result.status === 'fulfilled').length, 1)
|
||||
const rejected = settled.find((result) => result.status === 'rejected')
|
||||
assert.ok(rejected?.status === 'rejected')
|
||||
assert.equal((rejected.reason as AutomationRepositoryError).code, 'ACTIVE_RUN')
|
||||
})
|
||||
|
||||
await test('marks an active run failed when a new repository instance recovers the file', async () => {
|
||||
const file = join(root, 'recovery.json')
|
||||
const repository = new AutomationRepository(file, {
|
||||
now: () => new Date('2026-01-01T09:00:00.000Z'),
|
||||
createId: sequenceIds()
|
||||
})
|
||||
const definition = await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
|
||||
const claim = await repository.claimRun({
|
||||
automationId: definition.id,
|
||||
trigger: 'scheduled',
|
||||
scheduledFor: '2026-01-01T10:00:00.000Z'
|
||||
})
|
||||
await repository.markRunRunning(claim.run.id)
|
||||
const recovered = new AutomationRepository(file, {
|
||||
now: () => new Date('2026-01-01T10:01:00.000Z')
|
||||
})
|
||||
const runs = await recovered.listRuns({ automationId: definition.id })
|
||||
assert.equal(runs[0]?.status, 'failed')
|
||||
assert.match(runs[0]?.error ?? '', /应用重启/)
|
||||
})
|
||||
|
||||
await test('does not cache recovered state when the repair write fails', async () => {
|
||||
const file = join(root, 'recovery-write-failure.json')
|
||||
const repository = new AutomationRepository(file, {
|
||||
now: () => new Date('2026-01-01T09:00:00.000Z'),
|
||||
createId: sequenceIds()
|
||||
})
|
||||
const definition = await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
|
||||
const claim = await repository.claimRun({
|
||||
automationId: definition.id,
|
||||
trigger: 'scheduled',
|
||||
scheduledFor: '2026-01-01T10:00:00.000Z'
|
||||
})
|
||||
await repository.markRunRunning(claim.run.id)
|
||||
|
||||
class FailingRecoveryRepository extends AutomationRepository {
|
||||
protected override async persist(): Promise<void> {
|
||||
throw new Error('模拟磁盘写入失败')
|
||||
}
|
||||
}
|
||||
const failing = new FailingRecoveryRepository(file, {
|
||||
now: () => new Date('2026-01-01T10:01:00.000Z')
|
||||
})
|
||||
await assert.rejects(failing.listRuns({ automationId: definition.id }), /模拟磁盘写入失败/)
|
||||
await assert.rejects(failing.listRuns({ automationId: definition.id }), /模拟磁盘写入失败/)
|
||||
|
||||
const recovered = new AutomationRepository(file, {
|
||||
now: () => new Date('2026-01-01T10:02:00.000Z')
|
||||
})
|
||||
const runs = await recovered.listRuns({ automationId: definition.id })
|
||||
assert.equal(runs[0]?.status, 'failed')
|
||||
assert.match(runs[0]?.error ?? '', /应用重启/)
|
||||
})
|
||||
|
||||
await test('does not overwrite a corrupt store', async () => {
|
||||
const file = join(root, 'corrupt.json')
|
||||
await writeFile(file, '{not-json', 'utf8')
|
||||
const repository = new AutomationRepository(file)
|
||||
await assert.rejects(
|
||||
repository.listDefinitions(),
|
||||
(error: unknown) => error instanceof AutomationRepositoryError && error.code === 'CORRUPT_STORE'
|
||||
)
|
||||
assert.equal(await readFile(file, 'utf8'), '{not-json')
|
||||
})
|
||||
|
||||
await test('scheduler returns a running record before the background execution finishes', async () => {
|
||||
const file = join(root, 'scheduler-once.json')
|
||||
const clock = { now: () => new Date('2026-01-01T10:00:00.000Z') }
|
||||
const repository = new AutomationRepository(file, {
|
||||
now: clock.now,
|
||||
createId: sequenceIds()
|
||||
})
|
||||
const definition = await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
|
||||
const calls: AutomationDefinition[] = []
|
||||
let changed = 0
|
||||
const scheduler = new AutomationScheduler(repository, async ({ definition: frozen, run }) => {
|
||||
calls.push(frozen)
|
||||
await repository.attachRunSession(run.id, { sessionId: 'session-created', runId: 'chat-created' })
|
||||
return { status: 'succeeded', sessionId: 'session-created', runId: 'chat-created' }
|
||||
}, { clock, lateToleranceMs: 60_000, onChanged: () => { changed += 1 } })
|
||||
const results = await scheduler.tick()
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0]?.prompt, definition.prompt)
|
||||
assert.deepEqual(results.map(({ status, missed }) => ({ status, missed })), [{ status: 'running', missed: false }])
|
||||
const finished = await scheduler.waitForRun(results[0]!.runId)
|
||||
assert.equal(finished.status, 'succeeded')
|
||||
assert.equal(finished.sessionId, 'session-created')
|
||||
assert.equal(finished.runId, 'chat-created')
|
||||
assert.ok(changed >= 3, 'queued, running and terminal transitions must notify')
|
||||
})
|
||||
|
||||
await test('scheduler records stale recurring occurrences as missed without executing', async () => {
|
||||
const file = join(root, 'scheduler-missed.json')
|
||||
const clock = { now: () => new Date('2026-01-01T03:30:00.000Z') }
|
||||
const repository = new AutomationRepository(file, {
|
||||
now: clock.now,
|
||||
createId: sequenceIds()
|
||||
})
|
||||
const definition = await repository.createDefinition(input(
|
||||
recurring('interval', 'FREQ=HOURLY;INTERVAL=1', '2026-01-01T00:00:00.000Z')
|
||||
))
|
||||
let executed = 0
|
||||
const scheduler = new AutomationScheduler(repository, async () => {
|
||||
executed += 1
|
||||
return { status: 'succeeded' }
|
||||
}, { clock, lateToleranceMs: 60_000 })
|
||||
const results = await scheduler.tick()
|
||||
assert.equal(executed, 0)
|
||||
assert.equal(results[0]?.status, 'missed')
|
||||
assert.equal(results[0]?.missed, true)
|
||||
})
|
||||
|
||||
await test('manual scheduler runs reject overlap while the first execution is active', async () => {
|
||||
const file = join(root, 'scheduler-manual.json')
|
||||
const clock = { now: () => new Date('2026-01-01T09:00:00.000Z') }
|
||||
const repository = new AutomationRepository(file, {
|
||||
now: clock.now,
|
||||
createId: sequenceIds()
|
||||
})
|
||||
const definition = await repository.createDefinition(input(onceSchedule('2027-01-01T10:00:00.000Z')))
|
||||
let release!: (value: { status: 'succeeded' }) => void
|
||||
const entered = deferred<void>()
|
||||
const scheduler = new AutomationScheduler(repository, async () => {
|
||||
entered.resolve()
|
||||
return new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
}, { clock })
|
||||
const first = await scheduler.runNow(definition.id)
|
||||
assert.equal(first.status, 'running')
|
||||
await entered.promise
|
||||
await assert.rejects(
|
||||
scheduler.runNow(definition.id),
|
||||
(error: unknown) => error instanceof AutomationSchedulerError && error.code === 'ACTIVE_RUN'
|
||||
)
|
||||
release({ status: 'succeeded' })
|
||||
assert.equal((await scheduler.waitForRun(first.id)).status, 'succeeded')
|
||||
})
|
||||
|
||||
await test('scheduler background pump claims a due task without a manual tick', async () => {
|
||||
const file = join(root, 'scheduler-background.json')
|
||||
const clock = { now: () => new Date('2026-01-01T10:00:00.000Z') }
|
||||
const repository = new AutomationRepository(file, {
|
||||
now: clock.now,
|
||||
createId: sequenceIds()
|
||||
})
|
||||
await repository.createDefinition(input(onceSchedule('2026-01-01T10:00:00.000Z')))
|
||||
const executed = deferred<void>()
|
||||
const releaseExecution = deferred<void>()
|
||||
const scheduler = new AutomationScheduler(repository, async ({ run }) => {
|
||||
await repository.attachRunSession(run.id, { sessionId: 'session-background' })
|
||||
executed.resolve()
|
||||
await releaseExecution.promise
|
||||
return { status: 'succeeded' }
|
||||
}, { clock, pollIntervalMs: 5, lateToleranceMs: 60_000 })
|
||||
|
||||
scheduler.start()
|
||||
await executed.promise
|
||||
const running = await repository.listRuns()
|
||||
assert.equal(running[0]?.status, 'running')
|
||||
releaseExecution.resolve()
|
||||
const completed = await scheduler.waitForRun(running[0]!.id)
|
||||
assert.equal(completed.status, 'succeeded')
|
||||
await scheduler.stop()
|
||||
const runs = await repository.listRuns()
|
||||
assert.equal(runs.length, 1)
|
||||
assert.equal(runs[0]?.status, 'succeeded')
|
||||
assert.equal(runs[0]?.sessionId, 'session-background')
|
||||
})
|
||||
|
||||
await test('a long run does not block later due definitions and identity is attached before terminal', async () => {
|
||||
const file = join(root, 'scheduler-multi.json')
|
||||
const clock = { now: () => new Date('2026-01-01T10:00:00.000Z') }
|
||||
const repository = new AutomationRepository(file, {
|
||||
now: clock.now,
|
||||
createId: sequenceIds()
|
||||
})
|
||||
const longDefinition = await repository.createDefinition(input(
|
||||
onceSchedule('2026-01-01T10:00:00.000Z'),
|
||||
{ name: 'Long A' }
|
||||
))
|
||||
await repository.createDefinition(input(
|
||||
onceSchedule('2026-01-01T10:00:00.000Z'),
|
||||
{ name: 'Quick B' }
|
||||
))
|
||||
const longEntered = deferred<void>()
|
||||
const releaseLong = deferred<void>()
|
||||
const scheduler = new AutomationScheduler(repository, async ({ definition, run }) => {
|
||||
await repository.attachRunSession(run.id, {
|
||||
sessionId: `session-${definition.name}`,
|
||||
runId: `chat-${run.id}`
|
||||
})
|
||||
if (definition.name === 'Long A') {
|
||||
longEntered.resolve()
|
||||
await releaseLong.promise
|
||||
}
|
||||
return { status: 'succeeded' }
|
||||
}, { clock, lateToleranceMs: 60_000 })
|
||||
|
||||
const startedAt = Date.now()
|
||||
const started = await scheduler.tick()
|
||||
assert.ok(Date.now() - startedAt < 500, 'tick must not wait for the long executor')
|
||||
assert.equal(started.length, 2)
|
||||
assert.deepEqual(started.map((run) => run.status), ['running', 'running'])
|
||||
await longEntered.promise
|
||||
const longRunId = started.find((run) => run.automationId === longDefinition.id)!.runId
|
||||
const attachedWhileRunning = await repository.getRun(longRunId)
|
||||
assert.equal(attachedWhileRunning?.status, 'running')
|
||||
assert.equal(attachedWhileRunning?.sessionId, 'session-Long A')
|
||||
releaseLong.resolve()
|
||||
await Promise.all(started.map((run) => scheduler.waitForRun(run.runId)))
|
||||
})
|
||||
|
||||
await test('scheduler shutdown aborts active execution and persists cancelled state', async () => {
|
||||
const file = join(root, 'scheduler-shutdown.json')
|
||||
const clock = { now: () => new Date('2026-01-01T10:00:00.000Z') }
|
||||
const repository = new AutomationRepository(file, {
|
||||
now: clock.now,
|
||||
createId: sequenceIds()
|
||||
})
|
||||
const definition = await repository.createDefinition(input(onceSchedule('2027-01-01T10:00:00.000Z')))
|
||||
const entered = deferred<void>()
|
||||
const scheduler = new AutomationScheduler(repository, async ({ run, signal }) => {
|
||||
await repository.attachRunSession(run.id, { sessionId: 'session-shutdown' })
|
||||
entered.resolve()
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
const error = new Error('aborted')
|
||||
error.name = 'AbortError'
|
||||
reject(error)
|
||||
}, { once: true })
|
||||
})
|
||||
}, { clock })
|
||||
const started = await scheduler.runNow(definition.id)
|
||||
assert.equal(started.status, 'running')
|
||||
await entered.promise
|
||||
await scheduler.stop()
|
||||
const cancelled = await scheduler.waitForRun(started.id)
|
||||
assert.equal(cancelled.status, 'cancelled')
|
||||
assert.equal(cancelled.sessionId, 'session-shutdown')
|
||||
assert.match(cancelled.error ?? '', /停止/)
|
||||
})
|
||||
|
||||
await test('scheduler shutdown waits for an in-flight manual claim and cancels it before returning', async () => {
|
||||
const file = join(root, 'scheduler-pending-manual.json')
|
||||
const clock = { now: () => new Date('2026-01-01T10:00:00.000Z') }
|
||||
const claimPersisted = deferred<void>()
|
||||
const releaseClaim = deferred<void>()
|
||||
class DelayedClaimRepository extends AutomationRepository {
|
||||
override async claimRun(claimInput: AutomationRunClaimInput) {
|
||||
const claimed = await super.claimRun(claimInput)
|
||||
claimPersisted.resolve()
|
||||
await releaseClaim.promise
|
||||
return claimed
|
||||
}
|
||||
}
|
||||
const repository = new DelayedClaimRepository(file, {
|
||||
now: clock.now,
|
||||
createId: sequenceIds()
|
||||
})
|
||||
const definition = await repository.createDefinition(input(onceSchedule('2027-01-01T10:00:00.000Z')))
|
||||
let executed = 0
|
||||
const scheduler = new AutomationScheduler(repository, async () => {
|
||||
executed += 1
|
||||
return { status: 'succeeded' }
|
||||
}, { clock })
|
||||
|
||||
const runOutcome = assert.rejects(
|
||||
scheduler.runNow(definition.id),
|
||||
(error: unknown) => error instanceof AutomationSchedulerError && error.code === 'STOPPING'
|
||||
)
|
||||
await claimPersisted.promise
|
||||
|
||||
let stopped = false
|
||||
const shutdown = scheduler.stop().then(() => { stopped = true })
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
assert.equal(stopped, false, 'stop must wait for an in-flight manual start')
|
||||
|
||||
releaseClaim.resolve()
|
||||
await runOutcome
|
||||
await shutdown
|
||||
assert.equal(executed, 0)
|
||||
const runs = await repository.listRuns({ automationId: definition.id, triggers: ['manual'] })
|
||||
assert.equal(runs.length, 1)
|
||||
assert.equal(runs[0]?.status, 'cancelled')
|
||||
assert.match(runs[0]?.error ?? '', /停止/)
|
||||
})
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log(`PASS automation regression (${passed} suites)`)
|
||||
Reference in new issue
Block a user