97 lines
4.2 KiB
TypeScript
97 lines
4.2 KiB
TypeScript
/**
|
|
* The goal reminder: an automation that wakes the main agent every 30 minutes while a goal is open, so a stalled goal never
|
|
* waits silently. Every goal change (start, pause, resume, end) creates a sync task in its own commit; the task brings
|
|
* the automation in line with the goal as it is when the task runs. A crash leaves at most a task to rerun, and a rerun
|
|
* gets the automation an earlier try made back from its create key instead of making a second one.
|
|
*/
|
|
import { defineTask, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
|
|
import type { ConversationDocToken, Tx } from '@earendil-works/pi-durable'
|
|
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
|
import type { Host } from '../../host/js/extensions.ts'
|
|
import { AutomationsApi } from '../automations/server.ts'
|
|
import { MainAgent } from '../subagents/api.ts'
|
|
import type { Automation } from '../automations/types.ts'
|
|
import type { Goal } from './refs.ts'
|
|
|
|
export const SCHEDULE = 'every 30m'
|
|
export const reminderName = (goal: number) => `Goal reminder · goal ${goal}`
|
|
/** Each goal's reminder has its own create key: a rerun gets it back, and no goal touches another's. */
|
|
const reminderKey = (goal: number) => `goal.reminder:${goal}`
|
|
|
|
/**
|
|
* Steps at most: each makes one change (create, link, delete, pause or resume) and looks again, so a pause and resume
|
|
* that race still end on the newest goal.
|
|
*/
|
|
const STEPS = 6
|
|
|
|
type Goals = ConversationDocToken<{ next: number; goals: Goal[] }>
|
|
|
|
export function reminderTask(host: Host, Goals: Goals) {
|
|
const find = async (tx: Tx, id: number) =>
|
|
(await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find((item) => item.id === id)
|
|
/** One look: true when it changed something and must look again, false once the automation matches the goal. */
|
|
async function step(id: number): Promise<boolean> {
|
|
const automations = host.want<{ operate(args: Record<string, unknown>): Promise<unknown> }>(AutomationsApi)
|
|
// Automations is off: the goal runs without a reminder.
|
|
if (!automations) return false
|
|
// A plain copy: the document's overlay is unusable after the commit.
|
|
const goal = await host
|
|
.harness()
|
|
.commit(
|
|
async (tx) =>
|
|
await find(tx, id).then((found) => found && (JSON.parse(JSON.stringify(found)) as Goal)),
|
|
context,
|
|
)
|
|
const list = (await automations.operate({ action: 'list' })) as Automation[]
|
|
// The linked one by its id, whatever the user renamed it to; before it is linked, the one its key made.
|
|
const own = goal?.reminder
|
|
? list.find((item) => item.id === goal.reminder)
|
|
: list.find((item) => item.key === reminderKey(id))
|
|
if (!goal || goal.ended) {
|
|
if (!own) return false
|
|
await automations.operate({ action: 'delete', id: own.id })
|
|
return true
|
|
}
|
|
if (!own) {
|
|
// One the user deleted in the Automations panel stays deleted.
|
|
if (goal.reminder) return false
|
|
const mainAgent = await host.harness().commit(async tx => (await tx.doc(MainAgent, ROOT_CONVERSATION_ID))['$main-agent'], context)
|
|
await automations.operate({
|
|
action: 'create',
|
|
name: reminderName(id),
|
|
schedule: SCHEDULE,
|
|
subscribers: [mainAgent],
|
|
key: reminderKey(id),
|
|
})
|
|
return true
|
|
}
|
|
if (goal.reminder !== own.id) {
|
|
await host.harness().commit(async (tx) => {
|
|
const current = await find(tx, id)
|
|
if (current) current.reminder = own.id
|
|
}, context)
|
|
return true
|
|
}
|
|
if (own.enabled === !goal.waiting) return false
|
|
if (goal.waiting) await automations.operate({ action: 'pause', id: own.id })
|
|
else await automations.operate({ action: 'resume', id: own.id })
|
|
return true
|
|
}
|
|
return defineTask<{ goal: number }, { phase: 'sync' }, null>({
|
|
name: 'goal.reminder',
|
|
version: 1,
|
|
initial: () => ({ phase: 'sync' }),
|
|
phases: {
|
|
sync: async (task, runtime, ctx) => {
|
|
for (let i = 0; i < STEPS; i++) if (!(await step(task.input.goal))) break
|
|
await runtime.commit(
|
|
() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }),
|
|
ctx,
|
|
)
|
|
},
|
|
},
|
|
abort: (_task, runtime, ctx) =>
|
|
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
|
|
})
|
|
}
|