feat(schedule): add durable after package
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Strict Schedule decoding, replay, time validation, and framing.
|
||||
* @module @deepseek-ai/dsh-tool-schedule
|
||||
*/
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
AfterScheduleRecord,
|
||||
ScheduleChange,
|
||||
ScheduleId as ScheduleIdType,
|
||||
ScheduleReminderPresentation,
|
||||
ScheduleView,
|
||||
} from './types.ts'
|
||||
|
||||
/** Durable Schedule protocol version implemented by this package. */
|
||||
export const SCHEDULE_CHANGE_VERSION = 1 as const
|
||||
|
||||
/** Key used by the generic Host/client event-presentation slot. */
|
||||
export const SCHEDULE_REMINDER_PRESENTATION_KEY = 'schedule/reminder'
|
||||
|
||||
const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z')
|
||||
const UTC_INSTANT = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/
|
||||
|
||||
/** Error from malformed or transition-invalid durable Schedule data. */
|
||||
export class ScheduleLogError extends Error {
|
||||
/** Stable machine-readable error code. */
|
||||
readonly code = 'corrupt_schedule_log' as const
|
||||
|
||||
/**
|
||||
* Construct a durable-log failure.
|
||||
* @param message - Package-specific violated invariant.
|
||||
*/
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ScheduleLogError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Error from a model-supplied after rule that cannot become a record. */
|
||||
export class ScheduleInputError extends Error {
|
||||
/** Stable public Schedule input code. */
|
||||
readonly code: 'invalid_prompt' | 'invalid_rule' | 'time_out_of_range'
|
||||
|
||||
/**
|
||||
* Construct a stable input failure.
|
||||
* @param code - Public Schedule error discriminator.
|
||||
* @param message - Stable public diagnostic.
|
||||
*/
|
||||
constructor(
|
||||
code: 'invalid_prompt' | 'invalid_rule' | 'time_out_of_range',
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ScheduleInputError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure replay result, retaining active create order and every used id. */
|
||||
export interface FoldedSchedules {
|
||||
/** Active records in their original create order. */
|
||||
readonly active: readonly AfterScheduleRecord[]
|
||||
/** Every id ever created in this session-local suffix. */
|
||||
readonly seenIds: readonly ScheduleIdType[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand a raw session-local id without changing its runtime value.
|
||||
* @param value - Raw session-local id.
|
||||
* @returns The same string with the Schedule brand.
|
||||
*/
|
||||
export function ScheduleId(value: string): ScheduleIdType {
|
||||
return value as ScheduleIdType
|
||||
}
|
||||
|
||||
/** Whether an unknown value is a non-array object. */
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Require exactly the named durable object keys. */
|
||||
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
||||
const keys = Object.keys(value).sort()
|
||||
const wanted = [...expected].sort()
|
||||
return keys.length === wanted.length && keys.every((key, index) => key === wanted[index])
|
||||
}
|
||||
|
||||
/** Validate one stable session-local id at the durable boundary. */
|
||||
function decodeId(value: unknown): ScheduleIdType {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.trim() !== value) {
|
||||
throw new ScheduleLogError('schedule id must be a non-empty string without surrounding whitespace')
|
||||
}
|
||||
return ScheduleId(value)
|
||||
}
|
||||
|
||||
/** Validate one canonical four-digit-year UTC instant. */
|
||||
function decodeInstant(value: unknown): string {
|
||||
if (typeof value !== 'string' || !UTC_INSTANT.test(value)) {
|
||||
throw new ScheduleLogError('scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant')
|
||||
}
|
||||
const epoch = Date.parse(value)
|
||||
if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value) {
|
||||
throw new ScheduleLogError('scheduledAt is not a real UTC calendar instant')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Decode the exact v1 after record shape. */
|
||||
function decodeAfterRecord(value: unknown): AfterScheduleRecord {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['id', 'kind', 'prompt', 'afterSeconds', 'scheduledAt'])) {
|
||||
throw new ScheduleLogError('after schedule must contain exactly id, kind, prompt, afterSeconds, and scheduledAt')
|
||||
}
|
||||
if (value['kind'] !== 'after') throw new ScheduleLogError('v1 schedule kind must be "after"')
|
||||
const prompt = value['prompt']
|
||||
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
|
||||
throw new ScheduleLogError('after prompt must be non-empty and already trimmed')
|
||||
}
|
||||
const afterSeconds = value['afterSeconds']
|
||||
if (!Number.isSafeInteger(afterSeconds) || (afterSeconds as number) <= 0) {
|
||||
throw new ScheduleLogError('afterSeconds must be a positive safe integer')
|
||||
}
|
||||
return Object.freeze({
|
||||
id: decodeId(value['id']),
|
||||
kind: 'after',
|
||||
prompt,
|
||||
afterSeconds: afterSeconds as number,
|
||||
scheduledAt: decodeInstant(value['scheduledAt']),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one strict version-1 `schedule/change` payload.
|
||||
* @param value - Untrusted durable JSON value.
|
||||
* @returns Detached, frozen Schedule change.
|
||||
*/
|
||||
export function decodeScheduleChange(value: unknown): ScheduleChange {
|
||||
if (!isRecord(value)) throw new ScheduleLogError('schedule/change payload must be an object')
|
||||
if (value['version'] !== SCHEDULE_CHANGE_VERSION) {
|
||||
throw new ScheduleLogError('schedule/change version must be 1')
|
||||
}
|
||||
switch (value['operation']) {
|
||||
case 'create':
|
||||
if (!hasExactKeys(value, ['version', 'operation', 'schedule'])) {
|
||||
throw new ScheduleLogError('schedule create must contain exactly version, operation, and schedule')
|
||||
}
|
||||
return Object.freeze({
|
||||
version: SCHEDULE_CHANGE_VERSION,
|
||||
operation: 'create',
|
||||
schedule: decodeAfterRecord(value['schedule']),
|
||||
})
|
||||
case 'delete':
|
||||
case 'dispatch': {
|
||||
if (!hasExactKeys(value, ['version', 'operation', 'id'])) {
|
||||
throw new ScheduleLogError(`schedule ${value['operation']} must contain exactly version, operation, and id`)
|
||||
}
|
||||
return Object.freeze({
|
||||
version: SCHEDULE_CHANGE_VERSION,
|
||||
operation: value['operation'],
|
||||
id: decodeId(value['id']),
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new ScheduleLogError('schedule/change operation must be create, delete, or dispatch')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the package-owned stream after the durable fork seed boundary.
|
||||
* @param events - Complete ordered session log or candidate-extended log.
|
||||
* @param seedLength - Inherited prefix length excluded from child ownership.
|
||||
* @returns Active records and all previously used ids.
|
||||
*/
|
||||
export function foldScheduleEvents(
|
||||
events: readonly SessionEvent[],
|
||||
seedLength = 0,
|
||||
): FoldedSchedules {
|
||||
if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) {
|
||||
throw new ScheduleLogError('schedule seedLength must be within the supplied event log')
|
||||
}
|
||||
const active = new Map<ScheduleIdType, AfterScheduleRecord>()
|
||||
const seen = new Set<ScheduleIdType>()
|
||||
for (const event of events.slice(seedLength)) {
|
||||
if (event.type !== 'schedule/change') continue
|
||||
const change = decodeScheduleChange(event.data)
|
||||
switch (change.operation) {
|
||||
case 'create':
|
||||
if (seen.has(change.schedule.id)) {
|
||||
throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`)
|
||||
}
|
||||
seen.add(change.schedule.id)
|
||||
active.set(change.schedule.id, change.schedule)
|
||||
break
|
||||
case 'delete':
|
||||
case 'dispatch':
|
||||
if (!active.delete(change.id)) {
|
||||
throw new ScheduleLogError(`schedule ${change.operation} targets inactive id ${JSON.stringify(change.id)}`)
|
||||
}
|
||||
break
|
||||
/* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */
|
||||
default: {
|
||||
const unreachable: never = change
|
||||
throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
active: Object.freeze([...active.values()]),
|
||||
seenIds: Object.freeze([...seen]),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate the next readable id without reusing any prior session-local id.
|
||||
* @param folded - Fold containing every previously created id.
|
||||
* @returns A fresh `schedule-N` identity.
|
||||
*/
|
||||
export function allocateScheduleId(folded: FoldedSchedules): ScheduleIdType {
|
||||
const seen = new Set(folded.seenIds)
|
||||
let sequence = seen.size + 1
|
||||
let candidate = ScheduleId(`schedule-${sequence}`)
|
||||
while (seen.has(candidate)) {
|
||||
sequence += 1
|
||||
candidate = ScheduleId(`schedule-${sequence}`)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a model after rule and compute its durable target.
|
||||
* @param id - Already allocated session-local id.
|
||||
* @param prompt - User-authored reminder content.
|
||||
* @param afterSeconds - Requested positive delay.
|
||||
* @param now - Single creation-time wall-clock sample in epoch milliseconds.
|
||||
* @returns Frozen durable after record.
|
||||
*/
|
||||
export function createAfterScheduleRecord(
|
||||
id: ScheduleIdType,
|
||||
prompt: string,
|
||||
afterSeconds: number,
|
||||
now: number,
|
||||
): AfterScheduleRecord {
|
||||
const normalizedPrompt = prompt.trim()
|
||||
if (normalizedPrompt.length === 0) {
|
||||
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.')
|
||||
}
|
||||
if (!Number.isSafeInteger(afterSeconds) || afterSeconds <= 0) {
|
||||
throw new ScheduleInputError('invalid_rule', 'after_seconds must be a positive safe integer.')
|
||||
}
|
||||
const delay = afterSeconds * 1_000
|
||||
const target = now + delay
|
||||
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(delay)
|
||||
|| !Number.isSafeInteger(target) || target <= now || target > MAX_FOUR_DIGIT_YEAR_MS) {
|
||||
throw new ScheduleInputError(
|
||||
'time_out_of_range',
|
||||
'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',
|
||||
)
|
||||
}
|
||||
const scheduledAt = new Date(target).toISOString()
|
||||
/* v8 ignore next -- a safe target within the four-digit Date range always formats canonically. */
|
||||
if (!UTC_INSTANT.test(scheduledAt)) {
|
||||
throw new ScheduleInputError(
|
||||
'time_out_of_range',
|
||||
'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',
|
||||
)
|
||||
}
|
||||
return Object.freeze({
|
||||
id,
|
||||
kind: 'after',
|
||||
prompt: normalizedPrompt,
|
||||
afterSeconds,
|
||||
scheduledAt,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive one execution-local management view.
|
||||
* @param record - Active durable record.
|
||||
* @param now - Wall-clock sample used for its timing state.
|
||||
* @returns Complete session-local view.
|
||||
*/
|
||||
export function scheduleView(record: AfterScheduleRecord, now: number): ScheduleView {
|
||||
return Object.freeze({
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
prompt: record.prompt,
|
||||
afterSeconds: record.afterSeconds,
|
||||
scheduledAt: record.scheduledAt,
|
||||
state: now >= Date.parse(record.scheduledAt) ? 'overdue' : 'scheduled',
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the Web receipt for one dispatch from its owning stream segment.
|
||||
* A dispatch inside an inherited fork prefix folds that original prefix; a
|
||||
* child-owned dispatch folds only the child suffix, preserving the same
|
||||
* `seedLength` ownership rule as the live runtime while still allowing a
|
||||
* persisted parent receipt to render in child history.
|
||||
* @param events - Complete contiguous Session log.
|
||||
* @param dispatchSeq - Exact event seq to present.
|
||||
* @param seedLength - Inherited fork prefix length.
|
||||
* @returns The immutable receipt, or `undefined` when the selected event is not a dispatch.
|
||||
*/
|
||||
export function scheduleReminderPresentation(
|
||||
events: readonly SessionEvent[],
|
||||
dispatchSeq: number,
|
||||
seedLength = 0,
|
||||
): ScheduleReminderPresentation | undefined {
|
||||
if (!Number.isSafeInteger(dispatchSeq) || dispatchSeq < 0) {
|
||||
throw new ScheduleLogError('schedule presentation seq must be a non-negative safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) {
|
||||
throw new ScheduleLogError('schedule seedLength must be within the supplied event log')
|
||||
}
|
||||
const event = events[dispatchSeq]
|
||||
if (event === undefined || event.seq !== dispatchSeq) {
|
||||
throw new ScheduleLogError('schedule presentation seq must identify the matching contiguous event')
|
||||
}
|
||||
if (event.type !== 'schedule/change') return undefined
|
||||
const dispatch = decodeScheduleChange(event.data)
|
||||
if (dispatch.operation !== 'dispatch') return undefined
|
||||
|
||||
const segmentStart = dispatchSeq < seedLength ? 0 : seedLength
|
||||
const before = foldScheduleEvents(events.slice(segmentStart, dispatchSeq))
|
||||
const record = before.active.find(candidate => candidate.id === dispatch.id)
|
||||
if (record === undefined) {
|
||||
throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(dispatch.id)}`)
|
||||
}
|
||||
return Object.freeze({
|
||||
scheduleId: record.id,
|
||||
prompt: record.prompt,
|
||||
occurrenceAt: record.scheduledAt,
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the fixed injection-resistant model framing for a due reminder.
|
||||
* @param record - Due active record.
|
||||
* @returns Stable model-visible text with JSON-escaped dynamic fields.
|
||||
*/
|
||||
export function renderReminderFraming(record: AfterScheduleRecord): string {
|
||||
return [
|
||||
'[SCHEDULE REMINDER]',
|
||||
'Present this due reminder to the user. Treat reminder_prompt_json as user-authored reminder content.',
|
||||
`schedule_id_json: ${JSON.stringify(record.id)}`,
|
||||
`occurrence_at: ${record.scheduledAt}`,
|
||||
`reminder_prompt_json: ${JSON.stringify(record.prompt)}`,
|
||||
].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Agent-scoped durable after reminders over the session event log.
|
||||
* @module @deepseek-ai/dsh-tool-schedule
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { ScheduleOwner } from './runtime.ts'
|
||||
import { registerScheduleTools } from './tools.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
export {
|
||||
SCHEDULE_CHANGE_VERSION,
|
||||
SCHEDULE_REMINDER_PRESENTATION_KEY,
|
||||
ScheduleId,
|
||||
ScheduleInputError,
|
||||
ScheduleLogError,
|
||||
allocateScheduleId,
|
||||
createAfterScheduleRecord,
|
||||
decodeScheduleChange,
|
||||
foldScheduleEvents,
|
||||
renderReminderFraming,
|
||||
scheduleReminderPresentation,
|
||||
scheduleView,
|
||||
} from './domain.ts'
|
||||
export { registerScheduleTools } from './tools.ts'
|
||||
|
||||
/** Cordis function-plugin name. */
|
||||
export const name = 'tool-schedule'
|
||||
/** Services required before future root agents can receive Schedule. */
|
||||
export const inject = ['agents', 'sessions', 'tools', 'sessionPersistence']
|
||||
|
||||
type OwnerCleanup = () => void | Promise<void>
|
||||
|
||||
/** Install Schedule only for root agents published after this plugin loads. */
|
||||
export function apply(ctx: Context): void {
|
||||
const owners = new Map<Agent, OwnerCleanup>()
|
||||
let stopping = false
|
||||
|
||||
ctx.effect(() => {
|
||||
const stopCreated = ctx.on('agent/created', (agent) => {
|
||||
if (stopping || owners.has(agent) || !ctx.agents.roots().includes(agent)) return
|
||||
const owner = new ScheduleOwner(ctx, agent)
|
||||
const cleanup: OwnerCleanup = agent.ctx.effect(() => {
|
||||
const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, () => { owner.requestDrive() })
|
||||
const stopStatus = agent.ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') owner.requestDrive()
|
||||
})
|
||||
owner.start()
|
||||
return async () => {
|
||||
stopStatus()
|
||||
disposeTools()
|
||||
try {
|
||||
await owner.dispose()
|
||||
} finally {
|
||||
if (owners.get(agent) === cleanup) owners.delete(agent)
|
||||
}
|
||||
}
|
||||
}, 'tool-schedule.owner()')
|
||||
owners.set(agent, cleanup)
|
||||
})
|
||||
|
||||
return async () => {
|
||||
stopping = true
|
||||
stopCreated()
|
||||
const cleanups = [...owners.values()]
|
||||
owners.clear()
|
||||
await Promise.allSettled(cleanups.map(cleanup => Promise.resolve(cleanup())))
|
||||
}
|
||||
}, 'tool-schedule.lifecycle()')
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Package-owned strict Schedule stream invariant.
|
||||
* @module @deepseek-ai/dsh-tool-schedule/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { foldScheduleEvents, ScheduleLogError } from './domain.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-schedule'
|
||||
|
||||
/** Cordis invariant-companion plugin name. */
|
||||
export const name = 'tool-schedule-invariant'
|
||||
/** Service required before reserving this package's invariant ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Validate a complete exact-session stream under its fork suffix policy. */
|
||||
function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void {
|
||||
try {
|
||||
foldScheduleEvents(events, seedLength)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- foldScheduleEvents normalizes every rejected stream to ScheduleLogError. */
|
||||
if (!(error instanceof ScheduleLogError)) throw error
|
||||
fail(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Install replay and pre-append validation for the owned event stream. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
validate(session.events, session.header.seedLength ?? 0, fail)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (event.type !== 'schedule/change') return
|
||||
validate([...session.events, event], session.header.seedLength ?? 0, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the package-owned invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant registry.
|
||||
* @returns Exact registration disposer after child setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Schedule-owned use of the shared session durability barrier. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Failure to prove that the current live prefix reached a persistence listener. */
|
||||
export class SchedulePersistenceError extends Error {
|
||||
/**
|
||||
* Construct a contained persistence failure.
|
||||
* @param cause - Rejection returned by the shared barrier, when present.
|
||||
*/
|
||||
constructor(cause?: unknown) {
|
||||
super('Schedule persistence did not complete.', cause === undefined ? undefined : { cause })
|
||||
this.name = 'SchedulePersistenceError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require one successful shared persistence checkpoint.
|
||||
* @param ctx - Context carrying the live session store.
|
||||
* @param session - Exact live session to checkpoint.
|
||||
* @returns After at least one listener explicitly acknowledges completed durability work.
|
||||
*/
|
||||
export async function flushSchedulePersistence(ctx: Context, session: Session): Promise<void> {
|
||||
try {
|
||||
if (!await ctx.sessions.flush(session)) throw new SchedulePersistenceError()
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SchedulePersistenceError) throw error
|
||||
throw new SchedulePersistenceError(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Disposable live timer projection for one exact root agent.
|
||||
* @module @deepseek-ai/dsh-tool-schedule
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { AfterScheduleRecord } from './types.ts'
|
||||
import { foldScheduleEvents, renderReminderFraming, ScheduleLogError } from './domain.ts'
|
||||
import { flushSchedulePersistence } from './persistence.ts'
|
||||
|
||||
/** Largest delay that Node timers represent without clamping. */
|
||||
export const MAX_TIMER_DELAY_MS = 2_147_483_647
|
||||
|
||||
/** Select the earliest target while preserving create order for ties. */
|
||||
function earliest(records: readonly AfterScheduleRecord[]): AfterScheduleRecord | undefined {
|
||||
let selected: AfterScheduleRecord | undefined
|
||||
let selectedAt = Number.POSITIVE_INFINITY
|
||||
for (const record of records) {
|
||||
const target = Date.parse(record.scheduledAt)
|
||||
if (target < selectedAt) {
|
||||
selected = record
|
||||
selectedAt = target
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
/** Render an unknown value for process-local diagnostics only. */
|
||||
function renderThrown(value: unknown): string {
|
||||
return value instanceof Error ? value.message : String(value)
|
||||
}
|
||||
|
||||
/** One process-local, disposable projection of an exact agent's durable schedules. */
|
||||
export class ScheduleOwner {
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
private idleWait: Promise<void> | undefined
|
||||
private run: Promise<void> | undefined
|
||||
private requested = false
|
||||
private stopping = false
|
||||
private faulted = false
|
||||
private disposal: Promise<void> | undefined
|
||||
|
||||
/**
|
||||
* Construct an inactive owner; {@link start} begins the first preflight.
|
||||
* @param ctx - Global service context.
|
||||
* @param agent - Exact live root agent.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly agent: Agent,
|
||||
) {}
|
||||
|
||||
/** Begin the initial durability preflight and timer derivation. */
|
||||
start(): void {
|
||||
this.requestDrive()
|
||||
}
|
||||
|
||||
/** Recompute the live projection after a committed mutation or idle transition. */
|
||||
requestDrive(): void {
|
||||
if (this.stopping || this.faulted) return
|
||||
this.clearTimer()
|
||||
this.requested = true
|
||||
if (this.run !== undefined) return
|
||||
let run: Promise<void>
|
||||
try {
|
||||
run = this.ctx.agents.withoutInitiator(() => this.runRequested())
|
||||
} catch (error: unknown) {
|
||||
if (this.isLive()) {
|
||||
this.ctx.logger.warn(`tool-schedule: could not start owner for agent "${this.agent.id}": ${renderThrown(error)}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.run = run
|
||||
void run.then(
|
||||
() => { this.retire(run) },
|
||||
(error: unknown) => {
|
||||
if (this.isLive()) {
|
||||
this.ctx.logger.warn(`tool-schedule: owner failed for agent "${this.agent.id}": ${renderThrown(error)}`)
|
||||
}
|
||||
this.faulted = true
|
||||
this.retire(run)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Stop future work, cancel timers, and await every outstanding owner promise. */
|
||||
dispose(): Promise<void> {
|
||||
return (this.disposal ??= (async () => {
|
||||
this.stopping = true
|
||||
this.requested = false
|
||||
this.clearTimer()
|
||||
const pending = [this.run, this.idleWait].filter((value): value is Promise<void> => value !== undefined)
|
||||
await Promise.allSettled(pending)
|
||||
})())
|
||||
}
|
||||
|
||||
/** Drain coalesced triggers serially. */
|
||||
private async runRequested(): Promise<void> {
|
||||
while (this.requested && !this.stopping && !this.faulted) {
|
||||
this.requested = false
|
||||
await this.driveOnce()
|
||||
}
|
||||
}
|
||||
|
||||
/** Retire one exact run and honor a trigger that landed during its final microtask. */
|
||||
private retire(run: Promise<void>): void {
|
||||
/* v8 ignore next -- only the exact stored run installs this callback. */
|
||||
if (this.run !== run) return
|
||||
this.run = undefined
|
||||
/* v8 ignore next -- covers a trigger in the promise-settlement microtask gap. */
|
||||
if (this.requested && !this.stopping && !this.faulted) this.requestDrive()
|
||||
}
|
||||
|
||||
/** Whether this exact root lifecycle remains authoritative. */
|
||||
private isLive(): boolean {
|
||||
return this.ctx.agents.get(this.agent.id) === this.agent
|
||||
&& this.ctx.agents.roots().includes(this.agent)
|
||||
}
|
||||
|
||||
/** Cancel the currently armed timer, if any. */
|
||||
private clearTimer(): void {
|
||||
if (this.timer === undefined) return
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
}
|
||||
|
||||
/** Arm one bounded timer segment; every wake rechecks the wall clock. */
|
||||
private arm(target: number, now: number): void {
|
||||
const delay = Math.min(target - now, MAX_TIMER_DELAY_MS)
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined
|
||||
this.requestDrive()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** Await one public idle boundary without holding admission or creating a retry timer. */
|
||||
private waitForIdle(): void {
|
||||
if (this.idleWait !== undefined) return
|
||||
const wait = this.agent.whenIdle()
|
||||
this.idleWait = wait
|
||||
void wait.then(
|
||||
() => {
|
||||
this.idleWait = undefined
|
||||
this.requestDrive()
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.idleWait = undefined
|
||||
if (this.isLive()) {
|
||||
this.ctx.logger.warn(`tool-schedule: idle wait failed for agent "${this.agent.id}": ${renderThrown(error)}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Preflight, fold, arm, or dispatch the next active one-shot reminder. */
|
||||
private async driveOnce(): Promise<void> {
|
||||
this.clearTimer()
|
||||
if (this.stopping || !this.isLive()) return
|
||||
try {
|
||||
await flushSchedulePersistence(this.ctx, this.agent.session)
|
||||
} catch (error: unknown) {
|
||||
if (this.isLive()) {
|
||||
this.ctx.logger.warn(`tool-schedule: preflight failed for agent "${this.agent.id}": ${renderThrown(error)}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal or replacement can win while persistence is awaited.
|
||||
if (this.stopping || !this.isLive()) return
|
||||
|
||||
let record: AfterScheduleRecord | undefined
|
||||
try {
|
||||
const folded = foldScheduleEvents(
|
||||
this.agent.session.events,
|
||||
this.agent.session.header.seedLength ?? 0,
|
||||
)
|
||||
record = earliest(folded.active)
|
||||
} catch (error: unknown) {
|
||||
this.faulted = true
|
||||
const detail = error instanceof ScheduleLogError ? error.message : renderThrown(error)
|
||||
this.ctx.logger.warn(`tool-schedule: corrupt schedule log for agent "${this.agent.id}": ${detail}`)
|
||||
return
|
||||
}
|
||||
if (record === undefined) return
|
||||
|
||||
const target = Date.parse(record.scheduledAt)
|
||||
const wakeNow = Date.now()
|
||||
if (wakeNow < target) {
|
||||
this.arm(target, wakeNow)
|
||||
return
|
||||
}
|
||||
|
||||
const release = this.agent.reserveTurnAdmission()
|
||||
if (release === undefined) {
|
||||
this.waitForIdle()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- reservation can invalidate the owner.
|
||||
if (this.stopping || !this.isLive()) return
|
||||
const decisionNow = Date.now()
|
||||
if (decisionNow < target) {
|
||||
this.arm(target, decisionNow)
|
||||
return
|
||||
}
|
||||
const message = createUserMessage({
|
||||
content: [{ type: 'text', text: renderReminderFraming(record) }],
|
||||
source: { kind: 'plugin', plugin: 'tool-schedule' },
|
||||
})
|
||||
try {
|
||||
this.agent.followup(message)
|
||||
} catch (error: unknown) {
|
||||
if (this.isLive()) {
|
||||
this.ctx.logger.warn(`tool-schedule: followup failed for agent "${this.agent.id}": ${renderThrown(error)}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: record.id,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.faulted = true
|
||||
this.clearTimer()
|
||||
this.ctx.logger.warn(`tool-schedule: dispatch append failed for agent "${this.agent.id}": ${renderThrown(error)}`)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
|
||||
try {
|
||||
await flushSchedulePersistence(this.ctx, this.agent.session)
|
||||
} catch (error: unknown) {
|
||||
if (this.isLive()) {
|
||||
this.ctx.logger.warn(`tool-schedule: dispatch barrier failed for agent "${this.agent.id}": ${renderThrown(error)}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal can win while the barrier is awaited.
|
||||
if (!this.stopping && this.isLive()) this.requestDrive()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Agent-scoped Schedule management tools over the durable session fold.
|
||||
* @module @deepseek-ai/dsh-tool-schedule
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
allocateScheduleId,
|
||||
createAfterScheduleRecord,
|
||||
foldScheduleEvents,
|
||||
ScheduleId,
|
||||
ScheduleInputError,
|
||||
ScheduleLogError,
|
||||
scheduleView,
|
||||
} from './domain.ts'
|
||||
import { flushSchedulePersistence } from './persistence.ts'
|
||||
import type {
|
||||
AfterScheduleRecord,
|
||||
PersistenceUncertainError,
|
||||
ScheduleCreateValue,
|
||||
ScheduleDeleteValue,
|
||||
ScheduleId as ScheduleIdType,
|
||||
ScheduleListValue,
|
||||
SchedulePersistenceOperation,
|
||||
ScheduleToolError,
|
||||
} from './types.ts'
|
||||
|
||||
const VIEW_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
kind: { type: 'string', required: true, const: 'after' },
|
||||
prompt: { type: 'string', required: true },
|
||||
afterSeconds: { type: 'integer', required: true },
|
||||
scheduledAt: { type: 'string', required: true },
|
||||
state: { type: 'string', required: true, enum: ['scheduled', 'overdue'] },
|
||||
deliveryMode: { type: 'string', required: true, const: 'session-local' },
|
||||
},
|
||||
} as const
|
||||
|
||||
/** Build one exact two-field error schema while preserving its literal code. */
|
||||
function basicErrorSchema<const C extends string>(code: C) {
|
||||
return {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
code: { type: 'string', required: true, const: code },
|
||||
message: { type: 'string', required: true },
|
||||
},
|
||||
} as const
|
||||
}
|
||||
|
||||
const BASIC_ERROR_SCHEMAS = [
|
||||
basicErrorSchema('invalid_prompt'),
|
||||
basicErrorSchema('invalid_selector'),
|
||||
basicErrorSchema('invalid_rule'),
|
||||
basicErrorSchema('time_out_of_range'),
|
||||
basicErrorSchema('corrupt_schedule_log'),
|
||||
basicErrorSchema('internal_error'),
|
||||
] as const
|
||||
|
||||
const PERSISTENCE_ERROR_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
code: { type: 'string', required: true, const: 'persistence_uncertain' },
|
||||
message: { type: 'string', required: true },
|
||||
operation: { type: 'string', required: true, enum: ['create', 'list', 'delete', 'dispatch'] },
|
||||
id: { type: 'string' },
|
||||
},
|
||||
} as const
|
||||
|
||||
const ERROR_SCHEMAS = [...BASIC_ERROR_SCHEMAS, PERSISTENCE_ERROR_SCHEMA] as const
|
||||
|
||||
const CREATE_OUTPUT_SCHEMA = { oneOf: [VIEW_SCHEMA, ...ERROR_SCHEMAS] } as const
|
||||
const LIST_OUTPUT_SCHEMA = {
|
||||
oneOf: [
|
||||
{ type: 'array', items: VIEW_SCHEMA },
|
||||
...ERROR_SCHEMAS,
|
||||
],
|
||||
} as const
|
||||
const DELETE_OUTPUT_SCHEMA = {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
deleted: { type: 'boolean', required: true, const: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
deleted: { type: 'boolean', required: true, const: false },
|
||||
code: { type: 'string', required: true, const: 'schedule_not_found' },
|
||||
},
|
||||
},
|
||||
...ERROR_SCHEMAS,
|
||||
],
|
||||
} as const
|
||||
|
||||
const CREATE_DESCRIPTION =
|
||||
'Create one reminder in the current session. v1 accepts only a non-empty prompt and a positive '
|
||||
+ 'safe-integer after_seconds delay. Delivery is session-local: the reminder runs on time only '
|
||||
+ 'while this session is live and otherwise becomes overdue until the session is resumed.'
|
||||
|
||||
const LIST_DESCRIPTION =
|
||||
'List every active reminder in the current session in creation order, including its exact id, '
|
||||
+ 'UTC target, scheduled or overdue state, and session-local delivery mode.'
|
||||
|
||||
const DELETE_DESCRIPTION =
|
||||
'Delete one active reminder in the current session by the exact id returned by schedule_create '
|
||||
+ 'or schedule_list. Unknown or already-finished ids return deleted false.'
|
||||
|
||||
/** Deterministic model content for every canonical Schedule value. */
|
||||
function renderValue(_args: unknown, value: unknown): ContentBlock[] {
|
||||
// The ToolRegistry has already validated the value against the lossless-JSON output schema.
|
||||
const text = JSON.stringify(value)
|
||||
return [{ type: 'text', text }]
|
||||
}
|
||||
|
||||
/** Pure generic pending card. */
|
||||
function present(title: string, kind: 'read' | 'other', rawInput?: unknown): GenericCallView {
|
||||
return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } }
|
||||
}
|
||||
|
||||
/** Stable error for failures not safe to expose. */
|
||||
function internalError(): ScheduleToolError {
|
||||
return { code: 'internal_error', message: 'The schedule operation failed.' }
|
||||
}
|
||||
|
||||
/** Stable durable-log failure. */
|
||||
function corruptLogError(): ScheduleToolError {
|
||||
return { code: 'corrupt_schedule_log', message: 'The session schedule log is corrupt.' }
|
||||
}
|
||||
|
||||
/** Stable persistence uncertainty with the known operation identity. */
|
||||
function persistenceError(
|
||||
operation: SchedulePersistenceOperation,
|
||||
id?: ScheduleIdType,
|
||||
): PersistenceUncertainError {
|
||||
return {
|
||||
code: 'persistence_uncertain',
|
||||
message: 'Schedule persistence is uncertain; retry with schedule_list before relying on this result.',
|
||||
operation,
|
||||
...id === undefined ? {} : { id },
|
||||
}
|
||||
}
|
||||
|
||||
/** Translate a contained input failure to the closed tool union. */
|
||||
function inputError(error: ScheduleInputError): ScheduleToolError {
|
||||
return { code: error.code, message: error.message }
|
||||
}
|
||||
|
||||
/** Fold only after a successful preflight, mapping corruption to a stable value. */
|
||||
function foldForTool(agent: Agent): ReturnType<typeof foldScheduleEvents> | ScheduleToolError {
|
||||
try {
|
||||
return foldScheduleEvents(agent.session.events, agent.session.header.seedLength ?? 0)
|
||||
} catch (error: unknown) {
|
||||
return error instanceof ScheduleLogError ? corruptLogError() : internalError()
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a fold attempt produced an error rather than replay state. */
|
||||
function isToolError(
|
||||
value: ReturnType<typeof foldScheduleEvents> | ScheduleToolError,
|
||||
): value is ScheduleToolError {
|
||||
return 'code' in value
|
||||
}
|
||||
|
||||
/** Require one persistence checkpoint without leaking the backend failure. */
|
||||
async function preflight(
|
||||
rootCtx: Context,
|
||||
agent: Agent,
|
||||
operation: SchedulePersistenceOperation,
|
||||
id?: ScheduleIdType,
|
||||
): Promise<PersistenceUncertainError | undefined> {
|
||||
try {
|
||||
await flushSchedulePersistence(rootCtx, agent.session)
|
||||
return undefined
|
||||
} catch {
|
||||
return persistenceError(operation, id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the v1 selector constraints that the open parameter root cannot express. */
|
||||
function validateCreateArgs(args: { prompt: string; after_seconds: number }): ScheduleToolError | undefined {
|
||||
const keys = Object.keys(args as unknown as Record<string, unknown>)
|
||||
if (keys.some(key => key !== 'prompt' && key !== 'after_seconds')) {
|
||||
return {
|
||||
code: 'invalid_selector',
|
||||
message: 'schedule_create accepts exactly the after_seconds selector in this version.',
|
||||
}
|
||||
}
|
||||
if (args.prompt.trim().length === 0) {
|
||||
return { code: 'invalid_prompt', message: 'prompt must be non-empty after trimming.' }
|
||||
}
|
||||
if (!Number.isSafeInteger(args.after_seconds) || args.after_seconds <= 0) {
|
||||
return { code: 'invalid_rule', message: 'after_seconds must be a positive safe integer.' }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all three Schedule tools in one exact agent scope.
|
||||
* @param rootCtx - Global service context owning sessions and durability.
|
||||
* @param toolCtx - Exact agent-scoped context receiving the definitions.
|
||||
* @param agent - Exact live owner whose session the tools mutate.
|
||||
* @param onDurableChange - Called after a create or actual delete barrier succeeds.
|
||||
* @returns Idempotent aggregate disposer for the three registrations.
|
||||
*/
|
||||
export function registerScheduleTools(
|
||||
rootCtx: Context,
|
||||
toolCtx: Context,
|
||||
agent: Agent,
|
||||
onDurableChange: () => void,
|
||||
): () => void {
|
||||
const disposers: Array<() => void> = []
|
||||
|
||||
/** A projection observer cannot reverse a completed durability barrier. */
|
||||
const notifyDurableChange = (): void => {
|
||||
try {
|
||||
onDurableChange()
|
||||
} catch (error: unknown) {
|
||||
rootCtx.logger.warn(`tool-schedule: durable-change observer failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
disposers.push(toolCtx.tools.register(defineTool({
|
||||
name: 'schedule_create',
|
||||
description: CREATE_DESCRIPTION,
|
||||
parameters: {
|
||||
prompt: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Reminder content to present when the target becomes due.',
|
||||
},
|
||||
after_seconds: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
description: 'Positive safe-integer delay in seconds.',
|
||||
},
|
||||
},
|
||||
output: { schema: CREATE_OUTPUT_SCHEMA, render: renderValue },
|
||||
async execute(args, exec): Promise<ScheduleCreateValue> {
|
||||
if (exec.agent !== agent) return internalError()
|
||||
const invalid = validateCreateArgs(args)
|
||||
if (invalid !== undefined) return invalid
|
||||
const uncertain = await preflight(rootCtx, agent, 'create')
|
||||
if (uncertain !== undefined) return uncertain
|
||||
notifyDurableChange()
|
||||
const folded = foldForTool(agent)
|
||||
if (isToolError(folded)) return folded
|
||||
const id = allocateScheduleId(folded)
|
||||
let record: AfterScheduleRecord
|
||||
try {
|
||||
record = createAfterScheduleRecord(id, args.prompt, args.after_seconds, Date.now())
|
||||
} catch (error: unknown) {
|
||||
return error instanceof ScheduleInputError ? inputError(error) : internalError()
|
||||
}
|
||||
try {
|
||||
agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: record,
|
||||
})
|
||||
} catch {
|
||||
return internalError()
|
||||
}
|
||||
const barrier = await preflight(rootCtx, agent, 'create', id)
|
||||
if (barrier !== undefined) return barrier
|
||||
notifyDurableChange()
|
||||
return scheduleView(record, Date.now())
|
||||
},
|
||||
presentCall: args => present('Create reminder', 'other', args.prompt),
|
||||
})))
|
||||
|
||||
disposers.push(toolCtx.tools.register(defineTool({
|
||||
name: 'schedule_list',
|
||||
description: LIST_DESCRIPTION,
|
||||
parameters: {},
|
||||
output: { schema: LIST_OUTPUT_SCHEMA, render: renderValue },
|
||||
async execute(_args, exec): Promise<ScheduleListValue> {
|
||||
if (exec.agent !== agent) return internalError()
|
||||
const uncertain = await preflight(rootCtx, agent, 'list')
|
||||
if (uncertain !== undefined) return uncertain
|
||||
notifyDurableChange()
|
||||
const folded = foldForTool(agent)
|
||||
if (isToolError(folded)) return folded
|
||||
const now = Date.now()
|
||||
return folded.active.map(record => scheduleView(record, now))
|
||||
},
|
||||
presentCall: () => present('List reminders', 'read'),
|
||||
})))
|
||||
|
||||
disposers.push(toolCtx.tools.register(defineTool({
|
||||
name: 'schedule_delete',
|
||||
description: DELETE_DESCRIPTION,
|
||||
parameters: {
|
||||
id: { type: 'string', required: true, description: 'Exact session-local schedule id.' },
|
||||
},
|
||||
output: { schema: DELETE_OUTPUT_SCHEMA, render: renderValue },
|
||||
async execute(args, exec): Promise<ScheduleDeleteValue> {
|
||||
const id = ScheduleId(args.id)
|
||||
if (exec.agent !== agent) return internalError()
|
||||
const uncertain = await preflight(rootCtx, agent, 'delete', id)
|
||||
if (uncertain !== undefined) return uncertain
|
||||
notifyDurableChange()
|
||||
const folded = foldForTool(agent)
|
||||
if (isToolError(folded)) return folded
|
||||
if (!folded.active.some(record => record.id === id)) {
|
||||
return { id, deleted: false, code: 'schedule_not_found' }
|
||||
}
|
||||
try {
|
||||
agent.session.append('schedule/change', { version: 1, operation: 'delete', id })
|
||||
} catch {
|
||||
return internalError()
|
||||
}
|
||||
const barrier = await preflight(rootCtx, agent, 'delete', id)
|
||||
if (barrier !== undefined) return barrier
|
||||
notifyDurableChange()
|
||||
return { id, deleted: true }
|
||||
},
|
||||
presentCall: args => present('Delete reminder', 'other', args.id),
|
||||
})))
|
||||
} catch (error) {
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
throw error
|
||||
}
|
||||
|
||||
let active = true
|
||||
return () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Durable and model-facing Schedule value types.
|
||||
* @module @deepseek-ai/dsh-tool-schedule
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Stable reminder identity that is unique and never reused within one session. */
|
||||
export type ScheduleId = Branded<'ScheduleId'>
|
||||
|
||||
/** Durable one-shot reminder created from a positive delay. */
|
||||
export interface AfterScheduleRecord {
|
||||
/** Session-local stable identity. */
|
||||
readonly id: ScheduleId
|
||||
/** Rule discriminator; v1 supports only delayed one-shot reminders. */
|
||||
readonly kind: 'after'
|
||||
/** Trimmed user-authored reminder content. */
|
||||
readonly prompt: string
|
||||
/** Positive safe-integer delay accepted at creation. */
|
||||
readonly afterSeconds: number
|
||||
/** Four-digit-year RFC 3339 UTC target. */
|
||||
readonly scheduledAt: string
|
||||
}
|
||||
|
||||
/** The v1 durable reminder record union. */
|
||||
export type ScheduleRecord = AfterScheduleRecord
|
||||
|
||||
/** Creates one durable reminder record. */
|
||||
export interface ScheduleCreateChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'create'
|
||||
readonly schedule: ScheduleRecord
|
||||
}
|
||||
|
||||
/** Deletes one currently active reminder. */
|
||||
export interface ScheduleDeleteChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'delete'
|
||||
readonly id: ScheduleId
|
||||
}
|
||||
|
||||
/** Records that one active one-shot reminder entered the durable dispatch history. */
|
||||
export interface ScheduleDispatchChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'dispatch'
|
||||
readonly id: ScheduleId
|
||||
}
|
||||
|
||||
/** Strict version-1 durable Schedule mutation union. */
|
||||
export type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange
|
||||
|
||||
/** Current delivery timing derived from the durable record and wall clock. */
|
||||
export type ScheduleState = 'scheduled' | 'overdue'
|
||||
|
||||
/** Fixed v1 delivery boundary: the original session must be live. */
|
||||
export type ScheduleDeliveryMode = 'session-local'
|
||||
|
||||
/** Complete model-facing view of one active after reminder. */
|
||||
export interface ScheduleView extends AfterScheduleRecord {
|
||||
/** Whether the target remains in the future. */
|
||||
readonly state: ScheduleState
|
||||
/** Reminder delivery never leaves the owning session. */
|
||||
readonly deliveryMode: ScheduleDeliveryMode
|
||||
}
|
||||
|
||||
/** JSON-compatible Web receipt derived from one durable dispatch. */
|
||||
export interface ScheduleReminderPresentation {
|
||||
/** Session-local reminder identity. */
|
||||
readonly scheduleId: ScheduleId
|
||||
/** Original user-authored reminder content. */
|
||||
readonly prompt: string
|
||||
/** Scheduled one-shot occurrence represented by the dispatch. */
|
||||
readonly occurrenceAt: string
|
||||
/** Fixed delivery boundary rendered by the client plugin. */
|
||||
readonly deliveryMode: ScheduleDeliveryMode
|
||||
}
|
||||
|
||||
/** Operations whose persistence barrier may be uncertain. */
|
||||
export type SchedulePersistenceOperation = 'create' | 'list' | 'delete' | 'dispatch'
|
||||
|
||||
/** Stable error returned for an empty reminder prompt. */
|
||||
export interface InvalidPromptError {
|
||||
readonly code: 'invalid_prompt'
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Stable error returned for a missing, conflicting, or unsupported rule selector. */
|
||||
export interface InvalidSelectorError {
|
||||
readonly code: 'invalid_selector'
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Stable error returned for an invalid after delay. */
|
||||
export interface InvalidRuleError {
|
||||
readonly code: 'invalid_rule'
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Stable error returned when the computed instant cannot use a four-digit UTC year. */
|
||||
export interface TimeOutOfRangeError {
|
||||
readonly code: 'time_out_of_range'
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Stable error returned when the durable Schedule stream is malformed. */
|
||||
export interface CorruptScheduleLogError {
|
||||
readonly code: 'corrupt_schedule_log'
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Stable error returned when a required persistence checkpoint did not complete. */
|
||||
export interface PersistenceUncertainError {
|
||||
readonly code: 'persistence_uncertain'
|
||||
readonly message: string
|
||||
readonly operation: SchedulePersistenceOperation
|
||||
readonly id?: ScheduleId
|
||||
}
|
||||
|
||||
/** Stable fallback that does not disclose an internal exception. */
|
||||
export interface InternalScheduleError {
|
||||
readonly code: 'internal_error'
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Closed v1 Schedule management error union. */
|
||||
export type ScheduleToolError =
|
||||
| InvalidPromptError
|
||||
| InvalidSelectorError
|
||||
| InvalidRuleError
|
||||
| TimeOutOfRangeError
|
||||
| CorruptScheduleLogError
|
||||
| PersistenceUncertainError
|
||||
| InternalScheduleError
|
||||
|
||||
/** Canonical `schedule_create` value. */
|
||||
export type ScheduleCreateValue = ScheduleView | ScheduleToolError
|
||||
|
||||
/** Canonical `schedule_list` value. */
|
||||
export type ScheduleListValue = ScheduleView[] | ScheduleToolError
|
||||
|
||||
/** Successful `schedule_delete` value, including the non-mutating not-found result. */
|
||||
export type ScheduleDeleteResult =
|
||||
| { readonly id: ScheduleId; readonly deleted: true }
|
||||
| { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' }
|
||||
|
||||
/** Canonical `schedule_delete` value. */
|
||||
export type ScheduleDeleteValue = ScheduleDeleteResult | ScheduleToolError
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* Versioned Schedule mutation. The owning package validates the complete
|
||||
* session-local transition stream before accepting a candidate event.
|
||||
*/
|
||||
'schedule/change': ScheduleChange
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user