diff --git a/CHANGELOG.md b/CHANGELOG.md index cd35d57b..540c9b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ new version heading in the same commit. ## [Unreleased] +## [0.445.0] - 2026-09-17 +### Added +- **Edit an automation by talking it through with its agent.** Automations → ⋯ → **Edit with agent…** opens + an interactive session with the agent the automation runs, briefed with the automation's current + trigger, mode, run-as identity, full task prompt and its last five runs (plus an optional note on what + should change). The agent can't write the automation: `automation_propose` gained an `editOf` lane that + carries only the changed fields, validates them against the live automation when proposed, and lands as + an **edit card** in the Automations proposals panel (before → after per field, a side-by-side view for a + rewritten task prompt). Approving updates that automation under the same ownership rule as the Edit form, + and refuses if someone changed it after the proposal was made — so the agent's snapshot never silently + reverts a human's edit. Pinned by `scripts/automation-edit-proposal-test.cjs`. + **For admins:** On any automation, choose **Edit with agent…** to describe a change in plain words — the agent drafts it and you approve the edit before anything changes. [Open Automations](#/automations) + ## [0.444.2] - 2026-09-16 ### Fixed - **A blocked task now carries the actual question, and you can answer it with a number.** v0.444.0 made diff --git a/docs/agent-mcp-tools.md b/docs/agent-mcp-tools.md index 32b8f4fc..04a19cda 100644 --- a/docs/agent-mcp-tools.md +++ b/docs/agent-mcp-tools.md @@ -70,6 +70,7 @@ scope/parity properties, but not the cost. | `policy_propose` | `POST /api/agent/policy/propose` | `TerminalManager.proposePolicy` → `applyProposal` + messages | W | proposes a **TIGHTEN-ONLY** change to the governance ruleset — `tighten` an existing rule to a stricter outcome, `reorder` a conditional rule above the unconditional allow rules (fixes a first-match hole), or `add` a new `ask`/`never` guardrail. `applyProposal` (`src/governance/policy.ts`) refuses anything that would LOOSEN a guardrail (checked by construction **and** an exhaustive monotonicity sweep over the ruleset's arg space), touch a hard-deny `never`, change the default, or add an `allow` — so an invalid proposal is rejected up front with a reason. A valid proposal posts a `policy.proposal` inbox card (owner-addressed) carrying the delta + a before→after `preview`; **applies nothing** until an **owner** approves via `POST /api/policy/proposals/:id/approve` (re-validated against the current doc; then `AgentOS.applyPolicyDocument` snapshots to `policy_revisions`, writes the override, hot-reloads) or rejects via `…/reject`. Deduped + capped (≤10 open/agent). Agents see the raw `rules` via `GET /api/agent/policy` (`list_capabilities`) to craft the delta. Audited `policy.proposed` / `policy.proposal.approved` / `policy.proposal.rejected`; every applied edit (console, always-approve, approved proposal) is revertable via `POST /api/policy/revisions/:rev/revert` (owner) | | `goal_measure` | `POST /api/goals/measure` | `GoalStore.addReading` + `metricStatus` | W | records one measured READING of a goal's metric — the OUTCOME record, as against `goal_events` (activity). This is the one goal write agents get besides proposing: they may report the number, they may not move the goalposts (metric/target/status stay human-owned). Every reading stores WHO took it (`agent:`), so a number reported by the same agent that did the work is visibly self-reported. Refuses a non-finite value and a goal with no metric set. The reading also lands on the goal timeline, so a diligently-measured goal is never reported "stuck" by the activity sweep. Returns the recomputed verdict (`measuring` / `flat` / `regressing` / `achieved` / `unmeasured` / `new`). Audited `goal.measured` | | `workflow_propose` | `POST /api/agent/workflow/propose` | `TerminalManager.proposeWorkflow` + messages | W | proposes a **WORKFLOW** — 1–6 automations that make up one ongoing FUNCTION, carried on a SINGLE `automation.proposed` card (`args.specs` + a `workflow` name) and approved as a unit. `automation_propose` is the one-part sibling and shares the same validator, card type, queue cap (≤10 open/agent), dedupe and approve/reject routes. Approval is **all-or-nothing**: `POST /api/automations/proposals/:id/approve` creates every part, and a part `Automations.add` rejects (bad cron, an agent deleted since the proposal) rolls the earlier parts back — the error names the failing part and the proposal stays open. The approver's `runAs` override applies to every part. Proposes TRIGGERS only: the judgment inside a function belongs in each part's `task` prompt, decided at runtime, so a branch never becomes its own automation. Audited `automation.proposed` / `automation.proposal.approved` / `automation.proposal.rolled_back` / `automation.proposal.rejected` | +| `automation_propose({ editOf })` | `POST /api/agent/automation/propose` | `planAutomationEdit` (`src/edge/automation-edit.ts`) + `TerminalManager.postAutomationEditProposal` | W | the **EDIT** lane — the agent sends an existing automation's id and ONLY the fields that change (name / schedule / filter / mode / runAs / task; type + agent are fixed). Merged over the live automation and validated at propose time (bad cron, schedule-on-an-event-trigger, no-op edit → refused). Rides the same `automation.proposed` card (`args.edit` + `editOf` + `before`/`after`/`changes` + a `base` fingerprint), cap and approve route; approve calls `Automations.update` under the console edit's ownership rule and **409s if the automation changed since the proposal** (excluding `enabled`), so a human's interim edit is never silently reverted. Entry point: the console's **Edit with agent…** (`POST /api/automations/:id/edit-with-agent`) opens an interactive session with the automation's own agent, briefed with its config + last 5 runs. Audited `automation.edit.session` / `automation.edit.proposed` / `automation.edit.approved` | | `skill_find` | `GET /api/skills/discover` | `TerminalManager.requestableSkills` (+ `searchSkillsh` when `q`) | R | the caller's installed library (each flagged `active` for this agent) + the bundled catalog — what's installable to ask for; with a `query` also returns matching **community** skills from the skills.sh directory, each with its `source` (`owner/repo`) | | `skill_request` | `POST /api/skills/request` | `TerminalManager.requestSkill` + messages | W | **asks** an owner/admin to install a skill (never installs itself). `source` omitted ⇒ the bundled catalog (validated against it); `source: 'owner/repo'` ⇒ a **remote GitHub repo** (`browseRepo` resolves the skill + its path at request time so a typo fails fast). Dedupes an open request for the same skill+source, posts a `skill.request` card to owner/admins; audited `skill.requested`. Human installs via `POST /api/skills/requests/:id/approve` (owner/admin; catalog → `SkillsStore.install`, remote → `fetchSkill` + `installFiles`; optional `scope:'agent'`) or dismisses via `POST /api/skills/requests/:id/dismiss`. On approve, `TerminalManager.refreshAgentSkills` delivers same-session to the requester's LIVE interactive sessions (materialise into the watched `.claude/skills` + inject `/reload-skills`, claude ≥2.1.152); headless/next-launch otherwise | | `library_list` | `GET /api/agent/artifacts` | `ArtifactStore.list` + `ArtifactStore.folders` | R | list scoped to the agent's own deliverables in the Library; also returns the tenant-wide Library folders so a `publish` files into the existing tree (discovery) | diff --git a/package-lock.json b/package-lock.json index 2638b19b..776d1987 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.444.2", + "version": "0.445.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.444.2", + "version": "0.445.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 68dc26c4..9b8ec5ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.444.2", + "version": "0.445.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", @@ -27,7 +27,7 @@ "check-deps": "bash scripts/install-deps.sh --check", "dev": "ts-node src/cli.ts serve", "demo:dev": "ts-node src/demo.ts", - "test:governance": "node scripts/version-sync-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs", + "test:governance": "node scripts/version-sync-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs", "test:alert-staleness": "node scripts/alert-staleness-test.cjs", "test:deps": "node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-login-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs", "test:dm-continuity": "node scripts/dm-continuity-test.cjs", diff --git a/scripts/automation-edit-proposal-test.cjs b/scripts/automation-edit-proposal-test.cjs new file mode 100644 index 00000000..9df05067 --- /dev/null +++ b/scripts/automation-edit-proposal-test.cjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/* "Edit with agent" — an agent proposes an EDIT to an existing automation; a human approves it. + * + * What must hold: proposing changes nothing; the edit merges only the fields sent; a proposal that could + * not be applied is refused when made; approval updates THAT automation (never creates a second one); + * a human edit made while the proposal waited blocks approval instead of being silently reverted; and the + * "Edit with agent" route opens a session briefed with the live config and the editOf id. + * + * Isolated home; no ttyd. + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-autoedit-test-')); +process.env.AGENT_OS_HOME = HOME; +process.env.AGENT_OS_TENANT = 'testco'; +process.env.AOS_NO_TTYD = '1'; + +let pass = 0, fail = 0; +const assert = (c, name, d) => c ? (pass++, console.log(` \x1b[32m✓\x1b[0m ${name}`)) : (fail++, console.log(` \x1b[31m✗ ${name}\x1b[0m${d !== undefined ? ' — ' + JSON.stringify(d).slice(0, 300) : ''}`)); + +(async () => { + const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); + const { TenantRegistry } = require(path.join(ROOT, 'dist/tenant-registry.js')); + const registry = new TenantRegistry(ROOT, 0, path.join(ROOT, 'config/agent-os.config.json')); + registry.bootAll(); + const { os: aos, tm, autos } = registry.default(); + const server = createHttpServer(registry); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const base = `http://127.0.0.1:${server.address().port}`; + + const dir = path.join(aos.paths.userAgents, 'reporter'); + fs.mkdirSync(dir, { recursive: true }); + const manifest = { id: 'reporter', version: '1.0.0', description: 'reporter', principal: 'svc-reporter', policyContext: 'default@v3', runtime: 'claude-code' }; + fs.writeFileSync(path.join(dir, 'agent.json'), JSON.stringify(manifest)); + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# reporter\n'); + aos.registerAgent({ ...manifest, dir }); + + const owner = aos.team.listMembers().find((m) => m.role === 'owner'); + const cookie = `aos_sid=${aos.team.createSession(owner.id)}`; + const post = async (p, body) => { const r = await fetch(base + p, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }); return { status: r.status, ...(await r.json()) }; }; + const get = async (p) => (await fetch(base + p, { headers: { cookie } })).json(); + + const auto = autos.add({ agentId: 'reporter', name: 'Daily digest', type: 'cron', schedule: '0 9 * * *', task: 'Post the digest.', createdBy: owner.id }); + const hook = autos.add({ agentId: 'reporter', name: 'Inbound', type: 'webhook', task: 'Handle it.', createdBy: owner.id }); + const session = tm.createSession('reporter', 'edit test', 'task'); + const secret = aos.db.prepare('SELECT secret FROM term_sessions WHERE id = ?').get(session.id)?.secret; + const propose = async (body) => { + const r = await fetch(base + '/api/agent/automation/propose', { method: 'POST', headers: { 'content-type': 'application/json', ...(secret ? { 'x-aos-secret': secret } : {}) }, body: JSON.stringify({ session: session.id, ...body }) }); + return { status: r.status, ...(await r.json()) }; + }; + + console.log('\n\x1b[1m1) "Edit with agent" opens a briefed session\x1b[0m'); + const opened = await post(`/api/automations/${auto.id}/edit-with-agent`, { note: 'run at 8 instead' }); + assert(opened.ok && opened.id, 'the route spawns a session', opened); + const row = aos.db.prepare('SELECT agent, task FROM term_sessions WHERE id = ?').get(opened.id); + assert(row && row.agent === 'reporter', 'with the automation\'s own agent', row); + assert(row && row.task.includes(`editOf: "${auto.id}"`), 'the brief names the editOf id to propose against'); + assert(row && row.task.includes('Post the digest.') && row.task.includes('0 9 * * *'), 'and carries the current task + schedule verbatim'); + assert(row && row.task.includes('run at 8 instead'), 'and the member\'s note'); + + console.log('\n\x1b[1m2) Proposing an edit changes nothing, and refuses what could not apply\x1b[0m'); + const probe = await propose({ editOf: auto.id, schedule: '0 8 * * 1-5', rationale: 'weekdays at 8' }); + if (probe.status === 403) { console.log(' (session secret header not accepted — check sessionSecretOk)'); } + assert(probe.ok, 'a schedule edit is accepted', probe); + assert(/schedule: `0 9 \* \* \*` → `0 8 \* \* 1-5`/.test(probe.preview || ''), 'the preview shows before → after', probe.preview); + assert(autos.get(auto.id).schedule === '0 9 * * *', 'and the automation is untouched'); + assert(autos.list().length === 2, 'and nothing new is created'); + const dup = await propose({ editOf: auto.id, schedule: '0 8 * * 1-5' }); + assert(!dup.ok && /already awaiting review/.test(dup.error || ''), 'an identical open edit is deduped', dup); + const noop = await propose({ editOf: auto.id, schedule: '0 9 * * *' }); + assert(!noop.ok && /changes nothing/.test(noop.error || ''), 'a no-op edit is refused', noop); + const badCron = await propose({ editOf: auto.id, schedule: 'every morning' }); + assert(!badCron.ok && /invalid cron/.test(badCron.error || ''), 'a bad cron is refused when proposed', badCron); + const wrongField = await propose({ editOf: hook.id, schedule: '0 8 * * *' }); + assert(!wrongField.ok && /only applies to a cron/.test(wrongField.error || ''), 'a schedule on a webhook trigger is refused', wrongField); + const ghost = await propose({ editOf: 'auto-nope', task: 'x' }); + assert(!ghost.ok && ghost.status === 404, 'an unknown automation id is a 404', ghost); + + console.log('\n\x1b[1m3) Approving updates THAT automation\x1b[0m'); + const proposals = (await get('/api/automations/proposals')).proposals; + const card = proposals.find((p) => p.editOf === auto.id); + assert(card && card.changes.join() === 'schedule', 'the card surfaces as an edit with its changed fields', card); + const ok = await post(`/api/automations/proposals/${card.id}/approve`, {}); + assert(ok.ok && ok.edited, 'approve succeeds as an edit', ok); + assert(autos.get(auto.id).schedule === '0 8 * * 1-5', 'the schedule changed'); + assert(autos.get(auto.id).task === 'Post the digest.', 'fields not in the edit are kept'); + assert(autos.list().length === 2, 'no second automation was created'); + + console.log('\n\x1b[1m4) A human edit made meanwhile blocks approval\x1b[0m'); + const t = await propose({ editOf: auto.id, task: 'Post the digest, then a one-line summary in #general.' }); + assert(t.ok && /task prompt: rewritten/.test(t.preview || ''), 'a task rewrite is proposed', t); + autos.update(auto.id, { name: 'Morning digest' }); // the human renames it while the card waits + const staleId = (await get('/api/automations/proposals')).proposals.find((p) => p.editOf === auto.id).id; + const stale = await post(`/api/automations/proposals/${staleId}/approve`, {}); + assert(stale.status === 409 && /changed after this edit/.test(stale.error || ''), 'approval refuses with 409', stale); + assert(autos.get(auto.id).name === 'Morning digest' && autos.get(auto.id).task === 'Post the digest.', 'and the human\'s change survives'); + autos.update(auto.id, { enabled: false }); + const t2 = await propose({ editOf: auto.id, task: 'Post the digest twice.' }); + autos.update(auto.id, { enabled: true }); // toggling enabled must NOT invalidate + const t2Id = (await get('/api/automations/proposals')).proposals.find((p) => p.editOf === auto.id && p.after.task === 'Post the digest twice.').id; + const ok2 = await post(`/api/automations/proposals/${t2Id}/approve`, {}); + assert(t2.ok && ok2.ok && autos.get(auto.id).task === 'Post the digest twice.', 'pausing/resuming meanwhile does not block approval', ok2); + + console.log('\n\x1b[1m5) Creating still works as before\x1b[0m'); + const create = await propose({ name: 'Weekly', task: 'weekly thing', type: 'cron', schedule: '0 9 * * 1' }); + assert(create.ok && !create.edit, 'a proposal without editOf is still a create proposal', create); + + server.close(); + registry.stopAll(); + fs.rmSync(HOME, { recursive: true, force: true }); + console.log(`\n${fail ? '\x1b[31m' : '\x1b[32m'}${pass} passed, ${fail} failed\x1b[0m\n`); + process.exit(fail ? 1 : 0); +})(); diff --git a/src/edge/automation-edit.ts b/src/edge/automation-edit.ts new file mode 100644 index 00000000..83b34aa0 --- /dev/null +++ b/src/edge/automation-edit.ts @@ -0,0 +1,189 @@ +/** + * "Edit with agent" — changing an EXISTING automation through a conversation with the agent it runs. + * + * Two halves, both deliberately thin: + * 1. {@link buildAutomationEditBrief} composes the opening prompt of an interactive session with the + * automation's own agent: the current configuration verbatim, a few recent runs to ground the + * conversation, and the one way to land a change — `automation_propose({ editOf })`. + * 2. {@link planAutomationEdit} turns that call into a reviewable EDIT: the agent sends only what changes, + * this merges it over the live automation, validates it the same way `Automations.update` will at + * approve time, and names each changed field for the card. + * + * Nothing here writes. An edit proposal is a card; the automation changes only when an owner/admin who may + * manage it approves (`POST /api/automations/proposals/:id/approve`, which calls `Automations.update`). The + * agent never gets a direct write lane onto its own trigger — that would let a run reschedule or re-prompt + * the job that spawns it with no human in the loop. + * + * Staleness: the card pins {@link automationEditBase} of the automation as it was proposed against. If a + * human edits the automation in the meantime, approval refuses (409) rather than silently reverting their + * change with the agent's older full-field snapshot. `enabled` is excluded on purpose — pausing an + * automation while its edit waits for review is normal and must not invalidate the proposal. + */ +import { createHash } from 'crypto'; +import type { Automation } from './automations'; +import { parseCron } from './automations'; +import { validateFilter } from './webhook-ingress'; + +/** The fields an edit proposal may change — exactly what `Automations.update` accepts from a human edit, + * minus the credentials (`signingSecret`) and toggles (`enabled`) an agent has no business proposing. */ +export interface AutomationEditPatch { + name?: string; + schedule?: string; + filter?: string; + task?: string; + mode?: 'headless' | 'interactive'; + /** undefined = keep; '' = clear back to company identity; else a member id or email. */ + runAs?: string; +} + +/** The editable snapshot of an automation — what an edit card stores as `before` and `after`. */ +export interface AutomationEditState { + name: string; + mode: 'headless' | 'interactive'; + schedule?: string; + filter?: string; + task: string; + runAs?: string; +} + +export const EDITABLE_TYPES: ReadonlyArray = ['cron', 'webhook', 'composio', 'slack', 'discord', 'telegram', 'clickup']; + +const FILTER_TYPES: ReadonlyArray = ['webhook', 'composio', 'slack', 'discord', 'telegram', 'clickup']; + +export function editStateOf(a: Automation): AutomationEditState { + return { + name: a.name, + mode: a.mode === 'interactive' ? 'interactive' : 'headless', + ...(a.type === 'cron' && a.schedule ? { schedule: a.schedule } : {}), + ...(FILTER_TYPES.includes(a.type) && a.filter ? { filter: a.filter } : {}), + task: a.task, + ...(a.runAs ? { runAs: a.runAs } : {}), + }; +} + +/** A stable fingerprint of the editable fields, so approval can tell the automation moved underneath it. */ +export function automationEditBase(a: Automation): string { + const s = editStateOf(a); + const canon = [s.name, s.mode, s.schedule ?? '', s.filter ?? '', s.task, s.runAs ?? '']; + return createHash('sha256').update(JSON.stringify(canon)).digest('hex').slice(0, 16); +} + +const FIELD_LABEL: Record = { + name: 'name', mode: 'mode', schedule: 'schedule', filter: 'filter', task: 'task prompt', runAs: 'run as', +}; + +/** + * Merge an agent's partial edit over the live automation and validate it. Returns the full `after` state, + * the changed field names, and one preview line per change — or an error the agent can act on. + * `resolveMember` maps an id/email to a member id (undefined = unknown); `memberLabel` names one for humans. + */ +export function planAutomationEdit( + current: Automation, + patch: AutomationEditPatch, + resolveMember: (ref: string) => string | undefined, + memberLabel: (id: string) => string, +): { before: AutomationEditState; after: AutomationEditState; changes: (keyof AutomationEditState)[]; preview: string } | { error: string } { + if (!EDITABLE_TYPES.includes(current.type)) return { error: `a "${current.type}" automation can't be edited by proposal — it's a one-shot, cancel it and schedule a new one` }; + const before = editStateOf(current); + const after: AutomationEditState = { ...before }; + + if (patch.name !== undefined) { + const n = patch.name.trim(); + if (!n) return { error: 'name can\'t be empty — omit it to keep the current name' }; + after.name = n; + } + if (patch.task !== undefined) { + const t = patch.task.trim(); + if (!t) return { error: 'task can\'t be empty — omit it to keep the current prompt' }; + after.task = t; + } + if (patch.mode !== undefined) after.mode = patch.mode; + if (patch.schedule !== undefined) { + if (current.type !== 'cron') return { error: `schedule only applies to a cron automation — this one is a ${current.type} trigger (change its filter instead)` }; + const s = patch.schedule.trim(); + try { parseCron(s); } catch (e) { return { error: `invalid cron schedule "${s}": ${e instanceof Error ? e.message : String(e)}` }; } + after.schedule = s; + } + if (patch.filter !== undefined) { + if (!FILTER_TYPES.includes(current.type)) return { error: 'filter only applies to event triggers — a cron automation is changed by its schedule' }; + const f = current.type === 'composio' ? patch.filter.trim().toUpperCase() : patch.filter.trim(); + if (current.type === 'webhook' || current.type === 'slack') { + const bad = validateFilter(f || undefined); + if (bad) return { error: bad }; + } + if (f) after.filter = f; else delete after.filter; + } + if (patch.runAs !== undefined) { + const raw = patch.runAs.trim(); + if (!raw) delete after.runAs; + else { + const id = resolveMember(raw); + if (!id) return { error: `unknown member "${raw}" for runAs — pass a member id or email (use directory_lookup), or "" for the company identity` }; + after.runAs = id; + } + } + + const keys: (keyof AutomationEditState)[] = ['name', 'schedule', 'filter', 'mode', 'runAs', 'task']; + const changes = keys.filter((k) => (before[k] ?? '') !== (after[k] ?? '')); + if (!changes.length) return { error: 'that edit changes nothing — every field you sent matches the automation as it is' }; + + const show = (k: keyof AutomationEditState, s: AutomationEditState): string => { + const v = s[k]; + if (k === 'runAs') return v ? memberLabel(String(v)) : 'company identity'; + if (v === undefined || v === '') return '(none)'; + return `\`${v}\``; + }; + const lines = changes.map((k) => k === 'task' + ? `${FIELD_LABEL.task}: rewritten (${before.task.length} → ${after.task.length} chars)` + : `${FIELD_LABEL[k]}: ${show(k, before)} → ${show(k, after)}`); + return { before, after, changes, preview: lines.join('\n') }; +} + +/** One recent run of the automation, as the brief shows it. */ +export interface BriefRun { title?: string; status: string; createdAt: number; summary?: string } + +/** The opening prompt of an "Edit with agent" session. */ +export function buildAutomationEditBrief(a: Automation, opts: { memberName: string; runAsLabel: string; runs: BriefRun[]; note?: string; now?: number; canApprove: boolean }): string { + const trigger = a.type === 'cron' + ? `cron \`${a.schedule ?? ''}\`` + : `${a.type} trigger${a.filter ? ` — filter \`${a.filter}\`` : ' — any event'}`; + const now = opts.now ?? Date.now(); + const ago = (t: number) => { + const m = Math.max(0, Math.round((now - t) / 60000)); + return m < 60 ? `${m}m ago` : m < 48 * 60 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`; + }; + const runs = opts.runs.length + ? opts.runs.map((r) => `- ${ago(r.createdAt)} · ${r.status}${r.title ? ` · ${r.title}` : ''}${r.summary ? ` — ${r.summary}` : ''}`).join('\n') + : '- (no runs yet)'; + const fields = a.type === 'cron' ? 'name, schedule, mode, runAs, task' : 'name, filter, mode, runAs, task'; + return [ + `${opts.memberName} opened this session to EDIT an automation you run. Help them change it.`, + '', + `## The automation as it is now`, + `- id: ${a.id}`, + `- name: ${a.name}`, + `- trigger: ${trigger}`, + `- mode: ${a.mode}`, + `- runs as: ${opts.runAsLabel}`, + `- enabled: ${a.enabled ? 'yes' : 'no'}`, + `- task prompt (what each fired run receives, verbatim):`, + '```', + a.task, + '```', + '', + `## Recent runs (newest first)`, + runs, + '', + `## How to work`, + opts.note?.trim() + ? `1. They said what they want: "${opts.note.trim()}". Restate the change in one line, and ask only if something is genuinely ambiguous.` + : `1. Ask what they want to change, in one short question. If they want ideas, ground them in the configuration and the recent runs above (use \`session_open\` on a run if you need detail) — don't invent problems.`, + `2. Show the concrete change before proposing it — for a task prompt, the full revised text; for a schedule, the cron expression AND what it means in words.`, + `3. When they agree, call \`automation_propose\` with \`editOf: "${a.id}"\`, ONLY the fields that change (${fields}), and a one-line \`rationale\`. \`task\` replaces the WHOLE prompt, so send the complete revised text, never a fragment. The trigger type and the agent can't be changed by an edit — if they need that, propose a new automation instead and say the old one should then be deleted.`, + opts.canApprove + ? `4. The edit is a DRAFT until it's approved on the Automations page — ${opts.memberName} can approve it there. Tell them that; don't claim it's applied.` + : `4. The edit is a DRAFT until an owner/admin approves it on the Automations page. Tell ${opts.memberName} that; don't claim it's applied.`, + '', + `Don't create a new automation, don't edit files to change this one, and don't run the task yourself — this session is only about the automation's configuration.`, + ].join('\n'); +} diff --git a/src/memory/memory-mcp.ts b/src/memory/memory-mcp.ts index 9e730b29..d1b99d31 100644 --- a/src/memory/memory-mcp.ts +++ b/src/memory/memory-mcp.ts @@ -779,7 +779,11 @@ const TOOLS = [ 'If the task needs a specific human\'s OWN connected tools (e.g. THEIR Composio Gmail, their ClickUp), ' + 'those are injected only when the run acts AS that member — so set `runAs` to them (a member id or ' + 'email; use `directory_lookup` if unsure). Otherwise the scheduled run silently won\'t have those ' + - 'tools. The approver sees whose credentials will be used and can change it.', + 'tools. The approver sees whose credentials will be used and can change it. ' + + 'EDITING AN EXISTING AUTOMATION: pass `editOf` (its id) and ONLY the fields that change — name, ' + + 'schedule (cron), filter (event triggers), mode, runAs ("" = company identity) or task (replaces the ' + + 'WHOLE prompt, so send the full revised text). The trigger type and agent can\'t be changed by an edit. ' + + 'Same governance: the edit is a draft on a review card and the automation is untouched until approved.', inputSchema: { type: 'object', additionalProperties: false, @@ -792,9 +796,10 @@ const TOOLS = [ agentId: { type: 'string', description: 'Which agent the automation runs. Defaults to you (the proposing agent).' }, mode: { type: 'string', enum: ['headless', 'interactive'], description: 'headless (unattended, default for event triggers) or interactive.' }, runAs: { type: 'string', description: 'Optional member (id or email) the fired session should ACT AS, so THEIR personal connectors — e.g. their own Composio Gmail — are injected. Omit to run as the company identity (shared connectors only). Suggest this whenever the task uses a person\'s personal apps.' }, - rationale: { type: 'string', description: 'Why this automation is worth running — the approver reads this to decide.' }, + rationale: { type: 'string', description: 'Why this automation is worth running (or, for an edit, why the change) — the approver reads this to decide.' }, + editOf: { type: 'string', description: 'The id of an EXISTING automation to edit instead of creating a new one. Send only the fields that change; name and task are then optional.' }, }, - required: ['name', 'task'], + required: [], }, }, { @@ -2167,7 +2172,9 @@ async function workflowPropose(args: Record): Promise { async function automationPropose(args: Record): Promise { const name = String(args.name ?? '').trim(); const task = String(args.task ?? '').trim(); - if (!name || !task) return 'automation_propose needs a name and a task (the prompt the automation runs).'; + const editOf = String(args.editOf ?? '').trim(); + if (editOf) return automationProposeEdit(editOf, args); + if (!name || !task) return 'automation_propose needs a name and a task (the prompt the automation runs) — or `editOf` to change an existing automation.'; const res = await fetch(AOS_URL + '/api/agent/automation/propose', { method: 'POST', headers: H({ 'content-type': 'application/json' }), @@ -2188,6 +2195,22 @@ async function automationPropose(args: Record): Promise : `Could not propose the automation: ${d.error ?? 'unknown error'}`; } +/** The edit lane of `automation_propose`: fields are forwarded only when the agent SENT them (an empty + * string is meaningful — `runAs: ""` clears back to company identity), so omission means "keep". */ +async function automationProposeEdit(editOf: string, args: Record): Promise { + const body: Record = { session: SESSION, agent: AGENT, editOf }; + for (const k of ['name', 'task', 'schedule', 'filter', 'mode', 'runAs', 'rationale']) if (args[k] !== undefined && args[k] !== null) body[k] = String(args[k]); + const res = await fetch(AOS_URL + '/api/agent/automation/propose', { + method: 'POST', + headers: H({ 'content-type': 'application/json' }), + body: JSON.stringify(body), + }); + const d = (await res.json()) as { ok?: boolean; preview?: string; error?: string }; + return d.ok + ? `Edit proposed for automation ${editOf}:\n${d.preview ?? ''}\n\nIt's a DRAFT on a review card in Automations — the automation is unchanged until an owner/admin approves it.` + : `Could not propose the edit: ${d.error ?? 'unknown error'}`; +} + async function hostPropose(args: Record): Promise { const name = String(args.name ?? '').trim(); const match = String(args.match ?? '').trim(); diff --git a/src/server.ts b/src/server.ts index c7cd75d5..7b7feac4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,6 +36,7 @@ const LIST_CLIP = 240; import { type ChatArtifactRef, type ChatKbRef, type ChatAppRef } from './edge/conversation'; import { summarizeConversation } from './edge/summarize'; import { Automation, Automations, nextCronRun, derivedConcurrencyCap, chatTitle } from './edge/automations'; +import { automationEditBase, buildAutomationEditBrief, planAutomationEdit, EDITABLE_TYPES } from './edge/automation-edit'; import { chooseAgent } from './edge/router'; import { recordCapabilityGap } from './edge/capability-gap'; import { classifyIntent, SOCIAL_REPLY } from './edge/intent'; @@ -945,6 +946,24 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const agent = tm.sessionAgent(session); if (!agent) return sendJson(res, 404, { error: 'unknown session' }); if (!sessionSecretOk(session)) return sendJson(res, 403, { error: 'bad session secret' }); + // `editOf` = an EDIT to an existing automation (the "Edit with agent" lane): only the fields the agent + // sent change, merged over the live automation and validated now, so a proposal that could not be + // applied is refused here rather than at approve time. + if (b.editOf != null && String(b.editOf).trim()) { + const target = autos.get(String(b.editOf).trim()); + if (!target) return sendJson(res, 404, { ok: false, error: `no automation "${String(b.editOf).trim()}" — check the id` }); + const str = (v: unknown) => (v != null ? String(v) : undefined); + const plan = planAutomationEdit(target, { + name: str(b.name), schedule: str(b.schedule), filter: str(b.filter), task: str(b.task), runAs: str(b.runAs), + mode: b.mode === 'headless' || b.mode === 'interactive' ? b.mode : undefined, + }, (ref) => os.team.resolveMemberRef(ref)?.id, (id) => os.team.getMember(id)?.name || os.team.getMember(id)?.email || id); + if ('error' in plan) return sendJson(res, 400, { ok: false, error: plan.error }); + const out = tm.postAutomationEditProposal(session, agent, { + editOf: target.id, agentId: target.agentId, type: target.type as ProposedAutomation['type'], base: automationEditBase(target), + before: { ...plan.before }, after: plan.after, changes: plan.changes, preview: plan.preview, + }, b.rationale != null ? String(b.rationale) : undefined); + return sendJson(res, out.ok ? 200 : 400, { ...out, edit: true }); + } const spec = { agentId: b.agentId != null ? String(b.agentId) : '', name: String(b.name || ''), @@ -2946,6 +2965,32 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: runAs = m.id; } } + // An EDIT proposal updates the automation it names instead of creating one. It is held to the same + // ownership rule as a console edit (owner, or the member who created it), and refuses if the + // automation changed since the agent proposed against it — applying its full-field snapshot then + // would silently revert whatever the human changed in between. + if (card.editOf) { + const target = autos.get(card.editOf); + if (!target) return sendJson(res, 409, { error: 'the automation this edit targets no longer exists — reject the proposal' }); + if (!canManageAuto(me, target)) return sendJson(res, 403, { error: 'you can only approve edits to automations you created' }); + if (card.base && card.base !== automationEditBase(target)) { + return sendJson(res, 409, { error: 'the automation was changed after this edit was proposed — reject it and ask the agent again against the current version' }); + } + const after = (card.after ?? {}) as { name?: string; mode?: 'headless' | 'interactive'; schedule?: string; filter?: string; task?: string }; + try { + const updated = autos.update(target.id, { + name: after.name, mode: after.mode, task: after.task, + ...(target.type === 'cron' ? { schedule: after.schedule } : { filter: after.filter ?? '' }), + runAs: runAs ?? null, + }); + if (!updated) return sendJson(res, 409, { error: 'the automation this edit targets no longer exists' }); + tm.setAutomationProposalStatus(autoPropApprove[1], 'approved'); + os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'automation.edit.approved', data: { by: me.email, agent: card.agent, id: target.id, name: updated.name, changes: card.changes ?? [], runAs: runAs ?? null } }); + return sendJson(res, 200, { ok: true, edited: true, automation: automationView(updated, req, true), automations: [automationView(updated, req, true)] }); + } catch (e) { + return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) }); + } + } // A workflow proposal carries several automations and is approved as a UNIT: a part that `add` // rejects (a bad cron, an agent deleted since the proposal was made) rolls the earlier parts back, so // the approver never ends up with half a function running and no record of which half. The proposal @@ -3100,6 +3145,33 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const r = autos.fire(a, { guard: false, mode }); // explicit human action — no pile-up guard return sendJson(res, 200, r); } + // "Edit with agent": open an interactive session with the automation's OWN agent, briefed with its current + // configuration and recent runs, to talk through a change. The session can only PROPOSE the edit + // (`automation_propose({ editOf })`) — nothing changes until an owner/admin approves the card. Gated like + // the console's Edit (owner/admin who may manage it) plus canRun, since it spawns that agent. + const autoEditAgent = p.match(/^\/api\/automations\/([\w-]+)\/edit-with-agent$/); + if (method === 'POST' && autoEditAgent) { + if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' }); + const a = autos.get(autoEditAgent[1]); + if (!a) return sendJson(res, 404, { error: 'not found' }); + if (!canManageAuto(me, a)) return sendJson(res, 403, { error: 'you can only edit automations you created' }); + if (!EDITABLE_TYPES.includes(a.type)) return sendJson(res, 400, { error: `a ${a.type} automation can't be edited` }); + if (!os.agents.has(a.agentId)) return sendJson(res, 400, { error: `this automation's agent "${a.agentId}" no longer exists` }); + if (!os.team.canRun(me, a.agentId)) return sendJson(res, 403, { error: `you are not assigned to run "${a.agentId}"` }); + const b = await readBody(req); + const label = (id: string) => os.team.getMember(id)?.name || os.team.getMember(id)?.email || id; + const runs = tm.listRunsFor(`automation:${a.id}`, me) + .sort((x, y) => y.createdAt - x.createdAt) + .slice(0, 5) + .map((r) => ({ title: r.title, status: r.outcome && r.outcome !== 'unknown' ? `${r.status}/${r.outcome}` : r.status, createdAt: r.createdAt, summary: r.summary ? clipText(r.summary, 160) : undefined })); + const brief = buildAutomationEditBrief(a, { + memberName: me.name || me.email, runAsLabel: a.runAs ? label(a.runAs) : 'company identity', runs, + note: b.note != null ? String(b.note) : undefined, canApprove: true, + }); + const s = tm.createSession(a.agentId, `Edit automation — ${a.name}`, brief, me.id); + os.audit.append({ ts: Date.now(), runId: s.id, tenant: os.tenant, principal: me.email, type: 'automation.edit.session', data: { id: a.id, name: a.name, agent: a.agentId, session: s.id } }); + return sendJson(res, 200, { ok: true, id: s.id, tmux: s.tmux }); + } const autoRuns = p.match(/^\/api\/automations\/([\w-]+)\/runs$/); if (method === 'GET' && autoRuns) { const a = autos.get(autoRuns[1]); diff --git a/src/terminal.ts b/src/terminal.ts index 529831b8..9befc1d7 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -798,6 +798,26 @@ export interface ProposedAutomation { runAs?: string; } +/** The extra fields an `automation.proposed` card carries when it is an EDIT of an existing automation + * (see {@link TerminalManager.postAutomationEditProposal}); all absent on a create proposal. */ +export interface AutomationEditFields { + editOf?: string; + base?: string; + before?: Record; + after?: Record; + changes?: string[]; +} +function editFieldsOf(a: Record): AutomationEditFields { + if (a.edit !== true || typeof a.editOf !== 'string') return {}; + return { + editOf: a.editOf, + base: typeof a.base === 'string' ? a.base : undefined, + before: a.before && typeof a.before === 'object' ? (a.before as Record) : undefined, + after: a.after && typeof a.after === 'object' ? (a.after as Record) : undefined, + changes: Array.isArray(a.changes) ? a.changes.map(String) : undefined, + }; +} + /** What the session-event notifier sink receives when one of a member's own sessions changes state — it * began (a delegated/unattended run), started waiting on them, finished, or crashed. The registry DMs the * run's owner (its `run_as`, else the console member who spawned it) on Slack/Discord IF that member opted @@ -7552,6 +7572,33 @@ export class TerminalManager { return { ok: true, preview }; } + /** + * An agent proposes an EDIT to an existing automation (the "Edit with agent" lane). The server has + * already merged + validated the change against the live automation (`planAutomationEdit`); this only + * posts the review card. The card also carries `specs: [after]` so every existing reader of + * `automation.proposed` keeps parsing it; `editOf` is what tells the approve route to UPDATE instead of + * create, and `base` pins the automation as it was, so a human edit made meanwhile refuses approval. + */ + postAutomationEditProposal(sessionId: string, agent: string, edit: { editOf: string; agentId: string; type: ProposedAutomation['type'] | 'telegram' | 'clickup'; base: string; before: Record; after: { name: string; task: string; mode: 'headless' | 'interactive'; schedule?: string; filter?: string; runAs?: string }; changes: string[]; preview: string }, rationale?: string): { ok: boolean; preview?: string; error?: string } { + const open = this.db.prepare(`SELECT id, args FROM messages WHERE type = 'automation.proposed' AND status = 'open' AND agent = ?`).all<{ id: string; args: string | null }>(agent); + if (open.length >= 10) return { ok: false, error: 'you already have 10 open automation proposals awaiting review — wait for a human to act on them first' }; + const afterKey = JSON.stringify(edit.after); + if (open.some((o) => { + try { const a = JSON.parse(o.args || '{}') as { editOf?: string; after?: unknown }; return a.editOf === edit.editOf && JSON.stringify(a.after) === afterKey; } catch { return false; } + })) return { ok: false, error: 'an identical edit to this automation from you is already awaiting review' }; + const spec = { agentId: edit.agentId, type: edit.type, ...edit.after } as ProposedAutomation; + const name = String(edit.before.name ?? edit.after.name); + this.postReviewCard({ + type: 'automation.proposed', sessionId, agent, + title: `Automation edit proposed — ${name}`, + body: (rationale?.trim() || `${agent} proposes changing the "${name}" automation.`) + `\n\n${edit.preview}`, + args: { edit: true, editOf: edit.editOf, base: edit.base, before: edit.before, after: edit.after, changes: edit.changes, specs: [spec], spec, preview: edit.preview, ...(rationale ? { rationale } : {}) }, + summary: rationale?.trim() || `${agent} proposes changing the "${name}" automation (${edit.changes.join(', ')}).`, + }); + this.audit(sessionId, agent, 'automation.edit.proposed', { automation: edit.editOf, name, changes: edit.changes }); + return { ok: true, preview: edit.preview }; + } + /** Validate + normalise ONE proposed automation, returning its stored shape and its preview line. */ private validateProposedAutomation(agent: string, spec: ProposedAutomation): { clean: ProposedAutomation; preview: string } | { error: string } { const agentId = (spec.agentId || agent).trim(); @@ -7591,19 +7638,19 @@ export class TerminalManager { /** The proposed-automation review card by id (its specs + status) — for the approve/reject routes. * `spec` stays on the return as the FIRST part, so single-automation callers read unchanged. */ - automationProposalCard(id: string): { agent: string; spec: ProposedAutomation; specs: ProposedAutomation[]; workflow?: string; rationale?: string; preview?: string; status: string } | undefined { + automationProposalCard(id: string): { agent: string; spec: ProposedAutomation; specs: ProposedAutomation[]; workflow?: string; rationale?: string; preview?: string; status: string } & AutomationEditFields | undefined { const row = this.db.prepare(`SELECT agent, args, status FROM messages WHERE id = ? AND type = 'automation.proposed'`).get<{ agent: string; args: string | null; status: string }>(id); if (!row) return undefined; let a: Record = {}; try { a = row.args ? JSON.parse(row.args) : {}; } catch { /* tolerate a corrupt payload */ } const specs = this.proposalSpecs(a); if (!specs.length) return undefined; - return { agent: row.agent, spec: specs[0], specs, workflow: a.workflow ? String(a.workflow) : undefined, rationale: a.rationale ? String(a.rationale) : undefined, preview: a.preview ? String(a.preview) : undefined, status: row.status }; + return { agent: row.agent, spec: specs[0], specs, workflow: a.workflow ? String(a.workflow) : undefined, rationale: a.rationale ? String(a.rationale) : undefined, preview: a.preview ? String(a.preview) : undefined, status: row.status, ...editFieldsOf(a) }; } setAutomationProposalStatus(id: string, status: 'approved' | 'rejected'): void { this.db.prepare(`UPDATE messages SET status = ? WHERE id = ? AND type = 'automation.proposed'`).run(status, id); } - openAutomationProposals(): { id: string; agent: string; spec: ProposedAutomation; specs: ProposedAutomation[]; workflow?: string; rationale?: string; preview?: string; createdAt: number }[] { + openAutomationProposals(): ({ id: string; agent: string; spec: ProposedAutomation; specs: ProposedAutomation[]; workflow?: string; rationale?: string; preview?: string; createdAt: number } & AutomationEditFields)[] { return this.db .prepare(`SELECT id, agent, args, created_at FROM messages WHERE type = 'automation.proposed' AND status = 'open' ORDER BY created_at DESC`) .all<{ id: string; agent: string; args: string | null; created_at: number }>() @@ -7611,7 +7658,7 @@ export class TerminalManager { let a: Record = {}; try { a = r.args ? JSON.parse(r.args) : {}; } catch { /* tolerate corrupt payload */ } const specs = this.proposalSpecs(a); - return { id: r.id, agent: r.agent, spec: specs[0], specs, workflow: a.workflow ? String(a.workflow) : undefined, rationale: a.rationale ? String(a.rationale) : undefined, preview: a.preview ? String(a.preview) : undefined, createdAt: r.created_at }; + return { id: r.id, agent: r.agent, spec: specs[0], specs, workflow: a.workflow ? String(a.workflow) : undefined, rationale: a.rationale ? String(a.rationale) : undefined, preview: a.preview ? String(a.preview) : undefined, createdAt: r.created_at, ...editFieldsOf(a) }; }) .filter((p) => !!p.spec); } diff --git a/web/src/App.tsx b/web/src/App.tsx index ca493162..2f449314 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7179,7 +7179,7 @@ function FeedItem({ m, members = [], onOpen, onOpenArtifact, onOpenTask, onOpenG } else if (m.type === 'automation.proposed') { Icon = Zap; iconCls = 'text-violet-600'; highlight = m.status === 'open' const resolved = m.status === 'approved' ? 'approved' : m.status === 'rejected' ? 'rejected' : '' - verb = 'proposed an automation'; detail = m.body + verb = (m.args as { edit?: boolean } | undefined)?.edit === true ? 'proposed an automation edit' : 'proposed an automation'; detail = m.body badge = resolved ? : review in Automations @@ -13066,8 +13066,8 @@ function AutomationProposalsPanel({ agents, onChanged }: { agents: AgentInfo[];
- {partsOf(pr).length > 1 ? `workflow · ${partsOf(pr).length}` : pr.spec.type} - {pr.workflow || pr.spec.name} + {pr.editOf ? `edit · ${pr.spec.type}` : partsOf(pr).length > 1 ? `workflow · ${partsOf(pr).length}` : pr.spec.type} + {pr.workflow || (pr.editOf ? (pr.before?.name || pr.spec.name) : pr.spec.name)} {partsOf(pr).length > 1 ? <>runs {[...new Set(partsOf(pr).map((sp) => agentName(sp.agentId)))].join(', ')} @@ -13078,6 +13078,16 @@ function AutomationProposalsPanel({ agents, onChanged }: { agents: AgentInfo[]; set before pressing one button. */} {pr.preview &&
{pr.preview}
} {partsOf(pr).length > 1 &&
Approving creates all {partsOf(pr).length} — or none, if any part fails.
} + {/* An edit's task prompt is the one change a one-line preview can't show — put both versions side by side. */} + {pr.editOf && pr.changes?.includes('task') && ( +
+ Compare task prompt +
+
Before
{pr.before?.task}
+
After
{pr.after?.task}
+
+
+ )} {pr.rationale &&

“{pr.rationale}”

}
@@ -13090,7 +13100,7 @@ function AutomationProposalsPanel({ agents, onChanged }: { agents: AgentInfo[];
- + {hint && busy === '' && {hint}}
@@ -13115,6 +13125,8 @@ function AutomationsPage({ me, agents, sessions, serverTz, onOpen, nav, agentFil const [hint, setHint] = useState('') const [openRuns, setOpenRuns] = useState(null) // automation id whose Runs list is expanded const [runPrompt, setRunPrompt] = useState(null) // "Run now" asks headless vs interactive first + const [agentEdit, setAgentEdit] = useState(null) // "Edit with agent" — optional note, then a session + const [agentEditNote, setAgentEditNote] = useState('') const [showForm, setShowForm] = useState(false) // the New-automation form is collapsed until requested const [editId, setEditId] = useState(null) // when set, the form edits this automation instead of creating const formRef = useRef(null) // the create/edit form — scroll it into view when it opens (Edit sits below the fold) @@ -13211,6 +13223,17 @@ function AutomationsPage({ me, agents, sessions, serverTz, onOpen, nav, agentFil } } + // "Edit with agent": spawn an interactive session with the automation's own agent, briefed with its current + // config + recent runs. The agent can only PROPOSE the change — it lands in the proposals panel above. + const editWithAgent = async (a: Automation) => { + setBusy(a.id); setHint('') + const r = await api.editAutomationWithAgent(a.id, agentEditNote.trim() || undefined) + setBusy('') + if (!r.ok || !r.tmux) return setHint('⚠ ' + (r.error || 'failed')) + setAgentEdit(null); setAgentEditNote('') + onOpen(r.tmux, `${a.agentId} · edit ${a.name}`) + } + if (!items) return
Loading…
// When arriving from an agent's "N Automations" shortcut, scope the list to just that agent. @@ -13256,6 +13279,23 @@ function AutomationsPage({ me, agents, sessions, serverTz, onOpen, nav, agentFil
+ { if (!o) { setAgentEdit(null); setAgentEditNote('') } }}> + + Edit “{agentEdit?.name}” with {agentEdit?.agentId} +

+ Opens a session with the agent, briefed with this automation’s current setup and recent runs. Talk through the + change; the agent proposes it, and nothing changes until it’s approved here. +

+