Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/agent-scheduler/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,19 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one.
shared one changes its file, which the person commits. The rows the project's skills
bring are not edited or removed from the page. Picked over a page that edits any
scheduled skill: a skill's file is its author's.
- A row has one place for its pace on the Automations page, whoever made it: its Edit
panel, the same for every row. For a row a person made, the pace picked there is the
automation's own, saved into its file with its prompt and its check, and their machine
keeps only a time of day. For a row a skill brings, it is that machine's pick, and the
skill's pace is one of the choices. Picked over two places, the automation's pace in one
form and the machine's pick in another, which a person could not tell apart.
- A person may start one run of a row at once, from the Automations page: whatever its
switch, without waiting for its pace, and whatever quota is left, as for any run a person
starts. A row with a check still starts only when the check finds work, and its run is
handed what the check printed; the number of agents at once holds, and so does that its
skill is where a run's checkout starts. The run counts as the row's last start, so its
pace counts from it. Picked over starting the command from the launcher: that run gets
neither the row's publish pick nor what its check found.
- The state, `.agent-scheduler/state.json`, untracked, per user, hidden through git's
exclude file the way `.branches/` is: on or off, keep-alive, the model, the spend
cushion, this machine's switches, paces, numbers of agents and publish picks, the
Expand Down Expand Up @@ -180,8 +193,8 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one.
scheduled command, with its switch, its pace, its number of agents and its publish pick),
a Settings section (the spend cushion) and an Overview card (on or off, the last tick).
All read `status` and write through `offset`, `switch`, `pace`, `agents`, `publish`,
`add`, `edit` and `remove`; an automation is read through `show`, and a check is tried
through `try`.
`add`, `edit` and `remove`; an automation is read through `show`, a check is tried
through `try`, and a row is started at once through `now`.
Picked over a hook line per setting that a dashboard runs, and over the dashboard reading
the state file by name. The rows are on a page of their own, picked over rows inside
Settings: Settings keeps only what reaches every project.
243 changes: 88 additions & 155 deletions packages/agent-scheduler/dashboard/AutomationForm.tsx

Large diffs are not rendered by default.

1,879 changes: 1,202 additions & 677 deletions packages/agent-scheduler/dashboard/AutomationsPage.test.tsx

Large diffs are not rendered by default.

629 changes: 255 additions & 374 deletions packages/agent-scheduler/dashboard/AutomationsPage.tsx

Large diffs are not rendered by default.

327 changes: 327 additions & 0 deletions packages/agent-scheduler/dashboard/EditPanel.tsx

Large diffs are not rendered by default.

95 changes: 65 additions & 30 deletions packages/agent-scheduler/dashboard/automation-form.test.ts

Large diffs are not rendered by default.

114 changes: 73 additions & 41 deletions packages/agent-scheduler/dashboard/automation-form.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
import { MAX_NAME, isCommandName } from '../src/names.js'
import { MAX_COUNT, parseInterval, type PaceUnit } from '../src/pace.js'
import { pace } from './schedulers.js'
import { parseInterval } from '../src/pace.js'
import { draftOf, pace, paceArgs, paceProblem, type PaceDraft, type SchedulerCommand } from './schedulers.js'

// The automation form as data: what a person has typed so far, what still keeps it from being
// saved, the command that saves it (`agent-scheduler add …` for a new one, `edit …` for one opened
// from its row), the sentence its row would say, what trying its shell line answered
// (`agent-scheduler try …`), and what the page says before and after one is removed
// (`agent-scheduler remove …`).
// A person's own automation as data: what they have typed so far, into the "New automation" form
// or into the Edit panel of a row they made; what still keeps it from being saved; the commands
// that save it (`agent-scheduler add …` for a new one, `edit …` for one opened from its row, and
// `pace …` for a time of day, which is this machine's); the sentence its row would say; what
// trying its shell line answered (`agent-scheduler try …`); and what the page says before and
// after one is removed (`agent-scheduler remove …`).

/** The form while a person fills it in. The count is text, as typed, so a half-typed one is no pace yet. */
/** When a person's own automation runs, while they pick: on a pace, with a time of day for days or more, or by its shell line alone. Never "as the skill says": it is no skill's. */
export type OwnPace = Exclude<PaceDraft, { kind: 'skill' }>

/** A person's own automation while they fill it in. The count and the time are text, as typed, so a half-typed one is no pace yet. */
export interface AutomationDraft {
/** The command's name, without its slash. */
name: string
/** What the agent is told. */
prompt: string
/** Whether it runs on a pace: every so many of a unit. Without one, its shell line alone says when. */
paced: boolean
count: string
unit: PaceUnit
/** The shell line that says there is work; empty for none. */
when: string
/** What the shell line waits for, in plain words; said on the row. */
waitsFor: string
/** When it runs: on a pace, or whenever its shell line prints something. */
pace: OwnPace
/** Whether it is kept on this machine alone, and not shared with the project as a file to commit. */
onThisMachine: boolean
}
Expand All @@ -34,38 +36,48 @@ export interface AutomationDraft {
export const TEXT_MAX = 4000

/** The form as it opens: once a day, no shell line, shared with the project. */
export const EMPTY_DRAFT: AutomationDraft = { name: '', prompt: '', paced: true, count: '1', unit: 'd', when: '', waitsFor: '', onThisMachine: false }
export const EMPTY_DRAFT: AutomationDraft = { name: '', prompt: '', when: '', waitsFor: '', pace: { kind: 'every', count: '1', unit: 'd', at: '' }, onThisMachine: false }

/** The interval a draft's pace is, as a skill writes it (`15m`); nothing without a pace, or while the count is no whole number the tool takes. */
/** The interval a draft's pace is, as a skill writes it (`15m`); nothing without a pace, or while the pace typed is none yet. */
function everyOf(draft: AutomationDraft): string | undefined {
if (!draft.paced || !/^\d+$/.test(draft.count.trim())) return undefined
return parseInterval(`${draft.count.trim()}${draft.unit}`)?.text
return draft.pace.kind === 'every' ? paceArgs(draft.pace)?.[0] : undefined
}

/** An automation opened from its row to be saved again: the form as it opens, and the file it would write anew. */
/** An automation opened from its row to be saved again: what its file says, as the panel opens, and the file it would write anew. */
export interface OpenedAutomation {
draft: AutomationDraft
/** Its file, from the project's root. */
file: string
}

/**
* Read what `agent-scheduler show` printed into the form it opens. Nothing when the answer is not
* what the command promises, a name and a prompt at the least, or holds a pace the form cannot
* show: the form would then save something other than what is there.
* Read what `agent-scheduler show` printed into the draft a panel opens with. Nothing when the
* answer is not what the command promises, a name and a prompt at the least, or holds a pace the
* panel cannot show: it would then save something other than what is there.
*/
export function openedAutomation(output: unknown): OpenedAutomation | undefined {
const answer = typeof output === 'object' && output !== null ? (output as Record<string, unknown>) : {}
const { name, prompt, every, when, waitsFor, file } = answer
if (typeof name !== 'string' || typeof prompt !== 'string' || typeof file !== 'string') return undefined
const pace = every === undefined ? undefined : typeof every === 'string' ? parseInterval(every) : undefined
if (every !== undefined && !pace) return undefined
const read = every === undefined ? undefined : typeof every === 'string' ? parseInterval(every) : undefined
if (every !== undefined && !read) return undefined
return {
draft: { name, prompt, paced: pace !== undefined, count: String(pace?.count ?? EMPTY_DRAFT.count), unit: pace?.unit ?? EMPTY_DRAFT.unit, when: typeof when === 'string' ? when : '', waitsFor: typeof waitsFor === 'string' ? waitsFor : '', onThisMachine: answer['onThisMachine'] === true },
draft: { name, prompt, when: typeof when === 'string' ? when : '', waitsFor: typeof waitsFor === 'string' ? waitsFor : '', pace: read ? { kind: 'every', count: String(read.count), unit: read.unit, at: '' } : { kind: 'work' }, onThisMachine: answer['onThisMachine'] === true },
file,
}
}

/**
* The draft a row's panel opens with: what its file says, at the pace its row runs at on this
* machine. A row has one pace on the page: the time of day a person picked here is shown with the
* file's interval, and a pick of theirs made before, another interval or "whenever there is
* work", stands in for the file's until the panel is saved, which writes it into the file.
*/
export function atRowsPace(opened: AutomationDraft, row: SchedulerCommand): AutomationDraft {
const picked = draftOf(row)
return picked.kind === 'skill' ? opened : { ...opened, pace: picked }
}

/**
* What keeps a draft from being saved yet, for the person typing it, the first thing first; nothing
* when it can be saved. `file` is the file of an automation opened from its row: a text too long
Expand All @@ -80,13 +92,12 @@ export function draftProblem(draft: AutomationDraft, file?: string): string | un
if (draft.prompt.trim().length > TEXT_MAX) return `What the agent is told is ${draft.prompt.trim().length} characters, and this form takes ${TEXT_MAX} at most. ${file === undefined ? 'Save a shorter one, then write the rest into the file.' : `Change it in its file, ${file}.`}`
if (draft.when.trim().length > TEXT_MAX) return `The shell line is ${draft.when.trim().length} characters, and this form takes ${TEXT_MAX} at most.`
if (draft.when.trim() !== '' && draft.waitsFor.trim().length > TEXT_MAX) return `What the line waits for is ${draft.waitsFor.trim().length} characters, and this form takes ${TEXT_MAX} at most.`
if (draft.paced && everyOf(draft) === undefined) return `Type a whole number, from 1 to ${MAX_COUNT}.`
if (!draft.paced && draft.when.trim() === '') return 'Say when it runs: on a pace, by a shell line, or both.'
return undefined
if (draft.pace.kind === 'work' && draft.when.trim() === '') return 'Say when it runs: on a pace, by a shell line, or both.'
return paceProblem(draft.pace)
}

/**
* The command that saves a draft, after `agent-scheduler`; nothing while the draft cannot be
* The command that saves a new draft, after `agent-scheduler`; nothing while the draft cannot be
* saved. Each text goes as one argument with its flag (`--prompt=…`), so one that opens with a
* dash is still the flag's own text. What the shell line waits for is left out without a line.
* `--private` keeps it on this machine alone.
Expand All @@ -109,39 +120,55 @@ export function editArgs(draft: AutomationDraft, file: string): string[] | undef
return ['edit', draft.name.trim(), `--prompt=${draft.prompt.trim()}`, `--every=${everyOf(draft) ?? ''}`, `--when=${when}`, `--waits-for=${when !== '' ? draft.waitsFor.trim() : ''}`]
}

/** A draft's name and what it says, as the arguments of `add`: a part it does not have is left out. */
/**
* What a draft's pace leaves for this machine to hold, after `agent-scheduler pace <name>`: its
* interval with its time of day, when it has one, since a time of day is this machine's and no
* file says one; else `skill`, the file's own pace and nothing of this machine's. Nothing while
* the pace typed is none yet.
*/
export function ownPaceArgs(draft: AutomationDraft): string[] | undefined {
const args = paceArgs(draft.pace)
if (!args) return undefined
return draft.pace.kind === 'every' && args[1] !== undefined ? args : ['skill']
}

/** A draft's name and what its file would say, as the arguments of `add`: a part it does not have is left out. */
function saidOf(draft: AutomationDraft): string[] {
const every = everyOf(draft)
const when = draft.when.trim()
const waitsFor = draft.waitsFor.trim()
return [draft.name.trim(), `--prompt=${draft.prompt.trim()}`, ...(every !== undefined ? [`--every=${every}`] : []), ...(when !== '' ? [`--when=${when}`] : []), ...(when !== '' && waitsFor !== '' ? [`--waits-for=${waitsFor}`] : [])]
}

/** Whether two drafts would save the same automation: the form opened from a row has nothing to save, and loses nothing when closed, until they differ. */
/** Whether two drafts would write the same file: nothing of the automation itself is to save until they differ. */
export function saysTheSame(a: AutomationDraft, b: AutomationDraft): boolean {
return saidOf(a).join('\0') === saidOf(b).join('\0')
}

/** When a draft would run, as the sentence its row would say; nothing while it has no pace and no shell line that could say. */
export function draftSentence(draft: AutomationDraft): string | undefined {
/** A draft as the row it would be: what its file would say, and the time of day this machine would hold for it. Nothing while it has no pace and no shell line that could say when. */
export function draftRow(draft: AutomationDraft): SchedulerCommand | undefined {
const args = paceArgs(draft.pace)
const every = everyOf(draft)
const when = draft.when.trim()
if (every === undefined && when === '') return undefined
if (draft.paced && every === undefined) return undefined
if (!args || (every === undefined && when === '')) return undefined
const waitsFor = draft.waitsFor.trim()
return pace({ command: draft.name.trim(), on: false, publish: 'commit', ...(every !== undefined ? { every } : {}), ...(when !== '' ? { when, ...(waitsFor !== '' ? { waitsFor } : {}) } : {}) })
return { command: draft.name.trim(), on: false, publish: 'commit', ...(every !== undefined ? { every } : {}), ...(when !== '' ? { when, ...(waitsFor !== '' ? { waitsFor } : {}) } : {}), ...(every !== undefined && args[1] !== undefined ? { pace: { every, at: args[1], since: '' } } : {}) }
}

/** When a draft would run, as the sentence its row would say; nothing while it has no pace and no shell line that could say. */
export function draftSentence(draft: AutomationDraft): string | undefined {
const row = draftRow(draft)
return row && pace(row)
}

/** Whether nothing has been typed into a draft yet: closing such a form loses nothing. */
export function isUntouched(draft: AutomationDraft): boolean {
return draft.name === '' && draft.prompt === '' && draft.when === '' && draft.waitsFor === ''
}

/** What the words beside the pace say of it, given whether a shell line is typed: how the two decide together when the row runs. */
/** What the words beside a pace say of it, given whether a shell line is typed: how the two decide together when the row runs. */
export function paceHint(draft: AutomationDraft): string {
const line = draft.when.trim() !== ''
if (!draft.paced) return line ? 'not on a pace: the shell line below alone says when' : 'not on a pace'
return line ? 'at most, and only when the shell line below prints something' : 'by time alone'
return draft.when.trim() !== '' ? 'at most, and only when the shell line prints something' : 'by time alone'
}

/** What trying a shell line answered, as the page shows it. */
Expand Down Expand Up @@ -194,9 +221,14 @@ export function showsChangeWhen(ticking: boolean): string {
return ticking ? 'Its row shows the change once the scheduler has looked, within a minute.' : 'The scheduler is not running in this project, so its row shows the change once the scheduler runs.'
}

/** What the form says of where an automation opened from its row is saved: that is not the form's to change. */
export function keptWhereHint(opened: OpenedAutomation): string {
return opened.draft.onThisMachine ? `Kept on this machine alone, outside git: ${opened.file}.` : `A skill file in this project, ${opened.file}: the change is yours to commit.`
/**
* What a row's panel says of where its picks are saved. A skill's row keeps them on this machine
* and changes no tracked file. A row a person made keeps its words and its pace in its own file,
* a change of theirs to commit when it is shared, and the rest on this machine.
*/
export function keptWhereHint(opened: OpenedAutomation | undefined): string {
if (!opened) return 'Saved for you, in this project, on this machine. No tracked file changes.'
return opened.draft.onThisMachine ? `Kept on this machine alone, outside git: ${opened.file}.` : `Its words and its pace are in a skill file of this project, ${opened.file}: a change to them is yours to commit. The rest is saved on this machine.`
}

/** What a row says before its automation is removed, for the person to confirm: what is deleted, what of it can be brought back, and what stays. */
Expand Down
Loading
Loading