diff --git a/docs/superpowers/plans/2026-10-11-automation-permission-scopes.md b/docs/superpowers/plans/2026-10-11-automation-permission-scopes.md new file mode 100644 index 000000000..6031bedda --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-automation-permission-scopes.md @@ -0,0 +1,1120 @@ +# Automation Permission Scopes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a project share automation definitions in `solenta.json` while each machine opts in before those automations auto-fire. + +**Architecture:** Definitions live in `solenta.json` under `automations` with their own trust hash (`automationsTrust` on the project). Runtime state stays in the local store as overlay rows id’d `shared::`. `listAutomations` merges file + overlay. The ticker and GitHub poller use that merge and skip a project-scoped row unless it is trusted and `enabled` on this machine. + +**Tech Stack:** Electron main (CommonJS `electron/*.js`), React 18 + TypeScript renderer, `node:test`. No new dependency. + +Spec: `docs/superpowers/specs/2026-10-11-automation-permission-scopes-design.md`. Issue: #1590. + +## Global Constraints + +- **Firing model is local opt-in.** Shared file = definition. Auto-fire only when `enabled === true` on this machine and, for `scope === "project"`, `project.automationsTrust ===` the current automations hash. Creator auto-trusts and `enabled: true`. Teammates start `enabled: false` and untrusted. +- **Shared home is `solenta.json`, not `.solenta/`.** `.solenta/` is gitignored. +- **Separate hash from commands.** `automationsTrust` is independent of `repoConfigTrust`. Approving `npm test` must not approve a new agent prompt. +- **`enabled` is never written to the file.** It means auto-fire on this machine for both scopes. +- **Approve-and-run does not flip `enabled`.** `runNow({ id, hash })` on an untrusted shared row trusts, fires once, leaves `enabled` as-is. +- **Slug:** `^[a-z][a-z0-9-]{0,47}$`. At most 16 automations. Prompt cap 4000. `MAX_BYTES` 256 KB. +- **No auto-commit. No private ↔ project convert. No SSH file read/write.** +- **There is no `projects:changed` push today.** `trustFile` stores the hash; the renderer re-lists projects with the existing `api.projects.list()` path. Do not add a new push channel. +- Every new `electron/` file starts with `"use strict";`. JSDoc on exported functions. +- After editing `src/shared/ipcChannels.ts`, run `node --experimental-strip-types scripts/sync-ipc-preload.js`. +- Test commands, verbatim: + - one electron file: `node --import=./test/support/render.mjs --experimental-strip-types --test electron/test/.test.js` + - one renderer file: `CODER_GROK_MCP_DISABLE=1 node --import=./test/support/render.mjs --experimental-strip-types --test --test-timeout=20000 test/.test.tsx` + - typecheck: `npm run typecheck` +- Commit after every task. Message style: imperative subject, include `(#1590)`. + +## File Structure + +| File | Responsibility | +|---|---| +| `electron/automations-file.js` (new) | Parse/hash/write the `automations` key; slugs; shared ids; merge; `canAutoFire`; orphan GC; cache invalidation hook. | +| `electron/repoConfig.js` | Raise `MAX_BYTES` to 256 KB; export `invalidateRepoConfigCache(root)`; `presentAutomationsFile(project)`. | +| `electron/store-migrate.js` | `scope` default `"private"`; normalize `automationsTrust`. | +| `electron/services-settings.js` | Merged `listAutomations`; project-scope add/update/remove; `trustFile`. | +| `electron/automations.js` | Ticker uses merged list + `canAutoFire`; `runNow` accepts `{ id, hash? }`; ensure overlay before fire. | +| `electron/automation-triggers.js` | `pollTriggers` uses merged list + `canAutoFire`. | +| `electron/projectIcon.js` `presentProject` | Attach `automationsFile`. | +| `electron/ipc-runs.js` | `automations:trustFile`; pass `hash` into `runNow`. | +| `src/shared/ipc.ts` | `scope`, `automationsFile`, `trustFile`, `runNow` hash. | +| `src/shared/ipcChannels.ts` + preload | New `trustFile` row. | +| `src/components/AutomationsView.tsx` | Scope select, badge, approve card, write note, enable copy. | +| `src/coder/useCoderWorkflows.ts` | `runNow` may pass `hash`; `trustAutomationsFile`. | +| `src/dev/automations.ts` + `test/support/fakeCoder.ts` | Same semantics / stub. | +| `electron/test/automations-file.test.js` (new) | Parse, hash, write, merge, fire gate, trust, orphan GC. | +| `electron/test/automations.test.js` | Ticker / `runNow` hash / private unchanged. | +| `test/automationsView.test.tsx` | Scope, badge, approve card, write note. | + +--- + +### Task 1: Parse, hash, and present the automations section + +**Files:** +- Create: `electron/automations-file.js` +- Modify: `electron/repoConfig.js` (`MAX_BYTES`, cache invalidate, `presentAutomationsFile`) +- Modify: `electron/projectIcon.js` (`presentProject` attaches `automationsFile`) +- Test: `electron/test/automations-file.test.js` + +**Interfaces:** +- Consumes: `electron/repoConfig.js` `readRepoConfig` cache (via a new `readRepoJson` or by reading the same file); `normalizeTrigger` from `electron/automation-triggers.js`; `normalizeRoute` from `electron/routing.js`; `isKnownProviderId` from `electron/services-notes.js`. +- Produces: + - `SLUG_RE`, `MAX_SHARED_AUTOMATIONS` (16), `sharedId(projectId, slug)`, `slugFromName(name, taken)` + - `parseAutomations(data) → { automations, hash } | { error, automations: [] }` + - `automationsHash(rows)`, `readAutomations(root)`, `presentAutomationsFile(project)` + - `invalidateRepoConfigCache(absFileOrRoot)` exported from `repoConfig.js` + +- [ ] **Step 1: Write the failing test** + +Create `electron/test/automations-file.test.js`: + +```js +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { + parseAutomations, + automationsHash, + readAutomations, + presentAutomationsFile, + slugFromName, + sharedId, + MAX_SHARED_AUTOMATIONS, +} = require("../automations-file.js"); +const { parseRepoConfig } = require("../repoConfig.js"); + +const ROW = { + id: "pr-triage", + name: "PR triage", + prompt: "Triage the new pull request.", + provider: "claude", + model: null, + preset: "hourly", + hour: null, + trigger: { type: "github", event: "pr_opened" }, + tier: null, +}; + +describe("parseAutomations", () => { + it("accepts a valid row and hashes independently of commands", () => { + const section = parseAutomations({ setup: "npm ci", automations: [ROW] }); + assert.equal(section.error, undefined); + assert.equal(section.automations.length, 1); + assert.equal(section.automations[0].id, "pr-triage"); + assert.match(section.hash, /^[0-9a-f]{64}$/); + const commands = parseRepoConfig({ setup: "npm ci", automations: [ROW] }); + assert.notEqual(section.hash, commands.hash); + const tweaked = parseAutomations({ + setup: "npm ci", + automations: [{ ...ROW, prompt: "Different" }], + }); + assert.notEqual(section.hash, tweaked.hash); + const sameAuto = parseAutomations({ + setup: "evil", + automations: [ROW], + }); + assert.equal(section.hash, sameAuto.hash); + }); + + it("one bad row fails only the automations section", () => { + const data = { + setup: "npm ci", + automations: [{ ...ROW, id: "BAD" }], + }; + const section = parseAutomations(data); + assert.match(section.error, /id/); + assert.deepEqual(section.automations, []); + const commands = parseRepoConfig(data); + assert.equal(commands.setup, "npm ci"); + assert.equal(commands.error, undefined); + }); + + it("rejects a 17th row, a duplicate id, and a too-long prompt", () => { + const many = Array.from({ length: MAX_SHARED_AUTOMATIONS + 1 }, (_, i) => ({ + ...ROW, + id: `job-${i}`, + })); + assert.match(parseAutomations({ automations: many }).error, /16/); + assert.match( + parseAutomations({ automations: [ROW, { ...ROW, name: "Other" }] }).error, + /duplicate/i, + ); + assert.match( + parseAutomations({ automations: [{ ...ROW, prompt: "x".repeat(4001) }] }) + .error, + /4000/, + ); + }); + + it("an automations-only file still presents", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-auto-")); + fs.writeFileSync( + path.join(dir, "solenta.json"), + JSON.stringify({ automations: [ROW] }), + ); + assert.equal(parseRepoConfig({ automations: [ROW] }), null); + const section = readAutomations(dir); + assert.equal(section.automations[0].id, "pr-triage"); + const presented = presentAutomationsFile({ path: dir }); + assert.equal(presented.count, 1); + assert.equal(presented.trusted, false); + assert.equal(presentAutomationsFile({ remoteHost: "h", path: dir }), null); + }); +}); + +describe("slugFromName", () => { + it("derives pr-triage and falls back to auto", () => { + assert.equal(slugFromName("PR triage", []), "pr-triage"); + assert.equal(slugFromName("PR triage", ["pr-triage"]), "pr-triage-2"); + assert.equal(slugFromName("!!!", []), "auto"); + assert.equal(slugFromName("!!!", ["auto"]), "auto-2"); + }); +}); + +describe("sharedId", () => { + it("is shared:project:slug", () => { + assert.equal(sharedId("p1", "pr-triage"), "shared:p1:pr-triage"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --import=./test/support/render.mjs --experimental-strip-types --test electron/test/automations-file.test.js` + +Expected: FAIL — `Cannot find module '../automations-file.js'` + +- [ ] **Step 3: Write minimal implementation** + +`electron/automations-file.js`: + +- `SLUG_RE = /^[a-z][a-z0-9-]{0,47}$/` +- `MAX_SHARED_AUTOMATIONS = 16` +- `PROMPT_MAX = 4000` +- `sharedId(projectId, slug)` → `` `shared:${projectId}:${slug}` `` +- `slugFromName(name, taken)`: lower-case, replace `[^a-z0-9]+` with `-`, trim hyphens; if it fails `SLUG_RE` use `auto`; while taken, append `-2`, `-3`, … +- `parseAutomations(data)`: if `data.automations` is undefined, return `{ automations: [], hash: null }`. If not an array, `{ error, automations: [] }`. Validate each row (slug, unique, name, prompt ≤ 4000, `isKnownProviderId`, preset, hour, `normalizeTrigger` / `parseTriggerInput` for trigger, `normalizeRoute` for tier). On any failure return `{ error, automations: [] }` and **no** hash. Success: `{ automations: normalizedRows, hash: automationsHash(rows) }`. +- `automationsHash(rows)`: `createHash("sha256").update(JSON.stringify(rows.map(canonical))).digest("hex")` where canonical is `{ id, name, prompt, provider, model, preset, hour, trigger, tier }` in that key order. +- `readAutomations(root)`: read `/solenta.json` (reuse `readRepoConfig`’s file read or a shared `readRepoJson` so mtime cache is one). Missing file → `{ automations: [], hash: null }`. Invalid JSON → `{ error: "solenta.json is not valid JSON", automations: [] }`. Else `parseAutomations(json)`. +- `presentAutomationsFile(project)`: null when `!project || project.remoteHost || !project.path`. Else `readAutomations(project.path)`; if no hash and no error, return null (no automations key). Else `{ hash, trusted: project.automationsTrust === hash, error, count }`. + +`electron/repoConfig.js`: + +- `MAX_BYTES = 256 * 1024` and change the oversize error string from + `larger than 64 KB` to `larger than 256 KB`. +- Export `invalidateRepoConfigCache(root)` that `cache.delete(path.join(root, REPO_CONFIG_FILE))`. +- Keep `parseRepoConfig` command-only. An automations-only object still returns `null` (no commands). Do not let automations poison the commands parse. + +`electron/projectIcon.js` `presentProject`: after `repoConfig`, + +```js +const automationsFile = require("./automations-file.js").presentAutomationsFile(next); +if (automationsFile) next = { ...next, automationsFile }; +``` + +- [ ] **Step 4: Run the tests** + +Run: `node --import=./test/support/render.mjs --experimental-strip-types --test electron/test/automations-file.test.js electron/test/project-setup-h3.test.js` + +Expected: PASS. Existing command tests still pass; `MAX_BYTES` change must not break the “larger than 64 KB” test — update that assertion in `project-setup-h3.test.js` to 256 KB if it mentions 64. + +- [ ] **Step 5: Commit** + +```bash +git add electron/automations-file.js electron/repoConfig.js electron/projectIcon.js electron/test/automations-file.test.js electron/test/project-setup-h3.test.js +git commit -m "$(cat <<'EOF' +Parse shared automations from solenta.json (#1590) + +EOF +)" +``` + +--- + +### Task 2: Store migration for `scope` and `automationsTrust` + +**Files:** +- Modify: `electron/store-migrate.js` (`migrateAutomation`, `migrateProject`) +- Test: `electron/test/store.test.js` (existing automation migrate case) and `electron/test/project-setup-h3.test.js` (trust hash normalize) + +**Interfaces:** +- Consumes: `normalizeTrustHash` (already required). +- Produces: every loaded automation has `scope: "private" | "project"`; `automationsTrust` is 64 hex or absent. + +- [ ] **Step 1: Write the failing test** + +Add to `electron/test/store.test.js` next to the existing automations migrate test: + +```js +it("migrates missing automation scope to private", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-store-")); + const file = path.join(dir, "store.json"); + fs.writeFileSync( + file, + JSON.stringify({ + projects: [], + threads: [], + automations: [ + { + id: "a1", + projectId: "p", + name: "Old", + prompt: "x", + provider: "claude", + preset: "hourly", + }, + ], + }), + ); + const store = new Store(file); + const [auto] = store.getAutomations(); + assert.equal(auto.scope, "private"); +}); +``` + +Add to `electron/test/project-setup-h3.test.js` beside the `repoConfigTrust` normalize cases: + +```js +it("drops a non-hex automationsTrust", () => { + const n = migrateProject({ id: "p", path: "/tmp/p", automationsTrust: "nothex" }); + assert.equal(n.automationsTrust, undefined); +}); + +it("keeps a hex automationsTrust", () => { + const ok = migrateProject({ + id: "p", + path: "/tmp/p", + automationsTrust: "a".repeat(64), + }); + assert.equal(ok.automationsTrust, "a".repeat(64)); +}); +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `node --import=./test/support/render.mjs --experimental-strip-types --test electron/test/store.test.js electron/test/project-setup-h3.test.js` + +Expected: FAIL on `scope` / `automationsTrust`. + +- [ ] **Step 3: Implement** + +In `migrateAutomation`, add: + +```js +scope: a.scope === "project" ? "project" : "private", +``` + +In `migrateProject`, after `repoConfigTrust`: + +```js +const autoTrust = normalizeTrustHash(next.automationsTrust); +if (autoTrust) next.automationsTrust = autoTrust; +else delete next.automationsTrust; +``` + +- [ ] **Step 4: Re-run those two files** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add electron/store-migrate.js electron/test/store.test.js electron/test/project-setup-h3.test.js +git commit -m "$(cat <<'EOF' +Migrate automation scope and automationsTrust (#1590) + +EOF +)" +``` + +--- + +### Task 3: Merged list + +**Files:** +- Modify: `electron/automations-file.js` (`mergeAutomations`, `gcOrphanOverlays`, `canAutoFire`) +- Modify: `electron/services-settings.js` (`listAutomations`) +- Test: `electron/test/automations-file.test.js` + +**Interfaces:** +- Consumes: `Store.getAutomations`, `Store.getProjects`, `nextFire` from `electron/automations.js`. +- Produces: + - `mergeAutomations(store, nowMs) → AutomationInfo[]` + - `gcOrphanOverlays(store) → boolean` (true if it mutated) + - `canAutoFire(store, auto) → boolean` + - `listAutomations(store)` returns the merge (no save) + +- [ ] **Step 1: Write the failing test** + +Append to `electron/test/automations-file.test.js`. Use a real `Store` + temp repo with `solenta.json`: + +```js +const { Store } = require("../store.js"); +const { mergeAutomations, gcOrphanOverlays, canAutoFire } = require("../automations-file.js"); +const services = require("../services.js"); + +function tempRepo(automations) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-auto-")); + fs.writeFileSync( + path.join(dir, "solenta.json"), + JSON.stringify({ automations }, null, 2), + ); + return dir; +} + +describe("mergeAutomations", () => { + it("lists private store rows plus file rows with overlay defaults", () => { + const dir = tempRepo([ROW]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + store.setAutomations([ + { + id: "uuid-1", + projectId: "p1", + name: "Local", + prompt: "x", + provider: "claude", + preset: "hourly", + hour: null, + enabled: true, + scope: "private", + lastRunAt: null, + nextRunAt: 1, + lastError: null, + }, + ]); + const listed = mergeAutomations(store, Date.now()); + assert.equal(listed.length, 2); + const shared = listed.find((a) => a.scope === "project"); + assert.equal(shared.id, "shared:p1:pr-triage"); + assert.equal(shared.enabled, false); + assert.equal(shared.prompt, ROW.prompt); + assert.equal(shared.memory, ""); + assert.equal(shared.triggerCursor, null); + assert.equal(canAutoFire(store, shared), false); + }); + + it("file definition wins; overlay keeps enabled/cursor/memory", () => { + const dir = tempRepo([{ ...ROW, prompt: "From file" }]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir, automationsTrust: "dead".repeat(16) }]); + store.setAutomations([ + { + id: "shared:p1:pr-triage", + projectId: "p1", + scope: "project", + name: "Stale", + prompt: "Stale", + provider: "claude", + preset: "hourly", + hour: null, + enabled: true, + memory: "keep me", + triggerCursor: 9, + lastRunAt: 1, + nextRunAt: 2, + lastError: null, + }, + ]); + const shared = mergeAutomations(store, Date.now()).find((a) => a.scope === "project"); + assert.equal(shared.prompt, "From file"); + assert.equal(shared.name, "PR triage"); + assert.equal(shared.enabled, true); + assert.equal(shared.memory, "keep me"); + assert.equal(shared.triggerCursor, 9); + assert.equal(canAutoFire(store, shared), false); // trust mismatch + }); + + it("gc drops overlays whose slug left the file", () => { + const dir = tempRepo([]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + store.setAutomations([ + { + id: "shared:p1:gone", + projectId: "p1", + scope: "project", + name: "Gone", + prompt: "x", + provider: "claude", + preset: "hourly", + hour: null, + enabled: true, + lastRunAt: null, + nextRunAt: 0, + lastError: null, + }, + ]); + assert.equal(gcOrphanOverlays(store), true); + assert.equal(store.getAutomations().length, 0); + }); +}); +``` + +Also add: `services.listAutomations(store)` equals `mergeAutomations(store, …)` (same ids). + +- [ ] **Step 2: Run to verify fail** + +Expected: FAIL — `mergeAutomations` not exported / `listAutomations` still raw store. + +- [ ] **Step 3: Implement** + +`mergeAutomations(store, nowMs)`: + +1. `privates` = store rows with `scope !== "project"`. +2. For each local project, `readAutomations(project.path)`. Skip `error` / empty. +3. For each file row, id = `sharedId(project.id, row.id)`. Overlay = store row with that id, else defaults (`enabled: false`, `memory: ""`, `triggerCursor: null`, `lastRunAt: null`, `lastError: null`, `consecutiveFailures: 0`, `pendingRunThreadId: null`, `nextRunAt: nextFire(preset, hour, nowMs)`). +4. Definition fields from the file always. +5. Return `[...privates, ...shared]`. + +`canAutoFire(store, auto)`: `auto.enabled` and (if `scope === "project"`, `presentAutomationsFile(project)?.trusted`). + +`gcOrphanOverlays(store)`: valid ids = every `sharedId` currently in files; drop store rows with `scope === "project"` whose id is not valid. Return whether the list changed. **Do not save.** + +`listAutomations`: `return mergeAutomations(store, Date.now()).map((a) => ({ ...a }))`. + +Avoid a require cycle: `automations-file.js` should require `nextFire` lazily inside `mergeAutomations` (`require("./automations.js").nextFire`). + +- [ ] **Step 4: Re-run `electron/test/automations-file.test.js` and `electron/test/automations.test.js`** + +Expected: PASS. Existing `listAutomations` tests still see private-only stores. + +- [ ] **Step 5: Commit** + +```bash +git add electron/automations-file.js electron/services-settings.js electron/test/automations-file.test.js +git commit -m "$(cat <<'EOF' +Merge solenta.json automations with local overlays (#1590) + +EOF +)" +``` + +--- + +### Task 4: Project-scope writes + +**Files:** +- Modify: `electron/automations-file.js` (`writeAutomations`, `ensureOverlay`) +- Modify: `electron/services-settings.js` (`addAutomation`, `updateAutomation`, `removeAutomation`) +- Test: `electron/test/automations-file.test.js` + +**Interfaces:** +- Consumes: `parseAutomations`, `invalidateRepoConfigCache`, `normalizeAutomationInput`. +- Produces: add/update/remove with `scope: "project"` mutate `/solenta.json` and the overlay; auto-set `automationsTrust`; `gcOrphanOverlays` on those writes then `store.save()`. + +- [ ] **Step 1: Write the failing test** + +```js +describe("project-scope writes", () => { + it("add writes solenta.json, trusts, and enables", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-auto-")); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + const created = services.addAutomation(store, { + projectId: "p1", + name: "PR triage", + prompt: "Triage", + provider: "claude", + preset: "hourly", + scope: "project", + }); + assert.equal(created.scope, "project"); + assert.equal(created.id, "shared:p1:pr-triage"); + assert.equal(created.enabled, true); + const file = JSON.parse(fs.readFileSync(path.join(dir, "solenta.json"), "utf8")); + assert.equal(file.automations[0].id, "pr-triage"); + assert.equal(file.automations[0].enabled, undefined); + const hash = parseAutomations(file).hash; + assert.equal(store.getProject("p1").automationsTrust, hash); + assert.equal(canAutoFire(store, created), true); + }); + + it("teammate overlay defaults disabled; memory update does not rewrite prompt", () => { + const dir = tempRepo([ROW]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + const listed = services.listAutomations(store).find((a) => a.scope === "project"); + assert.equal(listed.enabled, false); + services.updateAutomation(store, { id: listed.id, memory: "notes" }); + const file = JSON.parse(fs.readFileSync(path.join(dir, "solenta.json"), "utf8")); + assert.equal(file.automations[0].prompt, ROW.prompt); + assert.equal(services.listAutomations(store)[0].memory, "notes"); + }); + + it("remove drops the slug; invalid JSON is not clobbered; remote is rejected", () => { + const dir = tempRepo([ROW]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + const id = "shared:p1:pr-triage"; + services.removeAutomation(store, { id }); + const file = JSON.parse(fs.readFileSync(path.join(dir, "solenta.json"), "utf8")); + assert.deepEqual(file.automations, []); + + fs.writeFileSync(path.join(dir, "solenta.json"), "{ nope"); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + assert.throws( + () => + services.addAutomation(store, { + projectId: "p1", + name: "X", + prompt: "x", + provider: "claude", + preset: "hourly", + scope: "project", + }), + /not valid JSON/, + ); + assert.equal(fs.readFileSync(path.join(dir, "solenta.json"), "utf8"), "{ nope"); + + store.setProjects([{ id: "r1", name: "r", path: dir, remoteHost: "h" }]); + assert.throws( + () => + services.addAutomation(store, { + projectId: "r1", + name: "X", + prompt: "x", + provider: "claude", + preset: "hourly", + scope: "project", + }), + /remote/i, + ); + }); + + it("rejects changing scope and a private id that starts with shared:", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-auto-")); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + const priv = services.addAutomation(store, { + projectId: "p1", + name: "Local", + prompt: "x", + provider: "claude", + preset: "hourly", + }); + assert.equal(priv.scope, "private"); + assert.throws( + () => services.updateAutomation(store, { id: priv.id, scope: "project" }), + /scope/, + ); + }); +}); +``` + +Preserve unknown keys: start with `{ setup: "npm ci", extra: true }` and after add, `setup` and `extra` remain. + +- [ ] **Step 2: Run to verify fail** + +Expected: FAIL — add ignores `scope`. + +- [ ] **Step 3: Implement** + +`writeAutomations(projectPath, rows)`: + +1. Read file. Missing → `doc = {}`. Exists + invalid JSON → throw `solenta.json is not valid JSON`. +2. `doc.automations = rows` (definition fields only). If `rows` is empty, write `automations: []` (do not delete the key). +3. `fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n")`. +4. `invalidateRepoConfigCache(projectPath)`. +5. Return `parseAutomations(doc)`. + +`addAutomation`: after `normalizeAutomationInput`, if `scope === "project"` (from input; existing is null): + +- Reject `remoteHost` / missing path. +- Reject an explicit `id` that fails `SLUG_RE`. +- Slug = input.id or `slugFromName(name, existingFileIds)`. +- Append definition to file via `writeAutomations`. +- `store.updateProject` / setProjects field `automationsTrust = hash`. +- Push overlay `{ id: sharedId(...), scope: "project", enabled: input.enabled !== false, …runtime defaults }`. +- `gcOrphanOverlays`; save; return merged row. + +Private add: if someone passed `id` starting with `shared:`, throw. Default `scope: "private"`. Current UUID path unchanged. + +`updateAutomation`: + +- If existing.scope === "project": `scope` in input that differs → throw. +- Definition fields changed (name, prompt, provider, model, preset, hour, trigger, tier) → rewrite that slug in the file, refresh `automationsTrust`. +- `enabled` / `memory` only → overlay only. +- Ensure overlay exists before patch. + +`removeAutomation`: + +- Project: rewrite file without that slug, drop overlay, refresh hash (empty array hash is still stored if the key remains). +- Private: current path. + +`normalizeAutomationInput`: pass through `scope` from existing; on create default `"private"`; reject unknown values. + +- [ ] **Step 4: Re-run automations-file + automations tests** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add electron/automations-file.js electron/services-settings.js electron/test/automations-file.test.js +git commit -m "$(cat <<'EOF' +Write project-scoped automations to solenta.json (#1590) + +EOF +)" +``` + +--- + +### Task 5: Fire gates, `trustFile`, and `runNow` hash + +**Files:** +- Modify: `electron/services-settings.js` (`trustFile`) +- Modify: `electron/automations.js` (`dueAutomations` or its caller; `runNow`; ticker; `gcOrphanOverlays` on tick) +- Modify: `electron/automation-triggers.js` (`pollTriggers`) +- Modify: `electron/services.js` exports if `trustFile` is re-exported +- Test: `electron/test/automations-file.test.js`, `electron/test/automations.test.js`, `electron/test/automation-triggers.test.js` + +**Interfaces:** +- Consumes: `mergeAutomations`, `canAutoFire`, `presentAutomationsFile`. +- Produces: + - `trustFile(store, { projectId, hash })` + - `runNow(ctx, { id, hash? })` — keep a string `id` overload if existing tests pass a string; prefer object from IPC. + - Ticker: `gcOrphanOverlays` then `dueAutomations(merge…)` filtered by `canAutoFire`. + - Poller: iterate merge, skip `!canAutoFire`. + +- [ ] **Step 1: Write the failing tests** + +Fire gates (new describe in `automations-file.test.js` or `automations.test.js`): + +```js +it("untrusted shared is never due", () => { + const now = Date.now(); + const auto = { + id: "shared:p1:pr-triage", + scope: "project", + enabled: true, + nextRunAt: now - 1, + }; + // Build a store+file whose hash !== automationsTrust, enabled true. + assert.equal(dueAutomations([auto], now).length, 1); // still filters enabled+due + // canAutoFire is the real gate — assert false, and the ticker uses it. +}); +``` + +Do not change `dueAutomations`’s existing contract (enabled + no trigger + nextRunAt). Put the trust gate in the ticker: + +```js +const due = dueAutomations(mergeAutomations(ctx.store, now), now) + .filter((a) => canAutoFire(ctx.store, a)); +``` + +`trustFile`: + +```js +it("trustFile persists a matching hash and rejects a stale one", () => { + const dir = tempRepo([ROW]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + const hash = readAutomations(dir).hash; + assert.throws(() => services.trustFile(store, { projectId: "p1", hash: "a".repeat(64) }), /hash/); + services.trustFile(store, { projectId: "p1", hash }); + assert.equal(store.getProject("p1").automationsTrust, hash); +}); +``` + +`runNow`: + +```js +it("runNow on untrusted shared rejects; hash trusts and fires once without enabling", async () => { + // create file+overlay enabled:false, no automationsTrust + // runNow(ctx, { id }) throws /not trusted/ + // runNow(ctx, { id, hash }) starts a run; overlay.enabled stays false +}); +``` + +Reuse the existing `startScheduler` fake-runner pattern in `electron/test/automations.test.js`. + +Poller: add a case in `electron/test/automation-triggers.test.js` — enabled shared + untrusted → `fireRun` not called. + +Before `runAutomation`, if the id is shared and no store row exists, write a default overlay so `patchAutomation` can stamp `lastRunAt`. + +- [ ] **Step 2: Run to verify fail** + +Expected: FAIL — `trustFile` missing; ticker still uses `store.getAutomations()`. + +- [ ] **Step 3: Implement** + +`trustFile(store, { projectId, hash })`: + +- `requireProject`. +- `section = readAutomations(project.path)`. +- If `!section.hash` throw `No automations in solenta.json`. +- If `hash !== section.hash` throw `Automations file is not trusted` or `Automations file hash does not match`. +- Set `automationsTrust`, save. + +`runNow(ctx, input)`: + +```js +const id = typeof input === "string" ? input : String(input && input.id || ""); +const hash = typeof input === "object" && input ? input.hash : undefined; +let auto = mergeAutomations(ctx.store, Date.now()).find((a) => a.id === id); +if (!auto) throw new Error(`Unknown automation: ${id}`); +if (auto.scope === "project" && !canAutoFire(ctx.store, { ...auto, enabled: true })) { + // trusted check only — runNow may run while enabled is false + const project = ctx.store.getProject(auto.projectId); + const file = presentAutomationsFile(project); + if (!file || !file.trusted) { + if (!hash || hash !== file?.hash) { + throw new Error("Automations file is not trusted"); + } + services.trustFile(ctx.store, { projectId: auto.projectId, hash }); + } +} +ensureOverlay(ctx.store, auto); +await runAutomation(ctx, ctx.store.getAutomation(id) || auto, Date.now(), { rethrow: true }); +return mergeAutomations(ctx.store, Date.now()).find((a) => a.id === id); +``` + +Careful: `canAutoFire` requires `enabled`. For the trust check on runNow, compare `file.trusted` / hash only. Enabled is irrelevant for manual run. + +Ticker `tick`: + +```js +if (gcOrphanOverlays(ctx.store)) ctx.store.save(); +const due = dueAutomations(mergeAutomations(ctx.store, now), now) + .filter((a) => canAutoFire(ctx.store, a)); +``` + +`pollTriggers`: `for (const auto of mergeAutomations(ctx.store, Date.now()))` and `if (!canAutoFire(ctx.store, auto) || !auto.trigger || …) continue`. + +Export `trustFile` from `services-settings.js` and `services.js`. + +- [ ] **Step 4: Re-run electron automations suites** + +``` +node --import=./test/support/render.mjs --experimental-strip-types --test \ + electron/test/automations-file.test.js \ + electron/test/automations.test.js \ + electron/test/automation-triggers.test.js +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add electron/services-settings.js electron/services.js electron/automations.js electron/automation-triggers.js electron/automations-file.js electron/test +git commit -m "$(cat <<'EOF' +Gate shared automation fires on trust and local enable (#1590) + +EOF +)" +``` + +--- + +### Task 6: IPC types, channel, and clients + +**Files:** +- Modify: `src/shared/ipc.ts` (`AutomationInfo.scope`, `AutomationWrite.scope` + optional `id`, `ProjectInfo.automationsFile`, `automations.trustFile`, `runNow` input) +- Modify: `src/shared/ipcChannels.ts` (add `{ ns: "automations", method: "trustFile" }`) +- Modify: `electron/ipc-runs.js` +- Modify: `electron/preload.js` via `scripts/sync-ipc-preload.js` +- Modify: `src/coder/useCoderWorkflows.ts` (`runAutomationNow(id, hash?)`, `trustAutomationsFile`) +- Modify: `src/useCoder.ts` (surface the new action) +- Modify: `test/support/fakeCoder.ts` (`trustFile` stub) +- Modify: `src/dev/automations.ts` (in-memory trust + scope; file write is best-effort against `project.path` if present) + +**Interfaces:** +- Consumes: Task 5 functions. +- Produces: renderer can call `api.automations.trustFile` and `runNow({ id, hash })`. + +- [ ] **Step 1: Write the failing type / wiring check** + +Add `scope?: "private" | "project"` on `AutomationInfo` (required on listed rows — use `scope: "private" | "project"` required, migrate in store). Prefer required on `AutomationInfo` and optional on `AutomationWrite`. + +```ts +export type AutomationScope = "private" | "project"; + +// AutomationInfo +scope: AutomationScope; + +// AutomationWrite +scope?: AutomationScope; +id?: string; // slug, project-scope create only + +// ProjectInfo +automationsFile?: { + hash: string; + trusted: boolean; + error?: string; + count: number; +}; + +// CoderApi.automations +trustFile(input: { projectId: string; hash: string }): Promise; +runNow(input: { id: string; hash?: string }): Promise; +``` + +Add the ipcChannels row. Do **not** hand-edit preload; run the sync script. + +Add a small electron IPC test only if this repo already tests handler names; otherwise the typecheck lock (`IPC_CHANNEL_LOCK`) is the failing test. + +- [ ] **Step 2: Run typecheck to see it fail** + +Run: `npm run typecheck` + +Expected: FAIL — `trustFile` missing on fakeCoder / devCoder / preload lock. + +- [ ] **Step 3: Implement** + +`electron/ipc-runs.js`: + +```js +"automations:trustFile": async (ctx, input) => { + services.trustFile(ctx.store, input); +}, +"automations:runNow": async (ctx, input) => { + return automations.runNow(ctx, input); +}, +``` + +`node --experimental-strip-types scripts/sync-ipc-preload.js` + +`fakeCoder.ts`: `trustFile: (input) => rec("automations.trustFile", [input], undefined)` + +`useCoderWorkflows.ts`: + +```ts +const runAutomationNow = useCallback( + async (automationId: string, hash?: string) => { + try { + return await api.automations.runNow({ + id: automationId, + ...(hash ? { hash } : {}), + }); + } finally { + await refreshAutomations(); + } + }, + [api, refreshAutomations], +); + +const trustAutomationsFile = useCallback( + async (projectId: string, hash: string) => { + await api.automations.trustFile({ projectId, hash }); + await api.projects.list().then(/* caller refreshes projects */); + }, + [api], +); +``` + +Thread `trustAutomationsFile` through `useCoder` the same way `runAutomationNow` is threaded. After trust, also refresh projects (the hook that already calls `api.projects.list()`). + +`src/dev/automations.ts`: keep an in-memory `trustByProject: Record`. `add` with `scope: "project"` uses `shared:${projectId}:${slug}` ids and sets trust. If `project.path` exists, write `solenta.json` the same way as main (optional for the harness; at minimum honor scope/trust in memory so the UI can be exercised). `trustFile` sets the map. `list` sets `enabled` defaults as spec’d. + +- [ ] **Step 4: `npm run typecheck` and renderer automations tests** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/ipc.ts src/shared/ipcChannels.ts electron/preload.js electron/ipc-runs.js src/coder/useCoderWorkflows.ts src/useCoder.ts src/dev/automations.ts test/support/fakeCoder.ts +git commit -m "$(cat <<'EOF' +Expose automations.trustFile over IPC (#1590) + +EOF +)" +``` + +--- + +### Task 7: Automations UI + +**Files:** +- Modify: `src/components/AutomationsView.tsx` +- Modify: `src/components/AutomationsView.module.css` (badge + approve card; reuse existing permission-card classes if they are already global, otherwise copy the ThreadView approve-card structure into this module) +- Modify: `src/App.tsx` (pass `trustFile` / `hash` into the view if not already via props) +- Test: `test/automationsView.test.tsx` + +**Interfaces:** +- Consumes: `AutomationInfo.scope`, `ProjectInfo.automationsFile`, `onTrustFile`, `onRunNow(id, hash?)`. +- Produces: Scope select, Private/Project badge, “run on this machine” copy, approve card, write note. + +- [ ] **Step 1: Write the failing renderer tests** + +In `test/automationsView.test.tsx`: + +```tsx +it("create form has a Scope select; Project is disabled for a remote project", async () => { + const m = await mount( + {}} + onUpdate={async () => {}} + onRemove={async () => {}} + onRunNow={async () => {}} + />, + ); + assert.ok(m.el.querySelector("[data-automation-scope]")); + // select p2 → Project option disabled +}); + +it("badges a project row and labels the toggle run-on-this-machine", async () => { + const m = await mount( + {}} + onUpdate={async () => {}} + onRemove={async () => {}} + onRunNow={async () => {}} + />, + ); + assert.match(m.el.textContent || "", /Project/); + const toggle = m.el.querySelector("[data-automation-toggle]"); + assert.match(toggle.getAttribute("aria-label") || "", /this machine/i); +}); + +it("shows one approve card per untrusted project and calls onTrustFile", async () => { + let trusted = null; + const m = await mount( + {}} + onUpdate={async () => {}} + onRemove={async () => {}} + onRunNow={async () => {}} + onTrustFile={async (projectId, hash) => { + trusted = { projectId, hash }; + }} + />, + ); + assert.ok(m.el.querySelector("[data-automations-approve]")); + assert.match(m.el.textContent || "", /solenta\.json/); + await inAct(() => { + m.el.querySelector("[data-automations-approve-run]").click(); + }); + assert.deepEqual(trusted, { projectId: "p1", hash: "a".repeat(64) }); +}); + +it("after a project-scope create, shows the write-and-commit note", async () => { + const m = await mount( + {}} + onUpdate={async () => {}} + onRemove={async () => {}} + onRunNow={async () => {}} + />, + ); + // fill name/prompt, set scope project, submit + // assert data-automation-wrote="" +}); +``` + +`auto()` helper must set `scope: over.scope ?? "private"`. + +Untrusted enable/Run now: toggle disabled; Run now calls `onRunNow(id, hash)` (approve-and-run). Trusted Run now calls `onRunNow(id)`. + +- [ ] **Step 2: Run renderer test to verify fail** + +Run: `CODER_GROK_MCP_DISABLE=1 node --import=./test/support/render.mjs --experimental-strip-types --test --test-timeout=20000 test/automationsView.test.tsx` + +Expected: FAIL — no `data-automation-scope`. + +- [ ] **Step 3: Implement the view** + +- State: `scope: "private" | "project"`, default `"private"`. +- Select `data-automation-scope`. Disable Project when `projectById.get(projectId)` has `remoteHost` or no `path`. +- Pass `scope` on create. Do not send `scope` on update. +- After successful project-scope create/update-of-definition, set a `wroteFile` note: “Wrote solenta.json — commit it to share.” `data-automation-wrote=""`. +- Row: `Private|Project`. +- Toggle aria-label: project → “Run on this machine” / “Stop running on this machine”; private stays Enable/Disable. Disable the toggle when `project.automationsFile && !project.automationsFile.trusted`. +- Approve card per project in `projects` where `automationsFile && (automationsFile.error || !automationsFile.trusted)` and `count > 0` (or error). Copy from the spec. Buttons: `data-automations-approve-run` → `onTrustFile(projectId, hash)`; cancel hides the card for this session only (local React state). +- Run now on untrusted project row: `onRunNow(auto.id, project.automationsFile.hash)`. + +Wire `onTrustFile` from `App.tsx` / the automations screen props. After trust, refresh projects + automations. + +- [ ] **Step 4: Re-run renderer automations tests + typecheck** + +``` +CODER_GROK_MCP_DISABLE=1 node --import=./test/support/render.mjs --experimental-strip-types --test --test-timeout=20000 \ + test/automationsView.test.tsx test/automationsCreateWiring.test.tsx +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/AutomationsView.tsx src/components/AutomationsView.module.css src/App.tsx test/automationsView.test.tsx +git commit -m "$(cat <<'EOF' +Add private vs project scope to the Automations UI (#1590) + +EOF +)" +``` + +--- + +### Task 8: Final regression sweep + +**Files:** none new. Re-run the suites this plan touched. + +- [ ] **Step 1: Electron** + +``` +node --import=./test/support/render.mjs --experimental-strip-types --test \ + electron/test/automations-file.test.js \ + electron/test/automations.test.js \ + electron/test/automation-triggers.test.js \ + electron/test/store.test.js \ + electron/test/project-setup-h3.test.js +``` + +Expected: PASS. + +- [ ] **Step 2: Renderer + types** + +``` +CODER_GROK_MCP_DISABLE=1 node --import=./test/support/render.mjs --experimental-strip-types --test --test-timeout=20000 \ + test/automationsView.test.tsx test/automationsCreateWiring.test.tsx test/automations.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Commit only if the sweep forced a fix.** Otherwise stop. Do not file a drive-by cleanup commit. + +--- + +## Self-review (plan vs spec) + +| Spec section | Task | +|---|---| +| File shape, hash, MAX_BYTES, automations-only file | Task 1 | +| `automationsTrust` migrate | Task 2 | +| `scope`, merge, overlay defaults, orphan GC | Task 3 | +| Writes, auto-trust writer, remote reject, no clobber | Task 4 | +| Fire gates, trustFile, runNow hash, approve-and-run does not enable | Task 5 | +| IPC + clients | Task 6 | +| UI | Task 7 | +| Out of scope (webhooks, NL, convert, shared memory, auto-commit, SSH) | nowhere — do not add | + +No TBD. `projects:changed` from the spec is implemented as `api.projects.list()` refresh (no such push exists). diff --git a/docs/superpowers/specs/2026-10-11-automation-permission-scopes-design.md b/docs/superpowers/specs/2026-10-11-automation-permission-scopes-design.md new file mode 100644 index 000000000..9315bce9c --- /dev/null +++ b/docs/superpowers/specs/2026-10-11-automation-permission-scopes-design.md @@ -0,0 +1,326 @@ +# Automation permission scopes (private vs project-shared) + +Issue #1590, split from #242. Automations live in the local Electron store +today, so every automation is private to this machine. This spec adds a +`scope` field and a committed shared home, trust-gated like `solenta.json` +commands. + +## Decision (user, 2026-10-11) + +**Local opt-in runner.** The committed file is the definition. Auto-fire +only on machines that opt in. The creator opts in automatically. **Run now** +works on any machine that has trusted the current file hash. Runtime state +stays local. There is no named owner and no every-machine fire. + +Rejected: every trusted machine fires (N teammates = N agent runs per GitHub +event). Rejected: a single named owner in the file (offline owner = silence; +changing owner needs a commit). + +## What is actually true today + +- `AutomationInfo` (`src/shared/ipc.ts`) has no `scope`. Writes go through + `electron/services-settings.js` `addAutomation` / `updateAutomation` into + `store.getAutomations()`. +- The minute ticker (`electron/automations.js` `startScheduler`) and the + GitHub poller (`electron/automation-triggers.js` `pollTriggers`) both + iterate `store.getAutomations()` and fire when `enabled` is true. +- GitHub events are local REST polls, not webhooks. There is no coordinator. + A shared definition that auto-fired on every machine would duplicate runs. +- `solenta.json` (`electron/repoConfig.js`) is the committed project file. + Commands (`setup`, `quickActions`, `onSettle`) run only after the user + approves a sha256 stored as `project.repoConfigTrust`. Editing the file + re-prompts. +- `.solenta/` is gitignored agent scratch (`.gitignore`). A file there would + never reach teammates. Shared automations therefore live in `solenta.json`, + not `.solenta/automations`. + +## Design + +### 1. File shape + +`solenta.json` gains an optional `automations` array. Unknown keys stay +ignored, as today. Example: + +```json +{ + "setup": "npm ci", + "automations": [ + { + "id": "pr-triage", + "name": "PR triage", + "prompt": "Triage the new pull request.", + "provider": "claude", + "model": null, + "preset": "hourly", + "hour": null, + "trigger": { "type": "github", "event": "pr_opened" }, + "tier": null + } + ] +} +``` + +Definition fields only (never runtime): + +| Field | Rule | +|---|---| +| `id` | Required slug: `^[a-z][a-z0-9-]{0,47}$`. Unique inside the file. | +| `name` | Non-empty, trimmed. Same required-name rule as private automations. | +| `prompt` | Non-empty. Cap 4000 characters (same as automation memory). | +| `provider` | Known provider id. | +| `model` | String or `null`. | +| `preset` | `hourly` \| `daily` \| `weekly`. Ignored when `trigger` is set, but still required so the row round-trips the existing writer. | +| `hour` | `0–23` for daily/weekly; `null` for hourly. | +| `trigger` | Absent/`null` = schedule. Else the existing `AutomationTrigger` object. | +| `tier` | Absent/`null` or `auto` \| `fast` \| `balanced` \| `strong`. | + +Not in the file: `enabled`, `lastRunAt`, `nextRunAt`, `lastError`, +`consecutiveFailures`, `pendingRunThreadId`, `triggerCursor`, `memory`, +`projectId`. `projectId` is the project whose checkout contains the file. + +Limits: at most **16** automations. `solenta.json` `MAX_BYTES` rises from +64 KB to **256 KB** so 16 × 4000-character prompts fit. One bad automation +row fails the **automations section only** (empty list + `error` string). +Commands still parse. Same “never a partial list” rule as `quickActions`. + +Hash: one sha256 of the canonical array (file order) of those nine +fields per row. Independent of `commandsHash`. Approving `npm test` does +not approve a new agent prompt. + +A file that contains only `automations` (no `setup` / `quickActions` / +`onSettle`) is valid. Today `parseRepoConfig` returns `null` when no +commands exist; that early return must not hide an automations-only file. +Commands presentation stays null; `automationsFile` is still filled. + +### 2. Trust, stored on the project + +New persisted field on the project, sibling of `repoConfigTrust`: + +- `automationsTrust`: sha256 hex of the current automations section, or + absent. + +`normalizeTrustHash` already in `repoConfig.js` validates the shape. +`store-migrate.js` `migrateProject` drops a non-hex value the same way it +drops a bad `repoConfigTrust`. + +A project is automations-trusted when +`project.automationsTrust === automationsHash(file)`. Editing the array +invalidates every teammate. The writer’s machine auto-trusts the new hash +(they just authored it). + +`ProjectInfo` gains a derived, never-persisted block: + +```ts +automationsFile?: { + hash: string; + trusted: boolean; + error?: string; + count: number; +}; +``` + +Remote (`remoteHost`) and pathless projects: no file, no block. Same as +`repoConfigFor`. + +### 3. `scope` on `AutomationInfo` + +```ts +scope: "private" | "project"; +``` + +Missing `scope` migrates to `"private"`. `AutomationWrite` accepts optional +`scope` and, for project scope on create, optional `id` (the slug). If +omitted on create, the slug is derived from the name +(`pr-triage` from `"PR triage"`) and de-duplicated with `-2`, `-3`, … inside +that file. A derived slug that fails the regex becomes `auto`, then +`auto-2`, … + +Internal id for a shared row is `shared::`. Private rows +keep UUIDs. A private create whose id would start with `shared:` is rejected. + +`listAutomations` returns the merge: + +1. Every store row with `scope !== "project"` (private). +2. For each local project, parse `solenta.json` automations. For each slug, + overlay the store row `shared::` if present, else + defaults: `enabled: false`, empty runtime, empty memory, `triggerCursor: null`, + `nextRunAt` from `nextFire` at list time (not persisted until the user + enables or runs). +3. Definition fields always come from the file. Store copies of name/prompt/… + on a shared id are overwritten on list and are not a source of truth. +4. Store overlays whose slug is gone from the file never appear in the + list. Orphan GC runs on add/update/remove and on the scheduler tick + (not as a surprise save from `list`). + +`enabled` means **auto-fire on this machine** for both scopes. It is never +written to `solenta.json`. + +| | Private | Project, this machine created it | Project, teammate just pulled | +|---|---|---|---| +| `scope` | `private` | `project` | `project` | +| `enabled` default | `true` (today) | `true` | `false` | +| Trusted | n/a | yes (writer auto-trusts) | no, until they approve | + +### 4. Who may fire + +The ticker and the GitHub poller iterate the **merged** list, not raw +`store.getAutomations()`. + +A row may auto-fire only when: + +- `enabled === true`, and +- if `scope === "project"`, the project’s `automationsTrust` matches the + current file hash. + +Untrusted shared rows are listed and are readable. They never enter +`dueAutomations`. `pollTriggers` skips them. `runNow` on an untrusted shared +row rejects with a distinct error (`Automations file is not trusted`) unless +the caller passes `hash` equal to the current file hash (approve-and-run). +That persists `automationsTrust` like `runCommand` + `trustRepoConfig`, then +fires. It does not flip `enabled`; approve-and-run is one manual fire. + +Private `runNow` is unchanged. + +Circuit breaker (`enabled=false` after 3 failures) still writes the overlay +only. It does not edit `solenta.json`. Re-enable is the existing UI toggle. + +### 5. Writes + +`add` / `update` / `remove` with `scope: "project"`: + +- Reject when the project has `remoteHost` or no local `path`. +- Read `solenta.json` (create `{ automations: [] }` if missing). +- Refuse if the file exists but is not valid JSON (do not clobber). +- Patch only the `automations` key. Preserve `setup`, `quickActions`, + `onSettle`, and unknown keys. +- Write `JSON.stringify(doc, null, 2) + "\n"` to `/solenta.json`. +- Invalidate the `repoConfig.js` read cache. +- Persist `automationsTrust` = the new hash on this project. +- Persist/update the overlay row (`enabled` included). +- `add` with project scope sets `enabled: true` unless the write explicitly + passes `enabled: false`. +- `update` of definition fields on a shared row writes the file. `update` of + `enabled` / `memory` only touches the overlay. +- `remove` of a shared row deletes that slug from the file and drops the + overlay. Other slugs stay. + +The app does **not** commit. The Automations UI says the file was written +and still needs a commit to reach teammates. + +`scope` cannot change on `update`. Convert private ↔ project is out of +scope (a later “Share with project” can add it). Create as one or the other. + +### 6. UI (`AutomationsView`) + +- Create form: a **Scope** select, `Private (this machine)` / `Project + (solenta.json)`. Project is disabled when the chosen project is remote + or has no path. +- Saving a project automation shows a one-line note: “Wrote solenta.json — + commit it to share.” +- Each row badges `Private` or `Project`. +- The existing enable toggle on a project row is “run on this machine.” +- When `projects` include any `automationsFile` that is untrusted or has + `error`, the view shows one approve card per such project (not per row), + copy parallel to the `solenta.json` command card in `ThreadView`: + + > Run automations from this repo’s solenta.json? + > These come from a checked-in file, so anyone who can push to the repo + > can change them. You will be asked again if they change. + + Approve calls `automations.trustFile({ projectId, hash })`. Cancel leaves + them listed and disabled for auto-fire. +- Edit form for a project row can change definition fields (writes the + file) and memory / enabled (overlay). Project cannot be changed (already + true for private). +- Untrusted project rows: definition is visible; enable and Run now wait + on the approve card (Run now can be the approve-and-run action). + +### 7. IPC + +`AutomationInfo` and `AutomationWrite` gain `scope?: "private" | "project"`. +`AutomationWrite` on create may include `id` when `scope === "project"` +(the slug). Listed shared rows always have `scope: "project"` and the +`shared::` id. + +New method: + +```ts +automations.trustFile(input: { projectId: string; hash: string }): Promise +``` + +Rejects an unknown project, a hash that is not the current file hash, or a +project with no automations section. On success stores `automationsTrust` +and broadcasts `projects:changed`. + +`runNow` input becomes `{ id: string; hash?: string }`. `hash` is ignored +for private rows. For project rows it is the approve-and-run path above. + +`ProjectInfo.automationsFile` is filled in `listProjects` next to +`presentRepoConfig`. + +Dev coder (`src/dev/automations.ts`, `src/devCoder.ts`) implements the same +merge and trust so the web harness stays honest. + +### 8. Error handling + +| Case | Behavior | +|---|---| +| Missing `solenta.json` | No shared rows. Private list unchanged. | +| Invalid JSON | Commands error (today). Automations section error. No shared rows. No write. | +| Bad automations array | Commands still load. `automationsFile.error` set. No shared rows. | +| Duplicate / bad slug | Automations section error (no partial list). | +| Provider in file not installed | Row lists. Fire / runNow fails with the existing unknown-provider error. | +| File hash changed since overlay existed | `trusted: false`. Overlay `enabled` is kept but ignored until re-trust. | +| SSH / remote project | No file read, no project-scope write. | + +### 9. Out of scope + +- Inbound webhook relay / a cloud single-owner (#1588). +- Natural-language authoring (#1589). +- Converting an existing private automation into a shared one. +- Sharing `memory` / `triggerCursor` across machines. +- Auto-commit or a git prompt after write. +- SSH remote checkouts. + +## Testing + +Electron (`electron/test/`): + +- `repoConfig` parse: automations present, hash independent of commands, + one bad row fails only the automations section, 17th row errors, slug + / prompt-cap / duplicate-id errors, `MAX_BYTES` 256 KB. +- Merge: private + shared; overlay `enabled` / cursor / memory survive a + definition edit; orphan overlay GC; missing scope → private. +- Fire gates: untrusted shared never due and never polled; trusted + + `enabled: false` never fires; trusted + `enabled: true` does; private + unchanged. +- Writes: add project-scope creates/patches `solenta.json`, auto-trusts, + `enabled: true`; teammate overlay defaults `enabled: false`; update + memory does not rewrite definition; remove drops the slug; refuse write + on invalid JSON; remote project rejected. +- `trustFile`: matching hash persists; stale hash rejected. +- `runNow` untrusted rejects; approve-and-run trusts and fires. +- `migrateProject` drops a non-hex `automationsTrust`. + +Renderer (`test/automationsView.test.tsx` and create-wiring): + +- Scope select; project badge; approve card copy and action; enable label + on a project row; write-note after a project save. + +## Files + +- `electron/repoConfig.js` — parse automations, separate hash, raise + `MAX_BYTES`, present `automationsFile`. +- `electron/automations-file.js` (new, if repoConfig would grow past one + job) — read/write the automations key, slug helpers, overlay merge. +- `electron/services-settings.js` — `list` / `add` / `update` / `remove` / + `trustFile`. +- `electron/automations.js` + `electron/automation-triggers.js` — merged + list + trust gate on tick and poll. +- `electron/store-migrate.js` — `scope` default, `automationsTrust`. +- `src/shared/ipc.ts` — types + `trustFile`. +- `src/components/AutomationsView.tsx` — scope, badge, approve card. +- `src/dev/automations.ts` — same semantics for the harness. +- Tests listed above. + +No new dependency. No schema change to memory-server. diff --git a/electron/automation-triggers.js b/electron/automation-triggers.js index e4651d280..f0f6ea113 100644 --- a/electron/automation-triggers.js +++ b/electron/automation-triggers.js @@ -376,9 +376,10 @@ async function pollTriggers(ctx, firing) { /** @type {Map>} */ const cache = new Map(); const { patchAutomation } = require("./automations.js"); + const { mergeAutomations, canAutoFire } = require("./automations-file.js"); - for (const auto of ctx.store.getAutomations()) { - if (!auto || !auto.enabled || !auto.trigger || firing.has(auto.id)) continue; + for (const auto of mergeAutomations(ctx.store, Date.now())) { + if (!canAutoFire(ctx.store, auto) || !auto.trigger || firing.has(auto.id)) continue; // A run is still going: leave the cursor alone so its events batch into // the next run instead of piling up parallel ones. if (auto.pendingRunThreadId) continue; @@ -435,7 +436,7 @@ async function pollTriggers(ctx, firing) { firing.add(auto.id); try { await ctx.fireRun( - ctx.store.getAutomation(auto.id), + auto, formatTriggerNote(triggerNoteTitle(trigger, remote), fresh), ); } finally { diff --git a/electron/automations-file.js b/electron/automations-file.js new file mode 100644 index 000000000..02a3bfe66 --- /dev/null +++ b/electron/automations-file.js @@ -0,0 +1,479 @@ +"use strict"; + +/** + * Shared automations section of solenta.json (#1590). Parse, hash, present, + * merge file definitions with local overlays, and write project-scoped rows. + * Ticker/poller/trustFile changes land in a later task. + * + * The hash is independent of commandsHash / repoConfigTrust so approving + * a setup command does not approve an agent prompt. + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { createHash } = require("node:crypto"); +const { normalizeTrigger, parseTriggerInput } = require("./automation-triggers.js"); +const { normalizeRoute } = require("./routing.js"); +const { isKnownProviderId } = require("./services-notes.js"); +const { readRepoJson, invalidateRepoConfigCache } = require("./repoConfig.js"); + +const REPO_CONFIG_FILE = "solenta.json"; + +const SLUG_RE = /^[a-z][a-z0-9-]{0,47}$/; +const MAX_SHARED_AUTOMATIONS = 16; +const PROMPT_MAX = 4000; +const PRESETS = new Set(["hourly", "daily", "weekly"]); + +/** + * Overlay / merge id for a shared automation row. + * @param {string} projectId + * @param {string} slug + */ +function sharedId(projectId, slug) { + return `shared:${projectId}:${slug}`; +} + +/** + * Derive a unique slug from a display name. Invalid leftovers become `auto`. + * @param {string} name + * @param {string[]} taken + */ +function slugFromName(name, taken) { + const used = new Set(taken || []); + let base = String(name || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!SLUG_RE.test(base)) base = "auto"; + if (!used.has(base)) return base; + let n = 2; + while (used.has(`${base}-${n}`)) n += 1; + return `${base}-${n}`; +} + +/** + * Canonical nine fields in key order, for a stable sha256. + * @param {{ + * id: string, + * name: string, + * prompt: string, + * provider: string, + * model: string | null, + * preset: string, + * hour: number | null, + * trigger: object | null, + * tier: string | null, + * }} row + */ +function canonical(row) { + return { + id: row.id, + name: row.name, + prompt: row.prompt, + provider: row.provider, + model: row.model, + preset: row.preset, + hour: row.hour, + trigger: row.trigger, + tier: row.tier, + }; +} + +/** + * sha256 of the canonical automations array. Independent of commandsHash. + * @param {Array} rows + */ +function automationsHash(rows) { + return createHash("sha256") + .update(JSON.stringify((rows || []).map(canonical))) + .digest("hex"); +} + +/** + * @param {unknown} hour + * @param {"hourly" | "daily" | "weekly"} preset + * @returns {{ hour: number | null } | { error: string }} + */ +function parseHour(preset, hour) { + if (preset === "hourly") return { hour: null }; + if (hour == null || hour === "") { + return { error: "solenta.json: hour is required for daily and weekly automations" }; + } + const n = Number(hour); + if (!Number.isInteger(n) || n < 0 || n > 23) { + return { error: "solenta.json: hour must be an integer from 0 to 23" }; + } + return { hour: n }; +} + +/** + * @param {unknown} raw + * @param {number} index + * @param {Set} seen + * @returns {{ row: object } | { error: string }} + */ +function parseRow(raw, index, seen) { + const at = `automations[${index}]`; + const r = raw && typeof raw === "object" && !Array.isArray(raw) + ? /** @type {Record} */ (raw) + : {}; + + const id = typeof r.id === "string" ? r.id : ""; + if (!SLUG_RE.test(id)) { + return { error: `solenta.json: ${at}.id must be a slug matching ${SLUG_RE}` }; + } + if (seen.has(id)) { + return { error: `solenta.json: duplicate automation id "${id}"` }; + } + seen.add(id); + + const name = typeof r.name === "string" ? r.name.trim() : ""; + if (!name) { + return { error: `solenta.json: ${at}.name is required` }; + } + + const prompt = r.prompt != null ? String(r.prompt) : ""; + if (!prompt.trim()) { + return { error: `solenta.json: ${at}.prompt is required` }; + } + if (prompt.length > PROMPT_MAX) { + return { error: `solenta.json: ${at}.prompt must be at most ${PROMPT_MAX} characters` }; + } + + const provider = typeof r.provider === "string" ? r.provider : ""; + if (!provider || !isKnownProviderId(provider)) { + return { error: `solenta.json: ${at}.provider is unknown` }; + } + + let model = null; + if (r.model != null && r.model !== "") { + if (typeof r.model !== "string") { + return { error: `solenta.json: ${at}.model must be a string or null` }; + } + const trimmed = r.model.trim(); + if (!trimmed) { + return { error: `solenta.json: ${at}.model must be a string or null` }; + } + model = trimmed; + } + + const preset = typeof r.preset === "string" ? r.preset : ""; + if (!PRESETS.has(preset)) { + return { + error: `solenta.json: ${at}.preset must be hourly, daily, or weekly`, + }; + } + + const hourParsed = parseHour(preset, r.hour); + if ("error" in hourParsed) return hourParsed; + + let trigger = null; + if (r.trigger != null) { + try { + trigger = parseTriggerInput(r.trigger); + } catch (err) { + const msg = err && err.message ? String(err.message) : String(err); + return { error: `solenta.json: ${at}.trigger: ${msg}` }; + } + trigger = normalizeTrigger(trigger); + } + + let tier = null; + if (r.tier != null && r.tier !== "") { + tier = normalizeRoute(r.tier); + if (!tier) { + return { + error: `solenta.json: ${at}.tier must be auto, fast, balanced, or strong`, + }; + } + } + + return { + row: { + id, + name, + prompt, + provider, + model, + preset, + hour: hourParsed.hour, + trigger, + tier, + }, + }; +} + +/** + * Validate `data.automations`. One bad row fails the section only (empty + * list + error, no hash). Commands are not considered. + * + * @param {unknown} data + * @returns {{ automations: object[], hash: string | null, error?: string }} + */ +function parseAutomations(data) { + if (!data || typeof data !== "object" || Array.isArray(data)) { + return { automations: [], hash: null }; + } + const d = /** @type {Record} */ (data); + if (d.automations === undefined) { + return { automations: [], hash: null }; + } + if (!Array.isArray(d.automations)) { + return { error: "solenta.json: automations must be an array", automations: [] }; + } + if (d.automations.length > MAX_SHARED_AUTOMATIONS) { + return { + error: `solenta.json: at most ${MAX_SHARED_AUTOMATIONS} automations`, + automations: [], + }; + } + const seen = new Set(); + const rows = []; + for (const [i, raw] of d.automations.entries()) { + const parsed = parseRow(raw, i, seen); + if ("error" in parsed) return { error: parsed.error, automations: [] }; + rows.push(parsed.row); + } + return { automations: rows, hash: automationsHash(rows) }; +} + +/** + * Read `/solenta.json` automations via the shared mtime cache. + * Missing file → empty, no hash. Invalid JSON → error, no hash. + * + * @param {string | null | undefined} root + */ +function readAutomations(root) { + const raw = readRepoJson(root); + if (!raw) return { automations: [], hash: null }; + if (raw.error) return { error: raw.error, automations: [] }; + return parseAutomations(raw.json); +} + +/** + * ProjectInfo.automationsFile for the renderer. Never persisted. + * Null when there is no local file section (remote, pathless, or no key). + * + * @param {any} project + */ +function presentAutomationsFile(project) { + if (!project || project.remoteHost || !project.path) return null; + const section = readAutomations(project.path); + if (!section.hash && !section.error) return null; + return { + hash: section.hash, + trusted: Boolean(section.hash && project.automationsTrust === section.hash), + ...(section.error ? { error: section.error } : {}), + count: section.automations.length, + }; +} + +/** + * Local project: on-disk path, not a remote host. + * @param {any} project + */ +function isLocalProject(project) { + return Boolean(project && project.path && !project.remoteHost); +} + +/** + * Merge private store rows with file definitions + local overlays. + * Definition fields always come from the file. Does not save. + * + * @param {import("./store").Store} store + * @param {number} nowMs + * @returns {object[]} + */ +function mergeAutomations(store, nowMs) { + const { nextFire } = require("./automations.js"); + const privates = (store.getAutomations() || []).filter( + (a) => a && a.scope !== "project", + ); + const overlayById = new Map(); + for (const a of store.getAutomations() || []) { + if (a && a.id) overlayById.set(a.id, a); + } + const shared = []; + for (const project of store.getProjects() || []) { + if (!isLocalProject(project)) continue; + const section = readAutomations(project.path); + if (section.error || !section.automations.length) continue; + for (const row of section.automations) { + const id = sharedId(project.id, row.id); + const overlay = overlayById.get(id); + const runtime = overlay + ? { + enabled: overlay.enabled, + memory: overlay.memory, + triggerCursor: overlay.triggerCursor, + lastRunAt: overlay.lastRunAt, + lastError: overlay.lastError, + consecutiveFailures: overlay.consecutiveFailures, + pendingRunThreadId: overlay.pendingRunThreadId, + nextRunAt: overlay.nextRunAt, + } + : { + enabled: false, + memory: "", + triggerCursor: null, + lastRunAt: null, + lastError: null, + consecutiveFailures: 0, + pendingRunThreadId: null, + nextRunAt: nextFire(row.preset, row.hour, nowMs), + }; + shared.push({ + ...runtime, + id, + projectId: project.id, + name: row.name, + prompt: row.prompt, + provider: row.provider, + model: row.model, + preset: row.preset, + hour: row.hour, + trigger: row.trigger, + tier: row.tier, + scope: "project", + }); + } + } + return [...privates, ...shared]; +} + +/** + * Auto-fire gate: enabled, and trusted when the row is project-scoped. + * Private enabled rows return true. + * + * @param {import("./store").Store} store + * @param {object} auto + * @returns {boolean} + */ +function canAutoFire(store, auto) { + if (!auto || !auto.enabled) return false; + if (auto.scope !== "project") return true; + const project = store.getProject(auto.projectId); + return Boolean(presentAutomationsFile(project)?.trusted); +} + +/** + * Drop store overlays whose slug is no longer in any local file. + * A parse-error file keeps that project's existing overlays (invalid + * JSON is not treated as an empty list). Mutates the store list; does + * not save. + * + * @param {import("./store").Store} store + * @returns {boolean} true if the list changed + */ +function gcOrphanOverlays(store) { + const list = store.getAutomations() || []; + const valid = new Set(); + for (const project of store.getProjects() || []) { + if (!isLocalProject(project)) continue; + const section = readAutomations(project.path); + if (section.error) { + for (const a of list) { + if (a && a.scope === "project" && a.projectId === project.id) { + valid.add(a.id); + } + } + continue; + } + for (const row of section.automations) { + valid.add(sharedId(project.id, row.id)); + } + } + const next = list.filter((a) => !a || a.scope !== "project" || valid.has(a.id)); + if (next.length === list.length) return false; + store.setAutomations(next); + return true; +} + +/** + * Replace `automations` in `/solenta.json`. Missing file → + * `{}`. Existing invalid JSON → throw. Empty `rows` writes `automations: []` + * (does not delete the key). Unknown sibling keys are preserved. + * + * @param {string} projectPath + * @param {object[]} rows + * @returns {{ automations: object[], hash: string | null, error?: string }} + */ +function writeAutomations(projectPath, rows) { + const file = path.join(projectPath, REPO_CONFIG_FILE); + let doc = {}; + const raw = readRepoJson(projectPath); + if (raw) { + if (raw.error) throw new Error(raw.error); + if (raw.json && typeof raw.json === "object" && !Array.isArray(raw.json)) { + doc = { ...raw.json }; + } + } + doc.automations = (rows || []).map((row) => canonical(row)); + const parsed = parseAutomations(doc); + if (parsed.error) throw new Error(parsed.error); + fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`); + invalidateRepoConfigCache(projectPath); + return parsed; +} + +/** + * Ensure a store overlay exists for a merged project-scoped row. + * Does not save. + * + * @param {import("./store").Store} store + * @param {object} auto + * @returns {object} + */ +function ensureOverlay(store, auto) { + if (!auto || !auto.id) { + throw new Error("Automation id is required"); + } + const existing = store.getAutomation(auto.id); + if (existing) return existing; + const { nextFire } = require("./automations.js"); + const overlay = { + id: auto.id, + projectId: auto.projectId, + scope: "project", + name: auto.name, + prompt: auto.prompt, + provider: auto.provider, + model: auto.model, + preset: auto.preset, + hour: auto.hour, + trigger: auto.trigger || null, + tier: auto.tier || null, + enabled: auto.enabled, + memory: auto.memory || "", + triggerCursor: auto.triggerCursor ?? null, + lastRunAt: auto.lastRunAt ?? null, + lastError: auto.lastError ?? null, + consecutiveFailures: auto.consecutiveFailures || 0, + pendingRunThreadId: auto.pendingRunThreadId ?? null, + nextRunAt: + typeof auto.nextRunAt === "number" && Number.isFinite(auto.nextRunAt) + ? auto.nextRunAt + : nextFire(auto.preset, auto.hour, Date.now()), + }; + const list = (store.getAutomations() || []).slice(); + list.push(overlay); + store.setAutomations(list); + return store.getAutomation(auto.id); +} + +module.exports = { + SLUG_RE, + MAX_SHARED_AUTOMATIONS, + PROMPT_MAX, + sharedId, + slugFromName, + parseAutomations, + automationsHash, + readAutomations, + writeAutomations, + presentAutomationsFile, + mergeAutomations, + gcOrphanOverlays, + canAutoFire, + ensureOverlay, +}; diff --git a/electron/automations.js b/electron/automations.js index 3bf459b1e..249c40116 100644 --- a/electron/automations.js +++ b/electron/automations.js @@ -338,7 +338,8 @@ async function runAutomation(ctx, auto, now, opts) { */ function listAutomationRuns(store, automationId) { const id = automationId != null ? String(automationId) : ""; - const auto = store.getAutomation(id); + const { mergeAutomations } = require("./automations-file.js"); + const auto = mergeAutomations(store, Date.now()).find((a) => a.id === id); if (!auto) { throw new Error(`Unknown automation: ${id}`); } @@ -370,17 +371,36 @@ function listAutomationRuns(store, automationId) { /** * Fire one automation immediately, even if nextRunAt is in the future. + * Accepts a string id (existing tests) or `{ id, hash? }` from IPC. + * Manual run is allowed while enabled is false; shared rows must be trusted + * or supply a matching hash (approve-and-run does not flip enabled). * * @param {{ store: import("./store").Store, runner: { startRun: Function }, broadcast?: Function }} ctx - * @param {string} id + * @param {string | { id: string, hash?: string }} input */ -async function runNow(ctx, id) { - const auto = ctx.store.getAutomation(id); - if (!auto) { - throw new Error(`Unknown automation: ${id}`); +async function runNow(ctx, input) { + const id = typeof input === "string" ? input : String((input && input.id) || ""); + const hash = typeof input === "object" && input ? input.hash : undefined; + const { + mergeAutomations, + presentAutomationsFile, + ensureOverlay, + } = require("./automations-file.js"); + const auto = mergeAutomations(ctx.store, Date.now()).find((a) => a.id === id); + if (!auto) throw new Error(`Unknown automation: ${id}`); + if (auto.scope === "project") { + const project = ctx.store.getProject(auto.projectId); + const file = presentAutomationsFile(project); + if (!file || !file.trusted) { + if (!hash || hash !== file?.hash) { + throw new Error("Automations file is not trusted"); + } + require("./services.js").trustFile(ctx.store, { projectId: auto.projectId, hash }); + } } + ensureOverlay(ctx.store, auto); await runAutomation(ctx, auto, Date.now(), { rethrow: true }); - return ctx.store.getAutomation(id); + return mergeAutomations(ctx.store, Date.now()).find((a) => a.id === id); } /** @@ -407,7 +427,14 @@ function startScheduler(ctx) { } catch { // Never let bookkeeping stall the scheduler. } - const due = dueAutomations(ctx.store.getAutomations(), now); + const { + mergeAutomations, + gcOrphanOverlays, + canAutoFire, + } = require("./automations-file.js"); + if (gcOrphanOverlays(ctx.store)) ctx.store.save(); + const due = dueAutomations(mergeAutomations(ctx.store, now), now) + .filter((a) => canAutoFire(ctx.store, a)); for (const auto of due) { if (!auto || !auto.id || firing.has(auto.id)) continue; firing.add(auto.id); diff --git a/electron/ipc-runs.js b/electron/ipc-runs.js index f94503a01..46e016e07 100644 --- a/electron/ipc-runs.js +++ b/electron/ipc-runs.js @@ -36,9 +36,11 @@ module.exports = { "automations:remove": async (ctx, input) => { services.removeAutomation(ctx.store, input); }, + "automations:trustFile": async (ctx, input) => { + services.trustFile(ctx.store, input); + }, "automations:runNow": async (ctx, input) => { - const id = input && input.id != null ? String(input.id) : ""; - return automations.runNow(ctx, id); + return automations.runNow(ctx, input); }, "automations:listRuns": async (ctx, input) => { const id = input && input.id != null ? String(input.id) : ""; diff --git a/electron/preload.js b/electron/preload.js index 237073691..89585c5ed 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -156,6 +156,7 @@ const IPC_CHANNELS = Object.freeze([ { ns: "automations", method: "update" }, { ns: "automations", method: "remove" }, { ns: "automations", method: "runNow" }, + { ns: "automations", method: "trustFile" }, { ns: "automations", method: "listRuns" }, { ns: "projects", method: "list" }, { ns: "projects", method: "add" }, diff --git a/electron/projectIcon.js b/electron/projectIcon.js index 51ac81df5..f3229da5d 100644 --- a/electron/projectIcon.js +++ b/electron/projectIcon.js @@ -507,6 +507,8 @@ function presentProject(project) { // #1506: checked-in solenta.json commands, derived at list time. const repoConfig = require("./repoConfig.js").presentRepoConfig(next); if (repoConfig) next = { ...next, repoConfig }; + const automationsFile = require("./automations-file.js").presentAutomationsFile(next); + if (automationsFile) next = { ...next, automationsFile }; return attachScm(next); } diff --git a/electron/repoConfig.js b/electron/repoConfig.js index 7f1f7c3e8..da1c7f75f 100644 --- a/electron/repoConfig.js +++ b/electron/repoConfig.js @@ -26,11 +26,11 @@ const { normalizeCommand } = require("./verify.js"); const REPO_CONFIG_FILE = "solenta.json"; const QUICK_ACTION_MAX = 8; const ACTION_NAME_MAX = 32; -const MAX_BYTES = 64 * 1024; +const MAX_BYTES = 256 * 1024; /** Stable ids so a header button keeps its identity across reads. */ const REPO_ACTION_ID_PREFIX = "repo:"; -/** @type {Map} */ +/** @type {Map} */ const cache = new Map(); /** @@ -114,11 +114,14 @@ function commandsHash(cfg) { } /** - * Read `/solenta.json`, cached on mtime+size. Missing file → null. + * Read `/solenta.json` as JSON, cached on mtime+size. Shared with + * automations-file.js so commands and automations see one cache. + * Missing file → null. Oversize / invalid JSON → `{ error }`. + * * @param {string | null | undefined} root - * @returns {RepoConfig | null} + * @returns {{ json?: unknown, error?: string } | null} */ -function readRepoConfig(root) { +function readRepoJson(root) { if (!root || typeof root !== "string") return null; const abs = path.join(root, REPO_CONFIG_FILE); let st; @@ -131,20 +134,45 @@ function readRepoConfig(root) { if (!st.isFile()) return null; const key = `${st.mtimeMs}:${st.size}`; const hit = cache.get(abs); - if (hit && hit.key === key) return hit.value; - /** @type {RepoConfig | null} */ - let value; + if (hit && hit.key === key) return hit; + /** @type {{ key: string, json?: unknown, error?: string }} */ + let entry; if (st.size > MAX_BYTES) { - value = { error: "solenta.json is larger than 64 KB" }; + entry = { key, error: "solenta.json is larger than 256 KB" }; } else { try { - value = parseRepoConfig(JSON.parse(fs.readFileSync(abs, "utf8"))); + entry = { key, json: JSON.parse(fs.readFileSync(abs, "utf8")) }; } catch { - value = { error: "solenta.json is not valid JSON" }; + entry = { key, error: "solenta.json is not valid JSON" }; } } - cache.set(abs, { key, value }); - return value; + cache.set(abs, entry); + return entry; +} + +/** + * Drop the cached parse for `/solenta.json` (or the file itself). + * @param {string | null | undefined} absFileOrRoot + */ +function invalidateRepoConfigCache(absFileOrRoot) { + if (!absFileOrRoot || typeof absFileOrRoot !== "string") return; + const abs = + path.basename(absFileOrRoot) === REPO_CONFIG_FILE + ? absFileOrRoot + : path.join(absFileOrRoot, REPO_CONFIG_FILE); + cache.delete(abs); +} + +/** + * Read `/solenta.json`, cached on mtime+size. Missing file → null. + * @param {string | null | undefined} root + * @returns {RepoConfig | null} + */ +function readRepoConfig(root) { + const raw = readRepoJson(root); + if (!raw) return null; + if (raw.error) return { error: raw.error }; + return parseRepoConfig(raw.json); } /** @@ -224,7 +252,9 @@ module.exports = { REPO_CONFIG_FILE, REPO_ACTION_ID_PREFIX, parseRepoConfig, + readRepoJson, readRepoConfig, + invalidateRepoConfigCache, effectiveCommands, presentRepoConfig, normalizeTrustHash, diff --git a/electron/services-settings.js b/electron/services-settings.js index b2f84088f..ed5b48ef2 100644 --- a/electron/services-settings.js +++ b/electron/services-settings.js @@ -202,6 +202,7 @@ function setSettings(store, patch, opts) { } const AUTOMATION_PRESETS = new Set(["hourly", "daily", "weekly"]); +const AUTOMATION_SCOPES = new Set(["private", "project"]); /** * @param {unknown} hour @@ -306,6 +307,26 @@ function normalizeAutomationInput(store, input, existing) { : base.memory; const memory = memoryRaw != null ? String(memoryRaw).slice(0, MAX_MEMORY_CHARS) : ""; + let scope; + if (existing) { + scope = existing.scope === "project" ? "project" : "private"; + if ( + Object.prototype.hasOwnProperty.call(src, "scope") && + src.scope != null && + src.scope !== "" && + String(src.scope) !== scope + ) { + throw new Error("Automation scope cannot be changed"); + } + } else if (Object.prototype.hasOwnProperty.call(src, "scope") && src.scope != null && src.scope !== "") { + scope = String(src.scope); + if (!AUTOMATION_SCOPES.has(scope)) { + throw new Error(`Invalid scope: ${scope}. Expected private or project`); + } + } else { + scope = "private"; + } + return { name, projectId, @@ -318,6 +339,7 @@ function normalizeAutomationInput(store, input, existing) { trigger, memory, tier, + scope, }; } @@ -325,7 +347,122 @@ function normalizeAutomationInput(store, input, existing) { * @param {import('./store').Store} store */ function listAutomations(store) { - return store.getAutomations().map((a) => ({ ...a })); + const { mergeAutomations } = require("./automations-file.js"); + return mergeAutomations(store, Date.now()).map((a) => ({ ...a })); +} + +/** + * @param {import('./store').Store} store + * @param {string} id + */ +function findAutomation(store, id) { + const listed = listAutomations(store).find((a) => a && a.id === id); + return listed || null; +} + +/** + * @param {any} project + */ +function assertLocalWritable(project) { + if (project && project.remoteHost) { + throw new Error("Cannot write project automations on a remote project"); + } + if (!project || !project.path) { + throw new Error("Cannot write project automations without a local path"); + } +} + +/** + * Trust the project's automations file when `hash` matches the current + * solenta.json automations section. Saves. + * + * @param {import('./store').Store} store + * @param {{ projectId: string, hash: string }} input + */ +function trustFile(store, input) { + const projectId = String((input && input.projectId) || ""); + const hash = input && input.hash; + const project = store.getProject(projectId); + if (!project) throw new Error(`Unknown project: ${projectId}`); + const { readAutomations } = require("./automations-file.js"); + const section = readAutomations(project.path); + if (!section.hash) throw new Error("No automations in solenta.json"); + if (hash !== section.hash) throw new Error("Automations file hash does not match"); + persistAutomationsTrust(store, projectId, hash); + store.save(); +} + +/** + * Persist automationsTrust via setProjects (same path as repoConfigTrust). + * Does not save. + * + * @param {import('./store').Store} store + * @param {string} projectId + * @param {string | null} hash + */ +function persistAutomationsTrust(store, projectId, hash) { + const projects = store.getProjects().slice(); + const idx = projects.findIndex((p) => p.id === projectId); + if (idx === -1) throw new Error(`Unknown project: ${projectId}`); + const next = { ...projects[idx] }; + if (hash) next.automationsTrust = hash; + else delete next.automationsTrust; + projects[idx] = next; + store.setProjects(projects); +} + +/** + * @param {object} auto + * @returns {string} + */ +function sharedSlug(auto) { + const prefix = `shared:${auto.projectId}:`; + if (typeof auto.id === "string" && auto.id.startsWith(prefix)) { + return auto.id.slice(prefix.length); + } + return String(auto.id || ""); +} + +const DEFINITION_KEYS = [ + "name", + "prompt", + "provider", + "model", + "preset", + "hour", + "trigger", + "tier", +]; + +/** + * @param {object} fields + * @param {object} existing + */ +function definitionChanged(fields, existing) { + return DEFINITION_KEYS.some((key) => { + if (key === "trigger") { + return JSON.stringify(fields.trigger) !== JSON.stringify(existing.trigger || null); + } + return fields[key] !== existing[key]; + }); +} + +/** + * @param {object} fields + * @param {string} slug + */ +function definitionRow(fields, slug) { + return { + id: slug, + name: fields.name, + prompt: fields.prompt, + provider: fields.provider, + model: fields.model, + preset: fields.preset, + hour: fields.hour, + trigger: fields.trigger || null, + tier: fields.tier || null, + }; } /** @@ -334,7 +471,73 @@ function listAutomations(store) { */ function addAutomation(store, input) { const { nextFire } = require("./automations.js"); + const { + writeAutomations, + readAutomations, + sharedId, + slugFromName, + SLUG_RE, + gcOrphanOverlays, + } = require("./automations-file.js"); const fields = normalizeAutomationInput(store, input, null); + const src = input && typeof input === "object" ? input : {}; + + if (fields.scope === "project") { + const project = store.getProject(fields.projectId); + assertLocalWritable(project); + if (src.id != null && src.id !== "" && !SLUG_RE.test(String(src.id))) { + throw new Error(`Automation id must be a slug matching ${SLUG_RE}`); + } + const section = readAutomations(project.path); + if (section.error) throw new Error(section.error); + const existingIds = section.automations.map((row) => row.id); + const slug = + src.id != null && src.id !== "" + ? String(src.id) + : slugFromName(fields.name, existingIds); + if (existingIds.includes(slug)) { + throw new Error(`Duplicate automation id "${slug}"`); + } + const written = writeAutomations(project.path, [ + ...section.automations, + definitionRow(fields, slug), + ]); + persistAutomationsTrust(store, project.id, written.hash); + const id = sharedId(project.id, slug); + const now = Date.now(); + const overlay = { + id, + projectId: project.id, + scope: "project", + name: fields.name, + prompt: fields.prompt, + provider: fields.provider, + model: fields.model, + preset: fields.preset, + hour: fields.hour, + trigger: fields.trigger || null, + tier: fields.tier || null, + enabled: src.enabled !== false, + memory: fields.memory, + lastRunAt: null, + nextRunAt: nextFire(fields.preset, fields.hour, now), + lastError: null, + consecutiveFailures: 0, + pendingRunThreadId: null, + triggerCursor: null, + }; + const list = store.getAutomations().slice(); + list.push(overlay); + store.setAutomations(list); + gcOrphanOverlays(store); + store.save(); + return findAutomation(store, id) || { ...overlay }; + } + + if (src.id != null && String(src.id).startsWith("shared:")) { + throw new Error("Private automation id cannot start with shared:"); + } + const now = Date.now(); const created = { id: randomUUID(), @@ -349,6 +552,7 @@ function addAutomation(store, input) { const list = store.getAutomations().slice(); list.push(created); store.setAutomations(list); + gcOrphanOverlays(store); store.save(); return { ...created }; } @@ -359,8 +563,14 @@ function addAutomation(store, input) { */ function updateAutomation(store, input) { const { nextFire } = require("./automations.js"); + const { + writeAutomations, + readAutomations, + ensureOverlay, + gcOrphanOverlays, + } = require("./automations-file.js"); const id = input && input.id != null ? String(input.id) : ""; - const existing = store.getAutomation(id); + const existing = findAutomation(store, id); if (!existing) { throw new Error(`Unknown automation: ${id}`); } @@ -373,20 +583,39 @@ function updateAutomation(store, input) { // so it never fires on history. const triggerChanged = JSON.stringify(fields.trigger) !== JSON.stringify(existing.trigger || null); + + if (existing.scope === "project") { + const project = store.getProject(existing.projectId); + assertLocalWritable(project); + if (definitionChanged(fields, existing)) { + const section = readAutomations(project.path); + if (section.error) throw new Error(section.error); + const slug = sharedSlug(existing); + const rows = section.automations.map((row) => + row.id === slug ? definitionRow(fields, slug) : row, + ); + const written = writeAutomations(project.path, rows); + persistAutomationsTrust(store, project.id, written.hash); + } + ensureOverlay(store, existing); + } + + const base = store.getAutomation(id) || existing; const updated = { - ...existing, + ...base, ...fields, ...(reEnabled ? { consecutiveFailures: 0, lastError: null } : {}), ...(triggerChanged ? { triggerCursor: null } : {}), nextRunAt: scheduleChanged ? nextFire(fields.preset, fields.hour, Date.now()) - : existing.nextRunAt, + : base.nextRunAt, }; store.setAutomations( store.getAutomations().map((a) => (a && a.id === id ? updated : a)), ); + gcOrphanOverlays(store); store.save(); - return { ...updated }; + return findAutomation(store, id) || { ...updated }; } /** @@ -394,15 +623,31 @@ function updateAutomation(store, input) { * @param {{ id: string }} input */ function removeAutomation(store, input) { + const { writeAutomations, readAutomations, gcOrphanOverlays } = require( + "./automations-file.js", + ); const id = input && input.id != null ? String(input.id) : ""; if (!id) { throw new Error("Automation id is required"); } - const existing = store.getAutomation(id); + const existing = findAutomation(store, id); if (!existing) { throw new Error(`Unknown automation: ${id}`); } + if (existing.scope === "project") { + const project = store.getProject(existing.projectId); + assertLocalWritable(project); + const section = readAutomations(project.path); + if (section.error) throw new Error(section.error); + const slug = sharedSlug(existing); + const written = writeAutomations( + project.path, + section.automations.filter((row) => row.id !== slug), + ); + persistAutomationsTrust(store, project.id, written.hash); + } store.setAutomations(store.getAutomations().filter((a) => !a || a.id !== id)); + gcOrphanOverlays(store); store.save(); } @@ -523,5 +768,6 @@ module.exports = { addAutomation, updateAutomation, removeAutomation, + trustFile, appStatus, }; diff --git a/electron/services.js b/electron/services.js index 9b4977886..e7cef5b66 100644 --- a/electron/services.js +++ b/electron/services.js @@ -192,6 +192,7 @@ const { addAutomation, updateAutomation, removeAutomation, + trustFile, appStatus, } = require("./services-settings.js"); @@ -370,6 +371,7 @@ module.exports = { addAutomation, updateAutomation, removeAutomation, + trustFile, appStatus, assertUnderDailyBudget, assertUnderOrchestrationBudget, diff --git a/electron/store-migrate.js b/electron/store-migrate.js index 1ecd78483..5bc81ddbd 100644 --- a/electron/store-migrate.js +++ b/electron/store-migrate.js @@ -186,6 +186,7 @@ function migrateAutomation(a) { ? a.triggerCursor : null, memory: typeof a.memory === "string" ? a.memory : "", + scope: a.scope === "project" ? "project" : "private", }; } @@ -254,6 +255,9 @@ function migrateProject(p) { const trust = normalizeTrustHash(next.repoConfigTrust); if (trust) next.repoConfigTrust = trust; else delete next.repoConfigTrust; + const autoTrust = normalizeTrustHash(next.automationsTrust); + if (autoTrust) next.automationsTrust = autoTrust; + else delete next.automationsTrust; delete next.repoConfig; return next; } diff --git a/electron/test/automation-triggers.test.js b/electron/test/automation-triggers.test.js index 5c1747a4c..d100b7952 100644 --- a/electron/test/automation-triggers.test.js +++ b/electron/test/automation-triggers.test.js @@ -413,6 +413,112 @@ describe("event-triggered automations", () => { sched.stop(); } }); + + it("does not fire an enabled untrusted shared trigger", async () => { + const row = { + id: "pr-triage", + name: "PR triage", + prompt: "review new PRs", + provider: "claude", + model: null, + preset: "hourly", + hour: null, + trigger: { type: "github", event: "pr_opened" }, + }; + fs.writeFileSync( + path.join(tmpDir, "solenta.json"), + JSON.stringify({ automations: [row] }, null, 2), + ); + store.setAutomations([ + { + id: "shared:p1:pr-triage", + projectId: "p1", + scope: "project", + name: row.name, + prompt: row.prompt, + provider: "claude", + preset: "hourly", + hour: null, + trigger: { event: "pr_opened" }, + enabled: true, + triggerCursor: 10, + lastRunAt: null, + nextRunAt: Date.now() + 99 * 86_400_000, + lastError: null, + }, + ]); + rows = [ + { number: 12, title: "second", user: { login: "y" } }, + { number: 10, title: "old", user: { login: "x" } }, + ]; + const started = []; + const sched = scheduler(started); + try { + await sched.tick(); + assert.equal(started.length, 0); + } finally { + sched.stop(); + } + }); + + it("fires a trusted shared trigger with the merged file prompt, not the overlay", async () => { + const { + presentAutomationsFile, + automationsHash, + readAutomations, + } = require("../automations-file.js"); + const row = { + id: "pr-triage", + name: "PR triage", + prompt: "From file", + provider: "claude", + model: null, + preset: "hourly", + hour: null, + trigger: { type: "github", event: "pr_opened" }, + }; + fs.writeFileSync( + path.join(tmpDir, "solenta.json"), + JSON.stringify({ automations: [row] }, null, 2), + ); + store.setAutomations([ + { + id: "shared:p1:pr-triage", + projectId: "p1", + scope: "project", + name: row.name, + prompt: "Stale", + provider: "claude", + preset: "hourly", + hour: null, + trigger: { event: "pr_opened" }, + enabled: true, + triggerCursor: 10, + lastRunAt: null, + nextRunAt: Date.now() + 99 * 86_400_000, + lastError: null, + }, + ]); + const section = readAutomations(tmpDir); + const hash = automationsHash(section.automations); + const project = store.getProject("p1"); + store.setProjects([{ ...project, automationsTrust: hash }]); + assert.equal(presentAutomationsFile(store.getProject("p1")).trusted, true); + rows = [ + { number: 12, title: "second", user: { login: "y" } }, + { number: 10, title: "old", user: { login: "x" } }, + ]; + const started = []; + const sched = scheduler(started); + try { + await sched.tick(); + assert.equal(started.length, 1); + assert.match(started[0].prompt, /^From file/); + assert.equal(store.getAutomation("shared:p1:pr-triage").prompt, "Stale"); + } finally { + sched.stop(); + } + }); }); describe("Linear and Sentry pollTriggers (#1588)", () => { diff --git a/electron/test/automations-file.test.js b/electron/test/automations-file.test.js new file mode 100644 index 000000000..81204cef2 --- /dev/null +++ b/electron/test/automations-file.test.js @@ -0,0 +1,467 @@ +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { + parseAutomations, + automationsHash, + readAutomations, + presentAutomationsFile, + slugFromName, + sharedId, + MAX_SHARED_AUTOMATIONS, + mergeAutomations, + gcOrphanOverlays, + canAutoFire, +} = require("../automations-file.js"); +const { parseRepoConfig } = require("../repoConfig.js"); +const { Store } = require("../store.js"); +const services = require("../services.js"); +const { dueAutomations } = require("../automations.js"); + +function tempRepo(automations) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-auto-")); + fs.writeFileSync( + path.join(dir, "solenta.json"), + JSON.stringify({ automations }, null, 2), + ); + return dir; +} + +const ROW = { + id: "pr-triage", + name: "PR triage", + prompt: "Triage the new pull request.", + provider: "claude", + model: null, + preset: "hourly", + hour: null, + trigger: { type: "github", event: "pr_opened" }, + tier: null, +}; + +describe("parseAutomations", () => { + it("accepts a valid row and hashes independently of commands", () => { + const section = parseAutomations({ setup: "npm ci", automations: [ROW] }); + assert.equal(section.error, undefined); + assert.equal(section.automations.length, 1); + assert.equal(section.automations[0].id, "pr-triage"); + assert.match(section.hash, /^[0-9a-f]{64}$/); + const commands = parseRepoConfig({ setup: "npm ci", automations: [ROW] }); + assert.notEqual(section.hash, commands.hash); + const tweaked = parseAutomations({ + setup: "npm ci", + automations: [{ ...ROW, prompt: "Different" }], + }); + assert.notEqual(section.hash, tweaked.hash); + const sameAuto = parseAutomations({ + setup: "evil", + automations: [ROW], + }); + assert.equal(section.hash, sameAuto.hash); + }); + + it("one bad row fails only the automations section", () => { + const data = { + setup: "npm ci", + automations: [{ ...ROW, id: "BAD" }], + }; + const section = parseAutomations(data); + assert.match(section.error, /id/); + assert.deepEqual(section.automations, []); + const commands = parseRepoConfig(data); + assert.equal(commands.setup, "npm ci"); + assert.equal(commands.error, undefined); + }); + + it("rejects a 17th row, a duplicate id, and a too-long prompt", () => { + const many = Array.from({ length: MAX_SHARED_AUTOMATIONS + 1 }, (_, i) => ({ + ...ROW, + id: `job-${i}`, + })); + assert.match(parseAutomations({ automations: many }).error, /16/); + assert.match( + parseAutomations({ automations: [ROW, { ...ROW, name: "Other" }] }).error, + /duplicate/i, + ); + assert.match( + parseAutomations({ automations: [{ ...ROW, prompt: "x".repeat(4001) }] }) + .error, + /4000/, + ); + }); + + it("an automations-only file still presents", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-auto-")); + fs.writeFileSync( + path.join(dir, "solenta.json"), + JSON.stringify({ automations: [ROW] }), + ); + assert.equal(parseRepoConfig({ automations: [ROW] }), null); + const section = readAutomations(dir); + assert.equal(section.automations[0].id, "pr-triage"); + const presented = presentAutomationsFile({ path: dir }); + assert.equal(presented.count, 1); + assert.equal(presented.trusted, false); + assert.equal(presentAutomationsFile({ remoteHost: "h", path: dir }), null); + }); +}); + +describe("slugFromName", () => { + it("derives pr-triage and falls back to auto", () => { + assert.equal(slugFromName("PR triage", []), "pr-triage"); + assert.equal(slugFromName("PR triage", ["pr-triage"]), "pr-triage-2"); + assert.equal(slugFromName("!!!", []), "auto"); + assert.equal(slugFromName("!!!", ["auto"]), "auto-2"); + }); +}); + +describe("sharedId", () => { + it("is shared:project:slug", () => { + assert.equal(sharedId("p1", "pr-triage"), "shared:p1:pr-triage"); + }); +}); + +describe("mergeAutomations", () => { + it("lists private store rows plus file rows with overlay defaults", () => { + const dir = tempRepo([ROW]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + store.setAutomations([ + { + id: "uuid-1", + projectId: "p1", + name: "Local", + prompt: "x", + provider: "claude", + preset: "hourly", + hour: null, + enabled: true, + scope: "private", + lastRunAt: null, + nextRunAt: 1, + lastError: null, + }, + ]); + const listed = mergeAutomations(store, Date.now()); + assert.equal(listed.length, 2); + const shared = listed.find((a) => a.scope === "project"); + assert.equal(shared.id, "shared:p1:pr-triage"); + assert.equal(shared.enabled, false); + assert.equal(shared.prompt, ROW.prompt); + assert.equal(shared.memory, ""); + assert.equal(shared.triggerCursor, null); + assert.equal(canAutoFire(store, shared), false); + }); + + it("file definition wins; overlay keeps enabled/cursor/memory", () => { + const dir = tempRepo([{ ...ROW, prompt: "From file" }]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir, automationsTrust: "dead".repeat(16) }]); + store.setAutomations([ + { + id: "shared:p1:pr-triage", + projectId: "p1", + scope: "project", + name: "Stale", + prompt: "Stale", + provider: "claude", + preset: "hourly", + hour: null, + enabled: true, + memory: "keep me", + triggerCursor: 9, + lastRunAt: 1, + nextRunAt: 2, + lastError: null, + }, + ]); + const shared = mergeAutomations(store, Date.now()).find((a) => a.scope === "project"); + assert.equal(shared.prompt, "From file"); + assert.equal(shared.name, "PR triage"); + assert.equal(shared.enabled, true); + assert.equal(shared.memory, "keep me"); + assert.equal(shared.triggerCursor, 9); + assert.equal(canAutoFire(store, shared), false); // trust mismatch + }); + + it("gc drops overlays whose slug left the file", () => { + const dir = tempRepo([]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + store.setAutomations([ + { + id: "shared:p1:gone", + projectId: "p1", + scope: "project", + name: "Gone", + prompt: "x", + provider: "claude", + preset: "hourly", + hour: null, + enabled: true, + lastRunAt: null, + nextRunAt: 0, + lastError: null, + }, + ]); + assert.equal(gcOrphanOverlays(store), true); + assert.equal(store.getAutomations().length, 0); + }); + + it("keeps overlays when solenta.json is invalid; still GCs empty successful parse", () => { + const brokenDir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-auto-")); + fs.writeFileSync(path.join(brokenDir, "solenta.json"), "{ not json"); + const emptyDir = tempRepo([]); + const store = new Store(path.join(emptyDir, "store.json")); + store.setProjects([ + { id: "broken", name: "broken", path: brokenDir }, + { id: "empty", name: "empty", path: emptyDir }, + ]); + store.setAutomations([ + { + id: "shared:broken:keep-me", + projectId: "broken", + scope: "project", + name: "Keep", + prompt: "x", + provider: "claude", + preset: "hourly", + hour: null, + enabled: true, + lastRunAt: null, + nextRunAt: 0, + lastError: null, + }, + { + id: "shared:empty:gone", + projectId: "empty", + scope: "project", + name: "Gone", + prompt: "x", + provider: "claude", + preset: "hourly", + hour: null, + enabled: true, + lastRunAt: null, + nextRunAt: 0, + lastError: null, + }, + ]); + assert.equal(readAutomations(brokenDir).error != null, true); + assert.equal(gcOrphanOverlays(store), true); + const left = store.getAutomations(); + assert.equal(left.length, 1); + assert.equal(left[0].id, "shared:broken:keep-me"); + }); + + it("listAutomations returns the merge (same ids)", () => { + const dir = tempRepo([ROW]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + store.setAutomations([ + { + id: "uuid-1", + projectId: "p1", + name: "Local", + prompt: "x", + provider: "claude", + preset: "hourly", + hour: null, + enabled: true, + scope: "private", + lastRunAt: null, + nextRunAt: 1, + lastError: null, + }, + ]); + const merged = mergeAutomations(store, Date.now()); + const listed = services.listAutomations(store); + assert.deepEqual( + listed.map((a) => a.id).sort(), + merged.map((a) => a.id).sort(), + ); + }); +}); + +describe("project-scope writes", () => { + it("add writes solenta.json, trusts, and enables", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-auto-")); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + const created = services.addAutomation(store, { + projectId: "p1", + name: "PR triage", + prompt: "Triage", + provider: "claude", + preset: "hourly", + scope: "project", + }); + assert.equal(created.scope, "project"); + assert.equal(created.id, "shared:p1:pr-triage"); + assert.equal(created.enabled, true); + const file = JSON.parse(fs.readFileSync(path.join(dir, "solenta.json"), "utf8")); + assert.equal(file.automations[0].id, "pr-triage"); + assert.equal(file.automations[0].enabled, undefined); + const hash = parseAutomations(file).hash; + assert.equal(store.getProject("p1").automationsTrust, hash); + assert.equal(canAutoFire(store, created), true); + }); + + it("teammate overlay defaults disabled; memory update does not rewrite prompt", () => { + const dir = tempRepo([ROW]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + const listed = services.listAutomations(store).find((a) => a.scope === "project"); + assert.equal(listed.enabled, false); + services.updateAutomation(store, { id: listed.id, memory: "notes" }); + const file = JSON.parse(fs.readFileSync(path.join(dir, "solenta.json"), "utf8")); + assert.equal(file.automations[0].prompt, ROW.prompt); + assert.equal(services.listAutomations(store)[0].memory, "notes"); + }); + + it("remove drops the slug; invalid JSON is not clobbered; remote is rejected", () => { + const dir = tempRepo([ROW]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + const id = "shared:p1:pr-triage"; + services.removeAutomation(store, { id }); + const file = JSON.parse(fs.readFileSync(path.join(dir, "solenta.json"), "utf8")); + assert.deepEqual(file.automations, []); + + fs.writeFileSync(path.join(dir, "solenta.json"), "{ nope"); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + assert.throws( + () => + services.addAutomation(store, { + projectId: "p1", + name: "X", + prompt: "x", + provider: "claude", + preset: "hourly", + scope: "project", + }), + /not valid JSON/, + ); + assert.equal(fs.readFileSync(path.join(dir, "solenta.json"), "utf8"), "{ nope"); + + store.setProjects([{ id: "r1", name: "r", path: dir, remoteHost: "h" }]); + assert.throws( + () => + services.addAutomation(store, { + projectId: "r1", + name: "X", + prompt: "x", + provider: "claude", + preset: "hourly", + scope: "project", + }), + /remote/i, + ); + }); + + it("rejects changing scope and a private id that starts with shared:", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-auto-")); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + const priv = services.addAutomation(store, { + projectId: "p1", + name: "Local", + prompt: "x", + provider: "claude", + preset: "hourly", + }); + assert.equal(priv.scope, "private"); + assert.throws( + () => services.updateAutomation(store, { id: priv.id, scope: "project" }), + /scope/, + ); + assert.throws( + () => + services.addAutomation(store, { + projectId: "p1", + name: "Local", + prompt: "x", + provider: "claude", + preset: "hourly", + id: "shared:nope", + }), + /shared:/, + ); + }); + + it("preserves unknown solenta.json keys", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-auto-")); + fs.writeFileSync( + path.join(dir, "solenta.json"), + JSON.stringify({ setup: "npm ci", extra: true }), + ); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + services.addAutomation(store, { + projectId: "p1", + name: "PR triage", + prompt: "Triage", + provider: "claude", + preset: "hourly", + scope: "project", + }); + const file = JSON.parse(fs.readFileSync(path.join(dir, "solenta.json"), "utf8")); + assert.equal(file.setup, "npm ci"); + assert.equal(file.extra, true); + assert.equal(file.automations[0].id, "pr-triage"); + }); +}); + +describe("fire gates", () => { + it("untrusted shared is never due", () => { + const now = Date.now(); + const auto = { + id: "shared:p1:pr-triage", + scope: "project", + enabled: true, + nextRunAt: now - 1, + }; + assert.equal(dueAutomations([auto], now).length, 1); + + const dir = tempRepo([ROW]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([ + { id: "p1", name: "p", path: dir, automationsTrust: "dead".repeat(16) }, + ]); + store.setAutomations([ + { + id: "shared:p1:pr-triage", + projectId: "p1", + scope: "project", + name: "PR triage", + prompt: ROW.prompt, + provider: "claude", + preset: "hourly", + hour: null, + enabled: true, + lastRunAt: null, + nextRunAt: now - 1, + lastError: null, + }, + ]); + const shared = mergeAutomations(store, now).find((a) => a.scope === "project"); + assert.equal(canAutoFire(store, shared), false); + }); + + it("trustFile persists a matching hash and rejects a stale one", () => { + const dir = tempRepo([ROW]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + const hash = readAutomations(dir).hash; + assert.throws( + () => services.trustFile(store, { projectId: "p1", hash: "a".repeat(64) }), + /hash/, + ); + services.trustFile(store, { projectId: "p1", hash }); + assert.equal(store.getProject("p1").automationsTrust, hash); + }); +}); diff --git a/electron/test/automations.test.js b/electron/test/automations.test.js index 591655f9b..f46e5b2ca 100644 --- a/electron/test/automations.test.js +++ b/electron/test/automations.test.js @@ -909,4 +909,184 @@ describe("automation CRUD + scheduler", () => { ); assert.equal(store.getThreads().length, before); }); + + function writeShared(row, overlay = {}) { + fs.writeFileSync( + path.join(tmpDir, "solenta.json"), + JSON.stringify({ automations: [row] }, null, 2), + ); + const id = `shared:p1:${row.id}`; + store.setAutomations([ + { + id, + projectId: "p1", + scope: "project", + name: row.name, + prompt: row.prompt, + provider: row.provider, + preset: row.preset, + hour: row.hour ?? null, + trigger: row.trigger || null, + enabled: true, + lastRunAt: null, + nextRunAt: Date.now() - 1000, + lastError: null, + ...overlay, + }, + ]); + return id; + } + + it("tick does not fire an untrusted shared automation", async () => { + writeShared({ + id: "nightly", + name: "Nightly", + prompt: "go", + provider: "claude", + preset: "hourly", + }); + const started = []; + const sched = startScheduler({ + store, + runner: { + startRun: async (input) => { + started.push(input); + return { runId: "r" }; + }, + }, + intervalMs: 60 * 60 * 1000, + }); + try { + await sched.tick(); + } finally { + sched.stop(); + } + assert.equal(started.length, 0); + }); + + it("tick GCs orphan shared overlays", async () => { + fs.writeFileSync( + path.join(tmpDir, "solenta.json"), + JSON.stringify({ automations: [] }, null, 2), + ); + store.setAutomations([ + { + id: "shared:p1:gone", + projectId: "p1", + scope: "project", + name: "Gone", + prompt: "x", + provider: "claude", + preset: "hourly", + hour: null, + enabled: true, + lastRunAt: null, + nextRunAt: Date.now() - 1000, + lastError: null, + }, + ]); + const sched = startScheduler({ + store, + runner: { startRun: async () => ({ runId: "r" }) }, + intervalMs: 60 * 60 * 1000, + }); + try { + await sched.tick(); + } finally { + sched.stop(); + } + assert.equal(store.getAutomations().length, 0); + }); + + it("runNow on untrusted shared rejects; hash trusts and fires once without enabling", async () => { + const id = writeShared( + { + id: "nightly", + name: "Nightly", + prompt: "go", + provider: "claude", + preset: "hourly", + }, + { enabled: false, nextRunAt: Date.now() + 86_400_000 }, + ); + const { readAutomations } = require("../automations-file.js"); + const hash = readAutomations(tmpDir).hash; + const started = []; + const ctx = { + store, + runner: { + startRun: async (input) => { + started.push(input); + return { runId: "r" }; + }, + }, + }; + await assert.rejects(() => runNow(ctx, { id }), /not trusted/); + assert.equal(started.length, 0); + const after = await runNow(ctx, { id, hash }); + assert.equal(started.length, 1); + assert.equal(after.enabled, false); + assert.equal(store.getAutomation(id).enabled, false); + assert.equal(store.getProject("p1").automationsTrust, hash); + }); + + it("runNow fires the merged file prompt, not a stale overlay", async () => { + const id = writeShared( + { + id: "nightly", + name: "Nightly", + prompt: "From file", + provider: "claude", + preset: "hourly", + }, + { prompt: "Stale", enabled: false, nextRunAt: Date.now() + 86_400_000 }, + ); + const { readAutomations } = require("../automations-file.js"); + const hash = readAutomations(tmpDir).hash; + const started = []; + const after = await runNow( + { + store, + runner: { + startRun: async (input) => { + started.push(input); + return { runId: "r" }; + }, + }, + }, + { id, hash }, + ); + assert.equal(started.length, 1); + assert.match(started[0].prompt, /^From file\n/); + assert.equal(after.enabled, false); + assert.equal(store.getAutomation(id).enabled, false); + assert.equal(store.getAutomation(id).prompt, "Stale"); + }); + + it("listAutomationRuns returns empty runs for a file-only shared row", () => { + fs.writeFileSync( + path.join(tmpDir, "solenta.json"), + JSON.stringify( + { + automations: [ + { + id: "nightly", + name: "Nightly", + prompt: "go", + provider: "claude", + preset: "hourly", + }, + ], + }, + null, + 2, + ), + ); + const id = "shared:p1:nightly"; + assert.equal(store.getAutomation(id), null); + const listed = listAutomationRuns(store, id); + assert.equal(listed.automationId, id); + assert.deepEqual(listed.runs, []); + assert.equal(listed.retentionLimitReached, false); + }); }); diff --git a/electron/test/project-setup-h3.test.js b/electron/test/project-setup-h3.test.js index 30f65bc14..2d2de386a 100644 --- a/electron/test/project-setup-h3.test.js +++ b/electron/test/project-setup-h3.test.js @@ -314,6 +314,20 @@ describe("solenta.json precedence and approval", () => { assert.equal(ok.branchPrefix, "agents/"); assert.equal(ok.repoConfigTrust, "a".repeat(64)); }); + + it("drops a non-hex automationsTrust", () => { + const n = migrateProject({ id: "p", path: "/tmp/p", automationsTrust: "nothex" }); + assert.equal(n.automationsTrust, undefined); + }); + + it("keeps a hex automationsTrust", () => { + const ok = migrateProject({ + id: "p", + path: "/tmp/p", + automationsTrust: "a".repeat(64), + }); + assert.equal(ok.automationsTrust, "a".repeat(64)); + }); }); describe("submodules on a new worktree", () => { diff --git a/electron/test/store.test.js b/electron/test/store.test.js index bcb700347..3c5b398ee 100644 --- a/electron/test/store.test.js +++ b/electron/test/store.test.js @@ -78,6 +78,31 @@ describe("Store", () => { assert.equal(auto.lastError, null); }); + it("migrates missing automation scope to private", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-store-")); + const file = path.join(dir, "store.json"); + fs.writeFileSync( + file, + JSON.stringify({ + projects: [], + threads: [], + automations: [ + { + id: "a1", + projectId: "p", + name: "Old", + prompt: "x", + provider: "claude", + preset: "hourly", + }, + ], + }), + ); + const store = new Store(file); + const [auto] = store.getAutomations(); + assert.equal(auto.scope, "private"); + }); + it("normalizes lastErrorKind and rejects unknown persisted kinds", () => { fs.writeFileSync( filePath, diff --git a/electron/webScopes.js b/electron/webScopes.js index df784ee25..5a0be3903 100644 --- a/electron/webScopes.js +++ b/electron/webScopes.js @@ -277,6 +277,7 @@ const BY_SCOPE = { "automations:update", "automations:remove", "automations:runNow", + "automations:trustFile", "projects:add", "projects:create", "projects:clone", diff --git a/src/App.tsx b/src/App.tsx index d80610ec0..2d790f1e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -413,6 +413,7 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {}) updateAutomation, removeAutomation, runAutomationNow, + trustAutomationsFile, listAutomationRuns, refreshAutomations, } = useCoder(); @@ -1870,8 +1871,12 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {}) await updateAutomation(input); }} onRemove={(id) => removeAutomation(id)} - onRunNow={async (id) => { - await runAutomationNow(id); + onRunNow={async (id, hash) => { + await runAutomationNow(id, hash); + }} + onTrustFile={async (projectId, hash) => { + await trustAutomationsFile(projectId, hash); + await refreshAutomations(); }} focusId={focusAutomationId} /> diff --git a/src/coder/useCoderWorkflows.ts b/src/coder/useCoderWorkflows.ts index 3372e12d0..b7eb354e4 100644 --- a/src/coder/useCoderWorkflows.ts +++ b/src/coder/useCoderWorkflows.ts @@ -4,6 +4,7 @@ import type { AutomationInfo, AutomationWrite, CoderApi, + ProjectInfo, ThreadDetail, ThreadInfo, WorkflowTemplateInfo, @@ -31,6 +32,7 @@ export function useCoderWorkflows({ setWorkflows, setWorkflowListError, setAutomations, + setProjects, setDetail, setError, selectedRef, @@ -42,6 +44,7 @@ export function useCoderWorkflows({ setWorkflows: Dispatch>; setWorkflowListError: Dispatch>; setAutomations: Dispatch>; + setProjects: Dispatch>; setDetail: Dispatch>; setError: Dispatch>; selectedRef: RefObject; @@ -93,17 +96,30 @@ export function useCoderWorkflows({ ); const runAutomationNow = useCallback( - async (automationId: string) => { + async (automationId: string, hash?: string) => { try { - return await api.automations.runNow({ id: automationId }); + return await api.automations.runNow({ + id: automationId, + ...(hash ? { hash } : {}), + }); } finally { // runNow rethrows the agent failure AFTER the main process has already // written lastError, so the row only shows it if we resync on the - // throwing path too (issue #85). + // throwing path too (issue #85). Approve-and-run also trusts the file, + // so refresh projects the same way trustAutomationsFile does. await refreshAutomations(); + await api.projects.list().then(setProjects); } }, - [api, refreshAutomations], + [api, refreshAutomations, setProjects], + ); + + const trustAutomationsFile = useCallback( + async (projectId: string, hash: string) => { + await api.automations.trustFile({ projectId, hash }); + await api.projects.list().then(setProjects); + }, + [api, setProjects], ); const listAutomationRuns = useCallback( @@ -218,6 +234,7 @@ export function useCoderWorkflows({ updateAutomation, removeAutomation, runAutomationNow, + trustAutomationsFile, listAutomationRuns, startWorkflowRun, retryWorkflowAgent, diff --git a/src/components/AutomationsView.module.css b/src/components/AutomationsView.module.css index 0584850a0..1b93fcf6f 100644 --- a/src/components/AutomationsView.module.css +++ b/src/components/AutomationsView.module.css @@ -190,6 +190,91 @@ color: var(--text-muted); } +.scopeBadge { + flex-shrink: 0; + padding: 1px 7px; + border-radius: 999px; + border: 1px solid var(--border); + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); +} + +.wrote { + margin: 0; + font-size: 12px; + color: var(--text-dim); +} + +.approveList { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 24px 0; +} + +.approveCard { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border-radius: var(--radius); + background: var(--amber-soft); + border: 1px solid var(--amber-border); + color: var(--warning-fg); + font-size: 12.5px; +} + +.approveHead { + font-weight: 500; +} + +.approveNote { + margin: 0; + line-height: 1.4; +} + +.approveActions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.approveRun { + padding: 4px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--green-border); + background: var(--diff-add-bg); + color: var(--success-fg); + font-size: 12px; + font-weight: 600; +} + +.approveRun:hover:not(:disabled) { + background: var(--green-soft); +} + +.approveRun:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.approveCancel { + padding: 4px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--danger-border); + background: var(--danger-soft); + color: var(--danger-fg); + font-size: 12px; + font-weight: 600; +} + +.approveCancel:hover { + background: var(--danger-soft); +} + .rowMeta { display: flex; align-items: center; @@ -245,6 +330,11 @@ margin: 0; } +.toggle input:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .action { display: inline-flex; align-items: center; diff --git a/src/components/AutomationsView.tsx b/src/components/AutomationsView.tsx index 9b14e6dc6..c08eb4bf2 100644 --- a/src/components/AutomationsView.tsx +++ b/src/components/AutomationsView.tsx @@ -14,6 +14,7 @@ import type { AutomationInfo, AutomationPreset, AutomationRunsResult, + AutomationScope, AutomationTrigger, AutomationTriggerEvent, AutomationWrite, @@ -46,7 +47,35 @@ export interface AutomationsViewProps { input: Partial & { id: string }, ) => Promise | void; onRemove: (id: string) => Promise | void; - onRunNow: (id: string) => Promise | void; + onRunNow: (id: string, hash?: string) => Promise | void; + onTrustFile?: (projectId: string, hash: string) => Promise | void; +} + +/** Same keys `updateAutomation` uses to decide whether to rewrite solenta.json. */ +function definitionChanged( + fields: { + name: string; + prompt: string; + provider: string; + model: string | null; + preset: AutomationPreset; + hour: number | null; + trigger: AutomationTrigger | null; + tier: ModelRoute | null; + }, + existing: AutomationInfo, +): boolean { + return ( + fields.name !== existing.name || + fields.prompt !== existing.prompt || + fields.provider !== existing.provider || + fields.model !== (existing.model ?? null) || + fields.preset !== existing.preset || + fields.hour !== (existing.hour ?? null) || + JSON.stringify(fields.trigger) !== + JSON.stringify(existing.trigger || null) || + fields.tier !== (existing.tier ?? null) + ); } function liveThreadKey( @@ -240,6 +269,7 @@ export function AutomationsView({ onUpdate, onRemove, onRunNow, + onTrustFile, }: AutomationsViewProps) { const [name, setName] = useState(draft?.name ?? ""); const [projectId, setProjectId] = useState( @@ -265,6 +295,11 @@ export function AutomationsView({ const [project, setProject] = useState(""); const [environment, setEnvironment] = useState(""); const [memory, setMemory] = useState(""); + const [scope, setScope] = useState("private"); + const [wroteFile, setWroteFile] = useState(false); + const [dismissedApprove, setDismissedApprove] = useState>( + () => new Set(), + ); const [formError, setFormError] = useState(null); const [confirmId, setConfirmId] = useState(null); const [editingId, setEditingId] = useState(null); @@ -306,6 +341,7 @@ export function AutomationsView({ setProject(auto.trigger?.project ?? ""); setEnvironment(auto.trigger?.environment ?? ""); setMemory(auto.memory ?? ""); + setScope(auto.scope ?? "private"); setFormError(null); }; @@ -336,6 +372,7 @@ export function AutomationsView({ setProject(""); setEnvironment(""); setMemory(""); + setScope("private"); setFormError(null); }; @@ -362,6 +399,22 @@ export function AutomationsView({ () => new Map(projects.map((p) => [p.id, p])), [projects], ); + const selectedProject = projectById.get(projectId); + const projectScopeBlocked = Boolean( + !selectedProject || selectedProject.remoteHost || !selectedProject.path, + ); + const approveProjects = projects.filter((p) => { + const file = p.automationsFile; + if (!file || dismissedApprove.has(p.id)) return false; + if (file.error) return true; + return !file.trusted && file.count > 0; + }); + + useEffect(() => { + if (projectScopeBlocked && scope === "project" && !editingId) { + setScope("private"); + } + }, [projectScopeBlocked, scope, editingId]); const needsHour = !event && (preset === "daily" || preset === "weekly"); const triggerType = isTriggerEvent(event) @@ -418,22 +471,41 @@ export function AutomationsView({ setFormError(null); try { if (editingId) { - const hadTier = automations.find((a) => a.id === editingId)?.tier; - await onUpdate({ - id: editingId, + const editing = automations.find((a) => a.id === editingId); + const hadTier = editing?.tier; + const nextDef = { name: name.trim(), prompt, provider, model: model.trim() || null, - // Event mode ignores the schedule; hourly needs no hour. - preset: event ? "hourly" : preset, + preset: event ? ("hourly" as const) : preset, hour: needsHour ? Number(hour) : null, trigger, + tier: (tier || null) as ModelRoute | null, + }; + await onUpdate({ + id: editingId, + name: nextDef.name, + prompt: nextDef.prompt, + provider: nextDef.provider, + model: nextDef.model, + // Event mode ignores the schedule; hourly needs no hour. + preset: nextDef.preset, + hour: nextDef.hour, + trigger: nextDef.trigger, memory, - ...(tier || hadTier ? { tier: tier || null } : {}), + ...(tier || hadTier ? { tier: nextDef.tier } : {}), }); + setWroteFile( + Boolean( + editing?.scope === "project" && + editing && + definitionChanged(nextDef, editing), + ), + ); clearEdit(); } else { + const writeScope = projectScopeBlocked ? "private" : scope; await onCreate({ name: name.trim(), projectId, @@ -445,12 +517,15 @@ export function AutomationsView({ hour: needsHour ? Number(hour) : null, trigger, enabled: true, + scope: writeScope, ...(tier ? { tier } : {}), }); + setWroteFile(writeScope === "project"); setName(""); setPrompt(""); setModel(""); setTier(""); + setScope("private"); } } catch (err) { setFormError( @@ -492,7 +567,14 @@ export function AutomationsView({ +