-
Notifications
You must be signed in to change notification settings - Fork 14
feat(tool): add automate_manage lifecycle actions #1278
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
5d5ac5e
feat(tool): add automate manage actions
Astro-Han 8393bf4
feat(tool): defer automate manage activation
Astro-Han d80c076
fix(permission): respect once-only approval prompts
Astro-Han 4f54cc7
fix(tool): report missing automation ids through Effect
Astro-Han c3b97aa
fix(tool): clarify automate_manage activation contract
Astro-Han d58cf06
test(session): cover automate_manage model activation
Astro-Han ad4c3c9
fix(tool): report stale automation ids clearly
Astro-Han b14ca66
fix(tool): avoid scheduler settle in automate_manage
Astro-Han 76a9642
test(tool): pin automate_manage model contract
Astro-Han a95a8ee
fix(tool): report delete race automation ids clearly
Astro-Han d3bb3d1
fix(tool): rely on automation delete events for scheduling
Astro-Han cbe32b3
fix(tool): report update race automation ids clearly
Astro-Han b7db39a
test(session): clarify automate_manage activation request count
Astro-Han fedb1dd
chore: merge dev into automate_manage branch
Astro-Han File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
59 changes: 59 additions & 0 deletions
59
packages/app/e2e/snap/fixtures/permission-dock-fixture.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { render } from "solid-js/web" | ||
| import type { PermissionRequest } from "@opencode-ai/sdk/v2" | ||
| import { LanguageProvider } from "@/context/language" | ||
| import { type Platform, PlatformProvider } from "@/context/platform" | ||
| import { SessionPermissionContent } from "@/pages/session/composer/session-permission-dock" | ||
|
|
||
| const platform: Platform = { | ||
| platform: "web", | ||
| openLink: () => {}, | ||
| restart: async () => {}, | ||
| back: () => {}, | ||
| forward: () => {}, | ||
| notify: async () => {}, | ||
| } | ||
|
|
||
| function deleteRequest(input: { id: string; title: string }): PermissionRequest { | ||
| return { | ||
| id: `perm_${input.id}`, | ||
| sessionID: "ses_permission_snap", | ||
| permission: "automate_manage", | ||
| patterns: [input.id], | ||
| always: [], | ||
| metadata: { action: "delete", id: input.id, title: input.title }, | ||
| } | ||
| } | ||
|
|
||
| const persistableRequest: PermissionRequest = { | ||
| id: "perm_bash_echo", | ||
| sessionID: "ses_permission_snap", | ||
| permission: "bash", | ||
| patterns: ["echo ok"], | ||
| always: ["echo ok"], | ||
| metadata: {}, | ||
| } | ||
|
|
||
| function Block(props: { snap: string; request: PermissionRequest }) { | ||
| return ( | ||
| <div data-snap={props.snap} style={{ width: "640px" }}> | ||
| <SessionPermissionContent request={props.request} responding={false} onDecide={() => {}} /> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| function PermissionDockFixture() { | ||
| return ( | ||
| <PlatformProvider value={platform}> | ||
| <LanguageProvider locale="en"> | ||
| <div style={{ display: "grid", gap: "20px", padding: "24px", background: "var(--bg-base)" }}> | ||
| <Block snap="delete-once" request={deleteRequest({ id: "aut_daily", title: "Daily repo brief" })} /> | ||
| <Block snap="persistable" request={persistableRequest} /> | ||
| </div> | ||
| </LanguageProvider> | ||
| </PlatformProvider> | ||
| ) | ||
| } | ||
|
|
||
| export function mountPermissionDockFixture(root: HTMLElement) { | ||
| render(() => <PermissionDockFixture />, root) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { expect, type Locator } from "@playwright/test" | ||
| import { fileURLToPath } from "node:url" | ||
| import { test } from "../fixtures" | ||
| import { composeGrid, snapOutputPath, type Shot } from "./_compose" | ||
|
|
||
| test.use({ viewport: { width: 760, height: 420 }, deviceScaleFactor: 2 }) | ||
|
|
||
| const fixturePath = fileURLToPath(new URL("./fixtures/permission-dock-fixture.tsx", import.meta.url)) | ||
|
|
||
| async function captureBlock(name: string, block: Locator): Promise<Shot> { | ||
| await expect(block).toBeVisible({ timeout: 30_000 }) | ||
| return { name, buf: await block.screenshot() } | ||
| } | ||
|
|
||
| async function waitForThemeBoot(page: import("@playwright/test").Page): Promise<void> { | ||
| await page.waitForFunction( | ||
| () => getComputedStyle(document.documentElement).getPropertyValue("--bg-base").trim().length > 0, | ||
| null, | ||
| { timeout: 30_000 }, | ||
| ) | ||
| } | ||
|
|
||
| test("permission-dock", async ({ page }) => { | ||
| test.setTimeout(180_000) | ||
|
|
||
| await page.goto("/") | ||
| await waitForThemeBoot(page) | ||
| await page.addStyleTag({ | ||
| content: | ||
| 'aside[aria-label="Development performance diagnostics"], [data-component="toast-region"] { display: none; }', | ||
| }) | ||
| await page.evaluate(async (path) => { | ||
| const mod = await import(path) | ||
| mod.mountPermissionDockFixture(document.body) | ||
| }, `/@fs/${fixturePath}`) | ||
|
|
||
| const deleteOnce = page.locator('[data-snap="delete-once"]') | ||
| await expect(deleteOnce).toContainText('Delete automation "Daily repo brief" (aut_daily)', { timeout: 30_000 }) | ||
| await expect(deleteOnce.getByRole("button", { name: "Allow once" })).toBeVisible() | ||
| await expect(deleteOnce.getByRole("button", { name: "Deny" })).toBeVisible() | ||
| await expect(deleteOnce.getByRole("button", { name: "Allow always" })).toHaveCount(0) | ||
|
|
||
| const persistable = page.locator('[data-snap="persistable"]') | ||
| await expect(persistable).toContainText("echo ok", { timeout: 30_000 }) | ||
| await expect(persistable.getByRole("button", { name: "Allow always" })).toBeVisible() | ||
|
|
||
| const out = snapOutputPath("permission-dock") | ||
| await composeGrid( | ||
| [ | ||
| await captureBlock("delete-once", deleteOnce), | ||
| await captureBlock("persistable", persistable), | ||
| ], | ||
| out, | ||
| { cols: 1 }, | ||
| ) | ||
| process.stdout.write(`\n[snap] permission-dock grid -> ${out}\n\n`) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
34 changes: 34 additions & 0 deletions
34
packages/app/src/pages/session/composer/session-permission-dock.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { describe, expect, test } from "bun:test" | ||
| import type { PermissionRequest } from "@opencode-ai/sdk/v2" | ||
| import { canPersistPermission, permissionMetadataLines } from "./session-permission-dock" | ||
|
|
||
| const request = (always: string[]): PermissionRequest => | ||
| ({ | ||
| id: "perm_1", | ||
| sessionID: "ses_1", | ||
| permission: "automate_manage", | ||
| patterns: ["aut_123"], | ||
| always, | ||
| metadata: { action: "delete", id: "aut_123", title: "Daily repo brief" }, | ||
| }) as PermissionRequest | ||
|
|
||
| describe("canPersistPermission", () => { | ||
| test("returns false when the request has no always patterns", () => { | ||
| expect(canPersistPermission(request([]))).toBe(false) | ||
| }) | ||
|
|
||
| test("returns true when the request has at least one always pattern", () => { | ||
| expect(canPersistPermission(request(["*"]))).toBe(true) | ||
| }) | ||
| }) | ||
|
|
||
| describe("permissionMetadataLines", () => { | ||
| test("renders automate_manage delete metadata as a readable confirmation line", () => { | ||
| const t = (key: string | number, params?: Record<string, string | number | boolean>) => | ||
| `${key}:${params?.title}:${params?.id}` | ||
|
|
||
| expect(permissionMetadataLines(request([]), t)).toEqual([ | ||
| "ui.permission.automateManageDelete:Daily repo brief:aut_123", | ||
| ]) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { Cause, Effect, Schema } from "effect" | ||
| import { ActiveRunStillRunningError, Automation } from "@/automation" | ||
| import { NotFoundError } from "@/storage/db" | ||
| import * as Tool from "./tool" | ||
|
|
||
| const Action = Schema.Literals(["list", "pause", "resume", "delete"]) | ||
|
|
||
| export const AutomateManageParameters = Schema.Struct({ | ||
| action: Action.annotate({ | ||
| description: | ||
| 'Management action for an existing PawWork Automation: "list", "pause", "resume", or "delete".', | ||
| }), | ||
| id: Schema.optional(Schema.String).annotate({ | ||
| description: | ||
| "Exact automation id from automate_manage list or an automate creation result. Required for pause, resume, and delete; omit for list.", | ||
| }), | ||
| }) | ||
|
|
||
| type Parameters = Schema.Schema.Type<typeof AutomateManageParameters> | ||
| type Metadata = { | ||
| automationDefinitions?: Automation.Definition[] | ||
| automationDefinition?: Automation.Definition | ||
| automationTombstone?: Automation.Tombstone | ||
| stoppedRun?: Automation.Run | ||
| } | ||
|
|
||
| function schedule(definition: Automation.Definition) { | ||
| if (definition.kind === "oneshot") return new Date(definition.fireAt).toISOString() | ||
| if (definition.rhythm.kind === "cron") return definition.rhythm.expression | ||
| return `every ${definition.rhythm.everyMs}ms` | ||
| } | ||
|
|
||
| function item(definition: Automation.Definition) { | ||
| return { | ||
| id: definition.id, | ||
| title: definition.title, | ||
| kind: definition.kind, | ||
| paused: definition.paused, | ||
| schedule: schedule(definition), | ||
| timezone: definition.timezone, | ||
| context: definition.context, | ||
| nextFireAt: definition.kind === "recurring" ? definition.nextFireAt : undefined, | ||
| } | ||
| } | ||
|
|
||
| function requireID(params: Parameters) { | ||
| if (params.id) return Effect.succeed(params.id) | ||
| return Effect.fail(new Error(`automate_manage action "${params.action}" requires an exact automation id.`)) | ||
| } | ||
|
|
||
| function readableAutomationError(error: unknown, id: string) { | ||
| if (NotFoundError.isInstance(error)) { | ||
| return new Error(`Automation not found: ${id}. Run automate_manage list to get a current id.`, { cause: error }) | ||
| } | ||
| if (error instanceof ActiveRunStillRunningError) { | ||
| return new Error( | ||
| `Cannot delete automation ${id}: active_run_still_running (${error.runID}). Try again after the active run finishes.`, | ||
| { cause: error }, | ||
| ) | ||
| } | ||
| return error | ||
| } | ||
|
|
||
| function readableAutomationEffect<A, E, R>(effect: Effect.Effect<A, E, R>, id: string) { | ||
| return effect.pipe( | ||
| Effect.catchCause((cause) => { | ||
| const error = Cause.squash(cause) | ||
| const readable = readableAutomationError(error, id) | ||
| if (readable === error) return Effect.failCause(cause) | ||
| return Effect.fail(readable) | ||
| }), | ||
| ) | ||
| } | ||
|
|
||
| function getAutomation(automation: Automation.Interface, id: string) { | ||
| return readableAutomationEffect(automation.get(id), id) | ||
| } | ||
|
|
||
| export function createAutomateManageDefinition( | ||
| automation: Automation.Interface, | ||
| ): Tool.DefWithoutID<typeof AutomateManageParameters, Metadata> { | ||
| return { | ||
| description: [ | ||
| "Manage existing PawWork Automations in the current context. Use this when the user asks to show scheduled tasks, list reminders, pause an automation, resume an automation, or delete/remove/cancel an automation. Never use OS schedulers (crontab, cron, at, launchd, schtasks) to manage PawWork Automations.", | ||
| "Use action list first when the user has not provided an exact automation id. Pause and resume are reversible and do not need confirmation. Delete is destructive and must ask the user for confirmation before removing anything.", | ||
| ].join("\n\n"), | ||
| parameters: AutomateManageParameters, | ||
| execute: (params, ctx) => | ||
| Effect.gen(function* () { | ||
| if (params.action === "list") { | ||
| const items = yield* automation.list() | ||
| return { | ||
| title: "Automations", | ||
| metadata: { automationDefinitions: items }, | ||
| output: JSON.stringify({ items: items.map(item) }, null, 2), | ||
| } | ||
| } | ||
|
|
||
| const id = yield* requireID(params) | ||
| const previous = yield* getAutomation(automation, id) | ||
| if (params.action === "pause" || params.action === "resume") { | ||
| const definition = yield* readableAutomationEffect(automation.update(id, { paused: params.action === "pause" }), id) | ||
| if (definition.revision !== previous.revision) { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| yield* automation.publishDefinitionUpdated(definition) | ||
| } | ||
| return { | ||
| title: params.action === "pause" ? "Automation paused" : "Automation resumed", | ||
| metadata: { automationDefinition: definition }, | ||
| output: JSON.stringify(item(definition), null, 2), | ||
| } | ||
| } | ||
|
|
||
| yield* ctx.ask({ | ||
| permission: "automate_manage", | ||
| patterns: [id], | ||
| always: [], | ||
| metadata: { action: "delete", id, title: previous.title }, | ||
| }) | ||
| const removed = yield* readableAutomationEffect(automation.remove(id), id) | ||
| if (removed.stoppedRun) yield* automation.publishRunUpdated(removed.stoppedRun) | ||
| yield* automation.publishDefinitionDeleted(removed.tombstone) | ||
| return { | ||
| title: "Automation deleted", | ||
| metadata: { automationTombstone: removed.tombstone, stoppedRun: removed.stoppedRun }, | ||
| output: JSON.stringify(removed.tombstone, null, 2), | ||
| } | ||
| }), | ||
| } | ||
| } | ||
|
|
||
| export const AutomateManageTool = Tool.define( | ||
| "automate_manage", | ||
| Effect.gen(function* () { | ||
| const automation = yield* Automation.Service | ||
| return createAutomateManageDefinition(automation) | ||
| }), | ||
| ) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.