diff --git a/electron/automation-destination.js b/electron/automation-destination.js new file mode 100644 index 000000000..e252df0ed --- /dev/null +++ b/electron/automation-destination.js @@ -0,0 +1,56 @@ +"use strict"; + +/** Missing mode keeps legacy new-thread-per-run definitions unchanged. */ +function destinationFields(input, base = {}) { + const sessionMode = input.sessionMode === undefined + ? (base.sessionMode || "new-thread") : input.sessionMode; + if (!["new-thread", "continue", "fresh"].includes(sessionMode)) { + throw new Error("Invalid automation session mode"); + } + const raw = input.destinationThreadId === undefined + ? base.destinationThreadId : input.destinationThreadId; + const destinationThreadId = sessionMode === "new-thread" ? null : String(raw || "").trim(); + if (sessionMode !== "new-thread" && !destinationThreadId) { + throw new Error("Destination thread is required"); + } + return { sessionMode, destinationThreadId }; +} + +/** Save validates identity and authority; execution additionally requires an idle target. */ +function destinationThread(store, auto, runner, staticOnly = false) { + const { sessionMode, destinationThreadId } = destinationFields(auto); + if (sessionMode === "new-thread") return null; + const t = store.getThread(destinationThreadId); + if (!t || t.projectId !== auto.projectId) { + throw new Error("Automation destination must exist in its project"); + } + if (t.archived || t.trashedAt || t.deletedAt || t.teleportedTo || + t.memoryConsolidate || t.ask || t.pendingFork) { + throw new Error("Automation destination is unavailable"); + } + if (!staticOnly && (runner?.isRunning?.(t.id) || + t.status === "working" || t.status === "quota-wait" || + t.quotaWaitUntil || t.quotaFailoverPending || t.pendingPlan || + t.pendingQuestion || t.pendingApproval || t.pendingSecret || t.awaitingInput || + t.runQueue || t.queued || t.interruptedAt || t.btw?.length || + runner?.getPendingPermission?.(t.id))) { + throw Object.assign( + new Error("Automation destination has unfinished work or pending input; run skipped"), + { code: "AUTOMATION_DESTINATION_BUSY" }, + ); + } + // Fixed destinations retain identity, but cannot lend their additional authority. + if (t.provider !== auto.provider || (t.model || null) !== (auto.model || null) || + t.providerInstance || t.agentProfileId || t.autoApprove || + (t.permissionMode && t.permissionMode !== "default") || + t.folderWriteGrants?.length || t.restartGrant || t.alwaysAllowKeys?.length || + t.alwaysAllowRules?.length || t.route || auto.tier) { + throw new Error("Automation destination must match provider/model with default permissions, no account override, grants, or routing"); + } + if (!staticOnly && t.freshContextPending && sessionMode !== "fresh") { + throw new Error("Automation destination is waiting for a user message after fresh context"); + } + return t; +} + +module.exports = { destinationFields, destinationThread }; diff --git a/electron/automations-file.js b/electron/automations-file.js index cb03b12fd..760cd4654 100644 --- a/electron/automations-file.js +++ b/electron/automations-file.js @@ -25,7 +25,7 @@ const MAX_SHARED_AUTOMATIONS = 16; const PROMPT_MAX = 4000; const PRESETS = new Set(["hourly", "daily", "weekly", "cron", "once"]); /** canonical()'s keys: anything else in a row is a typo (#438). */ -const ROW_KEYS = new Set(["id", "name", "prompt", "provider", "model", "preset", "hour", "cron", "runAt", "trigger", "tier", "budget"]); +const ROW_KEYS = new Set(["sessionMode", "destinationThreadId", "id", "name", "prompt", "provider", "model", "preset", "hour", "cron", "runAt", "trigger", "tier", "budget"]); /** * Overlay / merge id for a shared automation row. @@ -71,6 +71,8 @@ function slugFromName(name, taken) { */ function canonical(row) { return { + sessionMode: row.sessionMode, + destinationThreadId: row.destinationThreadId, id: row.id, name: row.name, prompt: row.prompt, @@ -238,8 +240,12 @@ function parseRow(raw, index, seen) { return { error: `solenta.json: ${at}.${msg}` }; } + let destination; + try { destination = require("./automation-destination.js").destinationFields(r); } + catch (err) { return { error: `solenta.json: ${at}: ${err.message}` }; } return { row: { + ...(r.sessionMode !== undefined || r.destinationThreadId !== undefined ? destination : {}), id, name, prompt, @@ -338,7 +344,7 @@ function isLocalProject(project) { * @returns {object[]} */ function mergeAutomations(store, nowMs) { - const { nextFire } = require("./automations.js"); + const { nextFire, MAX_THREADS_PER_AUTOMATION } = require("./automations.js"); const privates = (store.getAutomations() || []).filter( (a) => a && a.scope !== "project", ); @@ -363,6 +369,10 @@ function mergeAutomations(store, nowMs) { lastError: overlay.lastError, consecutiveFailures: overlay.consecutiveFailures, pendingRunThreadId: overlay.pendingRunThreadId, + ...(overlay.pendingRunId !== undefined ? { pendingRunId: overlay.pendingRunId } : {}), + ...(Array.isArray(overlay.runHistory) + ? { runHistory: overlay.runHistory.slice(0, MAX_THREADS_PER_AUTOMATION) } + : {}), nextRunAt: overlay.nextRunAt, stage: overlay.stage, graduate: overlay.graduate, @@ -388,6 +398,8 @@ function mergeAutomations(store, nowMs) { projectId: project.id, name: row.name, prompt: row.prompt, + sessionMode: row.sessionMode, + destinationThreadId: row.destinationThreadId, provider: row.provider, model: row.model, preset: row.preset, @@ -500,6 +512,8 @@ function ensureOverlay(store, auto) { scope: "project", name: auto.name, prompt: auto.prompt, + sessionMode: auto.sessionMode, + destinationThreadId: auto.destinationThreadId, provider: auto.provider, model: auto.model, preset: auto.preset, diff --git a/electron/automations.js b/electron/automations.js index c7231da4b..95615cc77 100644 --- a/electron/automations.js +++ b/electron/automations.js @@ -304,8 +304,14 @@ function settleAutomationRuns(ctx) { const t = ctx.store.getThread(auto.pendingRunThreadId); if (t && (t.status === "working" || t.status === "quota-wait")) continue; changed = true; + if (auto.pendingRunId && auto.runHistory?.length) patchAutomation(ctx.store, auto.id, { + pendingRunId: null, + runHistory: auto.runHistory.map((r) => r.runId === auto.pendingRunId ? { ...r, status: t?.status || "failed" } : r), + }); if (t) { - const last = ctx.store.getLastAssistantMessage(t.id); + const last = auto.pendingRunId + ? ctx.store.getMessages(t.id).filter((m) => m.role === "assistant" && m.runId === auto.pendingRunId).at(-1) + : ctx.store.getLastAssistantMessage(t.id); const memory = extractMemory(last && last.text); if (memory != null) patchAutomation(ctx.store, auto.id, { memory }); } @@ -368,7 +374,7 @@ function pruneAutomationThreads(store, automationId) { indexed.sort((a, b) => (b.t.createdAt - a.t.createdAt) || (b.i - a.i)); for (let i = MAX_THREADS_PER_AUTOMATION; i < indexed.length; i++) { const t = indexed[i].t; - if (isRetainedAutomationThread(t)) continue; + if (isRetainedAutomationThread(t) || require("./automations-file.js").mergeAutomations(store, Date.now()).some((a) => a.destinationThreadId === t.id || a.runHistory?.some((r) => r.threadId === t.id))) continue; services.purgeThread(store, t.id); } } @@ -383,7 +389,25 @@ function pruneAutomationThreads(store, automationId) { * @param {number} now * @param {{ rethrow?: boolean, triggerNote?: string }} [opts] */ +const firingByStore = new WeakMap(); async function runAutomation(ctx, auto, now, opts) { + let firing = firingByStore.get(ctx.store); + if (!firing) firingByStore.set(ctx.store, firing = new Set()); + const keys = [`automation:${auto.id}`]; + if (auto.sessionMode && auto.sessionMode !== "new-thread") keys.push(`thread:${auto.destinationThreadId}`); + if (keys.some((key) => firing.has(key))) { + const error = new Error("Automation destination is already starting a run"); + if (opts?.rethrow) throw error; + return; + } + for (const key of keys) firing.add(key); + try { + return await fireAutomation(ctx, auto, now, opts); + } finally { + for (const key of keys) firing.delete(key); + } +} +async function fireAutomation(ctx, auto, now, opts) { const services = require("./services.js"); const nextRunAt = nextFire(auto.preset, auto.hour, now, auto.nextRunAt, auto.cron, auto.runAt); // #388: settle ran first in the tick, so the breaker count is current. @@ -400,7 +424,12 @@ async function runAutomation(ctx, auto, now, opts) { let runErr = null; try { - const thread = services.createThread(ctx.store, { + settleAutomationRuns(ctx); + const pending = ctx.store.getAutomation(auto.id)?.pendingRunThreadId; + const pendingThread = pending && ctx.store.getThread(pending); + if (auto.sessionMode && auto.sessionMode !== "new-thread" && pendingThread && (pendingThread.status === "working" || pendingThread.status === "quota-wait")) throw Object.assign(new Error("Automation still has an unfinished run; run skipped"), { code: "AUTOMATION_DESTINATION_BUSY" }); + const target = require("./automation-destination.js").destinationThread(ctx.store, auto, ctx.runner); + const thread = target || services.createThread(ctx.store, { projectId: auto.projectId, title: auto.name, automationId: auto.id, @@ -409,8 +438,12 @@ async function runAutomation(ctx, auto, now, opts) { // #286: the automation's cap is harness state on the thread it fires. const { budgetPatchFrom } = require("./budgetCaps.js"); const capped = budgetPatchFrom(auto); - if (capped.budget) ctx.store.updateThread(thread.id, capped); - services.setProvider(ctx.store, { + if (capped.budget) { + const budget = { ...(thread.budget || {}) }; + for (const [key, limit] of Object.entries(capped.budget)) budget[key] = Math.min(budget[key] ?? Infinity, limit); + ctx.store.updateThread(thread.id, { budget }); + } + if (!target) services.setProvider(ctx.store, { threadId: thread.id, provider: auto.provider, model: auto.model, @@ -420,25 +453,31 @@ async function runAutomation(ctx, auto, now, opts) { if (typeof ctx.broadcast === "function") { ctx.broadcast("threads:changed", services.listThreads(ctx.store)); } - await ctx.runner.startRun({ + const launch = await (target ? ctx.runner.startAutomationRun.bind(ctx.runner) : ctx.runner.startRun.bind(ctx.runner))({ + ...(target ? { automationId: auto.id } : {}), threadId: thread.id, prompt: buildRunPrompt({ ...auto, ...life }, opts && opts.triggerNote), }); patchAutomation(ctx.store, auto.id, { lastError: null, pendingRunThreadId: thread.id, + ...((target || ctx.store.getAutomation(auto.id)?.runHistory?.length) && launch?.runId ? { pendingRunId: launch.runId, runHistory: [ + { id: require("node:crypto").randomUUID(), threadId: thread.id, newThread: !target, runId: launch.runId, startedAt: now, status: "working" }, + ...(ctx.store.getAutomation(auto.id)?.runHistory || []), + ].slice(0, MAX_THREADS_PER_AUTOMATION) } : {}), }); ctx.store.save(); } catch (err) { runErr = err; - recordFailure(ctx, auto.id, errorText(err)); + if (err?.code === "AUTOMATION_DESTINATION_BUSY") patchAutomation(ctx.store, auto.id, { lastError: errorText(err) }); + else recordFailure(ctx, auto.id, errorText(err)); ctx.store.save(); } // #134: prune after startRun so a thrown purge cannot undo a completed fire. // Own try/catch: retention failure must not stamp lastError or fail the run. try { - pruneAutomationThreads(ctx.store, auto.id); + if (!auto.sessionMode || auto.sessionMode === "new-thread") pruneAutomationThreads(ctx.store, auto.id); ctx.store.save(); const { scheduleImagePruneFromStore } = require("./image-store.js"); void scheduleImagePruneFromStore(ctx.store); @@ -473,6 +512,7 @@ function listAutomationRuns(store, automationId) { if (!auto) { throw new Error(`Unknown automation: ${id}`); } + const fixedRuns = (auto.runHistory || []).map(({ id, threadId, runId, startedAt, status }, i) => ({ id, threadId, runId, startedAt, status, pending: auto.pendingRunId === runId && auto.pendingRunThreadId === threadId })); const indexed = store .getThreads() .map((t, i) => ({ t, i })) @@ -480,15 +520,16 @@ function listAutomationRuns(store, automationId) { ({ t }) => t && t.automationId === id && + !auto.runHistory?.some((r) => r.newThread && r.threadId === t.id) && t.projectId === auto.projectId && t.memoryConsolidate !== true, ); indexed.sort((a, b) => b.t.createdAt - a.t.createdAt || b.i - a.i); - const runs = indexed.map(({ t }) => ({ + const runs = [...fixedRuns, ...indexed.map(({ t }) => ({ threadId: t.id, startedAt: t.createdAt, status: t.status || "idle", - })); + }))].sort((a, b) => b.startedAt - a.startedAt); return { automationId: id, runs, diff --git a/electron/ipc-git.js b/electron/ipc-git.js index 709e80eb2..02add6f98 100644 --- a/electron/ipc-git.js +++ b/electron/ipc-git.js @@ -325,6 +325,7 @@ module.exports = { "git:prTemplate": async (_ctx, input) => { return readPrTemplate(input && input.projectPath); }, + "git:prCompletions": async (_ctx, input) => require("./prCompletions.js").prCompletions(input), "git:prDetail": async (_ctx, input) => { return viewPrDetail(input && input.projectPath, input && input.prNumber, { repo: input && input.repo, diff --git a/electron/orchServer.js b/electron/orchServer.js index 4e6dd4651..236e441f4 100644 --- a/electron/orchServer.js +++ b/electron/orchServer.js @@ -1440,6 +1440,8 @@ function createToolHandlers(deps) { } const created = addAutomation(store, { projectId: thread.projectId, + sessionMode: args && args.sessionMode, + destinationThreadId: args && args.destinationThreadId, name: args && args.name, prompt: args && args.prompt, provider: (args && args.provider) || thread.provider, @@ -1460,6 +1462,8 @@ function createToolHandlers(deps) { ok: true, id: created.id, name: created.name, + sessionMode: created.sessionMode || "new-thread", + destinationThreadId: created.destinationThreadId || null, projectId: created.projectId, prompt: created.prompt, provider: created.provider, @@ -2452,6 +2456,8 @@ function buildMcpServer(sdk, handlers, opts = {}) { inputSchema: { threadId: z.string().min(1), projectId: z.string().min(1), + sessionMode: z.enum(["new-thread", "continue", "fresh"]).optional(), + destinationThreadId: z.string().optional(), name: z.string().min(1), prompt: z.string().min(1), provider: z.string().min(1).optional(), diff --git a/electron/prCompletions.js b/electron/prCompletions.js new file mode 100644 index 000000000..0836540e8 --- /dev/null +++ b/electron/prCompletions.js @@ -0,0 +1,70 @@ +"use strict"; +const { gitTryAsync, ghApiTryAsync, tailErr } = require('./worktrees.js'); +const { gitlabRemote, glabApi } = require('./gitlab.js'); +const { parseRepoSlug } = require('./prLinks.js'); + +// Every request resolves origin anew. Never search all users or all accounts. +async function prCompletions(input) { + const cwd = String(input?.projectPath || ''); + const kind = input?.kind; + if (!cwd || !['@', '#', '!'].includes(kind)) return { ok: false, reason: 'Invalid completion request' }; + // Match the textarea token grammar at the trust boundary too. In particular, + // GitHub search qualifiers, spaces and quotes must never augment repo scope. + const query = input.query == null ? '' : input.query; + if (typeof query !== 'string' || !/^[\w.-]{0,100}$/.test(query)) { + return { ok: false, reason: 'Invalid completion query' }; + } + const page = Math.max(1, Math.min(10, Math.trunc(Number(input.page) || 1))); + const remote = await gitTryAsync(cwd, ['remote', 'get-url', 'origin']); + if (!remote.ok) return { ok: false, reason: 'Repository unavailable' }; + const origin = String(remote.stdout || '').trim(); + const gl = gitlabRemote(origin); + let rows; + let exact = null; + const numeric = kind !== "@" && page === 1 && /^[1-9]\d*$/.test(query) && Number.isSafeInteger(Number(query)); + if (gl) { + // Explicit cross-repo requests stay on the origin host and its authenticated account. + if (input.repo) gl.path = String(input.repo); + const endpoint = kind === '@' ? '/members/all' : kind === '!' ? '/merge_requests' : '/issues'; + const [result, direct] = await Promise.all([ + glabApi(cwd, gl, 'GET', `${endpoint}?per_page=20&page=${page}&${kind === '@' ? 'query' : 'search'}=${encodeURIComponent(query)}`), + numeric ? glabApi(cwd, gl, 'GET', `${endpoint}/${Number(query)}`) : null, + ]); + if (direct?.ok && Number(direct.data?.iid) === Number(query)) exact = direct.data; + if (!result.ok && !exact) return result; + rows = result.ok && Array.isArray(result.data) ? result.data : []; + } else { + if (kind === '!') return { ok: true, items: [], hasMore: false }; + const slug = parseRepoSlug(input.repo || origin); + if (!slug) return { ok: false, reason: 'Repository unavailable' }; + const endpoint = kind === '@' + ? `repos/${slug}/assignees?per_page=20&page=${page}` + : `search/issues?q=${encodeURIComponent(`repo:${slug} ${query}`)}&per_page=20&page=${page}`; + const host = origin.match(/^[^@/]+@([^:]+):/)?.[1] || (() => { try { return new URL(origin).hostname; } catch { return null; } })(); + if (!host) return { ok: false, reason: 'Repository host unavailable' }; + const request = endpoint => ghApiTryAsync(cwd, ['api', endpoint, '--hostname', host], { originUrl: origin, timeout: 15000 }); + const [result, direct] = await Promise.all([ + request(endpoint), numeric ? request(`repos/${slug}/issues/${Number(query)}`) : null, + ]); + if (direct?.ok) { + try { const data = JSON.parse(direct.stdout); if (Number(data?.number) === Number(query)) exact = data; } catch { /* search remains available */ } + } + if (!result.ok && !exact) return { ok: false, reason: tailErr(result.stderr || result.combined, 'Completion search failed') }; + try { const data = result.ok ? JSON.parse(result.stdout) : []; rows = Array.isArray(data) ? data : data.items || []; } + catch { if (!exact) return { ok: false, reason: 'Invalid completion response' }; rows = []; } + } + const hasMore = rows.length >= 20 && page < 10; + // Pin the exact reference without displacing any of the 20 search rows or + // shifting subsequent search pages. A first numeric page has at most 21 items. + if (exact) rows = [exact, ...rows.filter(row => Number(row.iid || row.number) !== Number(query))]; + const items = rows.map(row => kind === '@' + ? { value: `@${row.username || row.login}`, label: row.name || row.username || row.login } + : { value: `${kind}${row.iid || row.number}`, label: row.title || '' }) + .filter(row => row.value.toLowerCase().includes(query.toLowerCase()) || row.label.toLowerCase().includes(query.toLowerCase())) + .slice(0, exact ? 21 : 20); + return { ok: true, items, hasMore }; +} +module.exports = { prCompletions: async input => { + try { return await prCompletions(input); } + catch { return { ok: false, reason: 'Completion search unavailable' }; } +} }; diff --git a/electron/prWorkspace.js b/electron/prWorkspace.js index 8ae88ba47..a080c37a4 100644 --- a/electron/prWorkspace.js +++ b/electron/prWorkspace.js @@ -184,6 +184,40 @@ function parsePrNumber(value) { * @param {string} projectPath * @returns {Promise<{ ok: true, cwd: string } | { ok: false, reason: string }>} */ +async function gitlabWorkspaceRemote(projectPath, repo) { + const remote = await gitTryAsync(String(projectPath || ""), ["remote", "get-url", "origin"]); + const gl = remote.ok ? require("./gitlab.js").gitlabRemote(String(remote.stdout || "").trim()) : null; + if (gl && repo) gl.path = String(repo); + return gl; +} + +async function viewGitlabWorkspace(projectPath, number, remote) { + const { glabApi } = require("./gitlab.js"); + const result = await glabApi(projectPath, remote, "GET", `/merge_requests/${number}`); + if (!result.ok) return result; + const mr = result.data; + if (!mr || !mr.iid || !mr.web_url) return { ok: false, reason: "Invalid merge request response" }; + const comments = []; + let commentsError; + // Keep transcript bounded, as with the GitHub workspace comment budget. + for (let page = 1; page <= 5; page++) { + const notes = await glabApi(projectPath, remote, "GET", `/merge_requests/${number}/notes?per_page=100&page=${page}&sort=asc&order_by=created_at`); + if (!notes.ok) { commentsError = notes.reason; break; } + const rows = Array.isArray(notes.data) ? notes.data : []; + for (const note of rows) { + if (!note.system) comments.push({ id: note.id, type: "issue", author: note.author?.username || "", body: note.body || "", createdAt: note.created_at || "", url: `${mr.web_url}#note_${note.id}` }); + } + if (rows.length < 100) break; + if (page === 5) commentsError = "Showing the first 500 discussion notes. Open GitLab for the full conversation."; + } + return { ok: true, pr: { + forge: "gitlab", number: mr.iid, title: mr.title || "", body: mr.description || "", url: mr.web_url, + state: mr.state === "merged" ? "MERGED" : mr.state === "closed" ? "CLOSED" : "OPEN", + isDraft: Boolean(mr.draft || mr.work_in_progress), headRefName: mr.source_branch || "", baseRefName: mr.target_branch || "", + author: mr.author?.username || "", updatedAt: mr.updated_at, comments, ...(commentsError ? { commentsError } : {}), + } }; +} + async function requireGitHubRepo(projectPath) { const cwd = String(projectPath || ""); if (!cwd) return { ok: false, reason: "not a GitHub repo" }; @@ -576,6 +610,8 @@ async function loadPrComments(cwd, number, other, info) { async function viewPrDetail(projectPath, prNumber, opts) { const number = parsePrNumber(prNumber); if (number == null) return { ok: false, reason: "invalid PR number" }; + const gl = await gitlabWorkspaceRemote(projectPath, opts && opts.repo); + if (gl) return viewGitlabWorkspace(projectPath, number, gl); const repo = await requireGitHubRepo(projectPath); if (!repo.ok) return repo; const other = parseRepoSlug(opts && opts.repo); @@ -673,6 +709,14 @@ async function editPr(projectPath, input, opts) { const blocked = scanOutbound(outbound, "PR"); if (blocked) return { ok: false, reason: blocked }; + const gl = await gitlabWorkspaceRemote(projectPath, input && input.repo); + if (gl) { + const fields = {}; + if (title != null) fields.title = title; + if (body != null) fields.description = body; + const edited = await require("./gitlab.js").glabApi(projectPath, gl, "PUT", `/merge_requests/${number}`, fields); + return edited.ok ? viewGitlabWorkspace(projectPath, number, gl) : edited; + } const repo = await requireGitHubRepo(projectPath); if (!repo.ok) return repo; const other = parseRepoSlug(input && input.repo); @@ -715,6 +759,11 @@ async function commentPr(projectPath, input) { const blocked = scanOutbound(text, "PR comment"); if (blocked) return { ok: false, reason: blocked }; + const gl = await gitlabWorkspaceRemote(projectPath, input && input.repo); + if (gl) { + const posted = await require("./gitlab.js").glabApi(projectPath, gl, "POST", `/merge_requests/${number}/notes`, { body: text }); + return posted.ok ? { ok: true } : posted; + } const repo = await requireGitHubRepo(projectPath); if (!repo.ok) return repo; diff --git a/electron/preload.js b/electron/preload.js index c0cd07453..5f040b736 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -342,6 +342,7 @@ const IPC_CHANNELS = Object.freeze([ { ns: "git", method: "listPrs" }, { ns: "git", method: "checkoutPr" }, { ns: "git", method: "prTemplate" }, + { ns: "git", method: "prCompletions" }, { ns: "git", method: "prDetail" }, { ns: "git", method: "prEdit" }, { ns: "git", method: "prComment" }, diff --git a/electron/runner.js b/electron/runner.js index eefbc4937..74183bf80 100644 --- a/electron/runner.js +++ b/electron/runner.js @@ -1798,7 +1798,14 @@ function createRunner(opts) { * appended) any throw — budget, missing CLI, worktree setup — puts that * queue back in front of whatever was queued since. */ + const automationStart = Symbol("configured automation start"); + function startAutomationRun(input) { + return startRun({ threadId: input.threadId, prompt: input.prompt, [automationStart]: input.automationId }); + } + async function startRun(input) { + // Capture completed fixed-destination automation outcomes before a manual turn changes the row. + if (require("./automations.js").settleAutomationRuns({ store })) store.save(); const fold = { taken: null }; try { return await startRunFolded(input, fold); @@ -1836,7 +1843,7 @@ function createRunner(opts) { // queued as the next prompt. fromNotice is skipped so a worker // quoting `/btw` cannot open a card on itself. fromInbound is skipped // so a cross-thread body that happens to start with `/btw` is data. - if (!input.fromNotice && !input.fromInbound) { + if (!input[automationStart] && !input.fromNotice && !input.fromInbound) { const question = btw.parseBtwCommand(prompt); if (question) { const thread = await startBtw({ threadId, question }); @@ -1855,10 +1862,18 @@ function createRunner(opts) { ); } - const thread = store.getThread(threadId); + let thread = store.getThread(threadId); if (!thread) { throw new Error(`Unknown thread: ${threadId}`); } + const configuredAutomationId = input[automationStart]; + const automation = configuredAutomationId + ? require("./automations-file.js").mergeAutomations(store, Date.now()).find((a) => a.id === configuredAutomationId) + : null; + if (configuredAutomationId) { + if (!automation || automation.destinationThreadId !== threadId || !automation.sessionMode || automation.sessionMode === "new-thread") throw new Error("Automation destination configuration changed before launch"); + require("./automation-destination.js").destinationThread(store, automation, { isRunning, getPendingPermission }); + } if (services.isTrashed(thread)) { throw new Error("Cannot start a run on a deleted thread"); } @@ -1930,6 +1945,7 @@ function createRunner(opts) { // otherwise fan out again — and the cheap `/` test keeps the provider // probe (`which`) off the ordinary send path. if ( + !input[automationStart] && !input.fromNotice && !input.fromInbound && String(prompt).trimStart().startsWith("/") @@ -2128,6 +2144,7 @@ function createRunner(opts) { (store.getSettings().maxConcurrentRuns != null && (!runSlotsFree() || runQueueOrder().length > 0))) ) { + if (automation) throw Object.assign(new Error("Automation destination is waiting for a run slot; run skipped"), { code: "AUTOMATION_DESTINATION_BUSY" }); fold.taken = null; return enqueueRun(threadId, input, prompt, attachments); } @@ -2154,6 +2171,13 @@ function createRunner(opts) { } } + // Only explicitly configured fixed fresh-session fires may reset context, + // after all admission and preflight gates have accepted the run. + if (automation?.sessionMode === "fresh") { + freshAgentContext({ threadId }); + thread = store.getThread(threadId); + } + // Prefix is CLI-only and must see the retained tail BEFORE this turn's // user message is appended (rewind replay would otherwise digest itself). const prefix = services.buildHandoffPrefix( @@ -3357,6 +3381,7 @@ function createRunner(opts) { queueMicrotask(admitQueuedRuns); return { + startAutomationRun, startRun, steerRun, startBtw, diff --git a/electron/services-settings.js b/electron/services-settings.js index f61243957..e07d3bfe6 100644 --- a/electron/services-settings.js +++ b/electron/services-settings.js @@ -377,7 +377,16 @@ function normalizeAutomationInput(store, input, existing) { scope = "private"; } + const destination = require("./automation-destination.js").destinationFields(src, base); + // A stale destination must never prevent pausing an automation. Inactive + // definitions can be repaired incrementally; enabling validates them again. + if ((!existing || enabled) && destination.sessionMode !== "new-thread") { + require("./automation-destination.js").destinationThread( + store, { ...destination, projectId, provider, model, tier }, null, true, + ); + } return { + ...destination, name, projectId, prompt, @@ -479,6 +488,7 @@ function sharedSlug(auto) { } const DEFINITION_KEYS = [ + "sessionMode", "destinationThreadId", "name", "prompt", "provider", @@ -498,6 +508,8 @@ const DEFINITION_KEYS = [ */ function definitionChanged(fields, existing) { return DEFINITION_KEYS.some((key) => { + if (key === "sessionMode") return (fields[key] || "new-thread") !== (existing[key] || "new-thread"); + if (key === "destinationThreadId") return (fields[key] || null) !== (existing[key] || null); if (key === "trigger" || key === "budget") { return JSON.stringify(fields[key]) !== JSON.stringify(existing[key] || null); } @@ -512,6 +524,8 @@ function definitionChanged(fields, existing) { function definitionRow(fields, slug) { return { id: slug, + sessionMode: fields.sessionMode, + destinationThreadId: fields.destinationThreadId, name: fields.name, prompt: fields.prompt, provider: fields.provider, @@ -570,6 +584,8 @@ function addAutomation(store, input) { id, projectId: project.id, scope: "project", + sessionMode: fields.sessionMode, + destinationThreadId: fields.destinationThreadId, name: fields.name, prompt: fields.prompt, provider: fields.provider, diff --git a/electron/test/automation-triggers.test.js b/electron/test/automation-triggers.test.js index 2bbf19389..4bd2636dc 100644 --- a/electron/test/automation-triggers.test.js +++ b/electron/test/automation-triggers.test.js @@ -280,6 +280,7 @@ describe("event-triggered automations", () => { return startScheduler({ store, runner: { + startAutomationRun: async (input) => { started.push(input); return { runId: "fixed" }; }, startRun: async (input) => { started.push(input); return { runId: "r" }; @@ -309,6 +310,25 @@ describe("event-triggered automations", () => { }); } + it("GitHub events continue the configured fixed destination without changing ownership", async () => { + const t = services.createThread(store, { projectId: "p1", provider: "claude", model: null }); + services.setProvider(store, { threadId: t.id, provider: "claude", model: null }); + addPrOpened({ sessionMode: "continue", destinationThreadId: t.id }); + const started = []; + const sched = scheduler(started); + try { + rows = [{ number: 10, title: "old", user: { login: "x" } }]; + await sched.tick(); + rows = [{ number: 11, title: "new", user: { login: "x" } }, ...rows]; + await sched.tick(); + assert.equal(started.length, 1); + assert.equal(started[0].threadId, t.id); + assert.match(started[0].prompt, /GitHub pr_opened/); + assert.equal(store.getThreads().length, 1); + assert.equal(store.getThread(t.id).automationId, null); + } finally { sched.stop(); } + }); + it("baselines on the first poll, then batches new PRs into one run", async () => { const auto = addPrOpened(); assert.equal(auto.trigger.event, "pr_opened"); diff --git a/electron/test/automations-file.test.js b/electron/test/automations-file.test.js index 5ac3656d4..cc97f8a57 100644 --- a/electron/test/automations-file.test.js +++ b/electron/test/automations-file.test.js @@ -326,6 +326,20 @@ describe("project-scope writes", () => { assert.equal(services.listAutomations(store)[0].memory, "notes"); }); + it("legacy shared overlay edits preserve exact file bytes, hash, and trust", () => { + const dir = tempRepo([ROW]); + const store = new Store(path.join(dir, "store.json")); + store.setProjects([{ id: "p1", name: "p", path: dir }]); + const before = fs.readFileSync(path.join(dir, "solenta.json"), "utf8"); + const hash = readAutomations(dir).hash; + services.trustFile(store, { projectId: "p1", hash }); + services.updateAutomation(store, { id: "shared:p1:pr-triage", enabled: true }); + services.updateAutomation(store, { id: "shared:p1:pr-triage", memory: "notes", enabled: false }); + assert.equal(fs.readFileSync(path.join(dir, "solenta.json"), "utf8"), before); + assert.equal(readAutomations(dir).hash, hash); + assert.equal(store.getProject("p1").automationsTrust, hash); + }); + 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")); diff --git a/electron/test/automations.test.js b/electron/test/automations.test.js index bbadb33ff..5050c6b35 100644 --- a/electron/test/automations.test.js +++ b/electron/test/automations.test.js @@ -202,6 +202,111 @@ describe("automation CRUD + scheduler", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + it("persists fixed destinations, continues identity, and starts fresh only when configured", async () => { + const target = services.createThread(store, { projectId: "p1", provider: "claude", model: null }); + services.setProvider(store, { threadId: target.id, provider: "claude", model: null }); + store.updateThread(target.id, { sessionId: "existing", budget: { turns: 2 } }); + const created = services.addAutomation(store, { projectId: "p1", name: "Fixed", prompt: "go", provider: "claude", model: null, preset: "hourly", sessionMode: "continue", destinationThreadId: target.id, budget: { turns: 10 } }); + let starts = 0, resets = 0; + const runner = { startRun: async ({ threadId }) => { assert.equal(threadId, target.id); starts++; return { runId: `run-${starts}` }; }, startAutomationRun: async (input) => { if (store.getAutomation(input.automationId).sessionMode === "fresh") resets++; return runner.startRun(input); } }; + await runNow({ store, runner }, created.id); + assert.equal(store.getThread(target.id).sessionId, "existing"); + assert.equal(store.getThread(target.id).automationId, null); + assert.equal(store.getThread(target.id).budget.turns, 2); + assert.equal(resets, 0); + services.updateAutomation(store, { id: created.id, sessionMode: "fresh" }); + await runNow({ store, runner }, created.id); + assert.equal(resets, 1); + assert.equal(starts, 2); + assert.equal(listAutomationRuns(store, created.id).runs.length, 2); + store.saveNow(); + const restored = new Store(path.join(tmpDir, "store.json")); + assert.equal(restored.getAutomation(created.id).destinationThreadId, target.id); + assert.equal(restored.getAutomation(created.id).sessionMode, "fresh"); + }); + + it("rejects missing, cross-project, busy and elevated destinations without creating another thread", async () => { + const t = services.createThread(store, { projectId: "p1", provider: "claude", model: null }); + services.setProvider(store, { threadId: t.id, provider: "claude", model: null }); + const input = { projectId: "p1", name: "Fixed", prompt: "go", provider: "claude", model: null, preset: "hourly", sessionMode: "continue", destinationThreadId: t.id }; + assert.throws(() => services.addAutomation(store, { ...input, destinationThreadId: "missing" }), /must exist/); + const a = services.addAutomation(store, input); + for (const patch of [{ status: "working" }, { status: "idle", pendingQuestion: {} }, { pendingQuestion: null, queued: "user draft" }, { queued: null, permissionMode: "bypassPermissions" }, { permissionMode: "default", alwaysAllowKeys: ["grant"] }, { alwaysAllowKeys: [], archived: true }]) { + store.updateThread(t.id, patch); + await assert.rejects(runNow({ store, runner: { startRun: () => assert.fail("must not start") } }, a.id), /destination/); + } + assert.equal(store.getThreads().length, 1); + }); + + it("can disable and edit inactive automations after their destination becomes invalid", () => { + for (const patch of [{ archived: true }, { trashedAt: Date.now() }, { provider: "codex" }, { model: "different" }, { permissionMode: "bypassPermissions" }, null]) { + const t = services.createThread(store, { projectId: "p1", provider: "claude", model: null }); + services.setProvider(store, { threadId: t.id, provider: "claude", model: null }); + const a = services.addAutomation(store, { projectId: "p1", name: "Fixed", prompt: "go", provider: "claude", model: null, preset: "hourly", sessionMode: "continue", destinationThreadId: t.id }); + if (patch) store.updateThread(t.id, patch); + else store.setThreads(store.getThreads().filter((row) => row.id !== t.id)); + assert.equal(services.updateAutomation(store, { id: a.id, enabled: false }).enabled, false); + assert.equal(services.updateAutomation(store, { id: a.id, name: "Paused" }).name, "Paused"); + assert.throws(() => services.updateAutomation(store, { id: a.id, enabled: true }), /destination/i); + assert.equal(store.getAutomation(a.id).enabled, false); + } + }); + + it("scheduled busy destinations skip without pausing and remain editable", async () => { + const t = services.createThread(store, { projectId: "p1", provider: "claude", model: null }); + services.setProvider(store, { threadId: t.id, provider: "claude", model: null }); + const a = services.addAutomation(store, { projectId: "p1", name: "Fixed", prompt: "go", provider: "claude", preset: "hourly", sessionMode: "continue", destinationThreadId: t.id }); + store.updateThread(t.id, { status: "working", queued: "user input" }); + services.updateAutomation(store, { id: a.id, name: "Renamed" }); + const scheduler = startScheduler({ store, runner: { startRun: () => assert.fail("must skip") }, intervalMs: 3600000 }); + try { + for (let i = 0; i < 4; i++) { + store.setAutomations(store.getAutomations().map((row) => ({ ...row, nextRunAt: 0 }))); + await scheduler.tick(); + } + const updated = store.getAutomation(a.id); + assert.equal(updated.enabled, true); + assert.equal(updated.consecutiveFailures, 0); + assert.match(updated.lastError, /skipped/); + assert.equal(store.getThread(t.id).queued, "user input"); + } finally { scheduler.stop(); } + }); + + it("mode changes retain legacy, fixed, and later new-thread run history independently", async () => { + const t = services.createThread(store, { projectId: "p1", provider: "claude", model: null }); + services.setProvider(store, { threadId: t.id, provider: "claude", model: null }); + const a = services.addAutomation(store, { projectId: "p1", name: "Modes", prompt: "go", provider: "claude", preset: "hourly" }); + let n = 0; + const start = async () => ({ runId: `run-${++n}` }); + const runner = { startRun: start, startAutomationRun: start }; + await runNow({ store, runner }, a.id); + services.updateAutomation(store, { id: a.id, sessionMode: "continue", destinationThreadId: t.id }); + await runNow({ store, runner }, a.id); + const fixed = store.getAutomation(a.id).runHistory[0]; + services.updateAutomation(store, { id: a.id, sessionMode: "new-thread" }); + await runNow({ store, runner }, a.id); + const latest = store.getAutomation(a.id).pendingRunThreadId; + store.updateThread(latest, { status: "failed" }); + require("../automations.js").settleAutomationRuns({ store }); + const runs = listAutomationRuns(store, a.id).runs; + assert.equal(runs.length, 3); + assert.equal(runs.find((r) => r.id === fixed.id).status, "idle"); + assert.equal(runs.find((r) => r.threadId === latest).status, "failed"); + assert.equal(new Set(runs.map((r) => r.id || r.threadId)).size, 3); + }); + + it("serializes concurrent manual fires at a fixed destination", async () => { + const t = services.createThread(store, { projectId: "p1", provider: "claude", model: null }); + services.setProvider(store, { threadId: t.id, provider: "claude", model: null }); + const a = services.addAutomation(store, { projectId: "p1", name: "Fixed", prompt: "go", provider: "claude", preset: "hourly", sessionMode: "continue", destinationThreadId: t.id }); + let release; + const runner = { startAutomationRun: () => new Promise((r) => { release = r; }) }; + const first = runNow({ store, runner }, a.id); + await assert.rejects(runNow({ store, runner }, a.id), /already starting/); + release({ runId: "one" }); + await first; + }); + it("add/list/update/remove persist", () => { const created = services.addAutomation(store, { projectId: "p1", diff --git a/electron/test/pr-branch-delete.test.js b/electron/test/pr-branch-delete.test.js index e51719500..332f66593 100644 --- a/electron/test/pr-branch-delete.test.js +++ b/electron/test/pr-branch-delete.test.js @@ -38,9 +38,6 @@ const fs = require("fs"); const statePath = process.env.CODER_FAKE_GH_STATE; const args = process.argv.slice(2); const state = JSON.parse(fs.readFileSync(statePath, "utf8")); -state.calls = state.calls || []; -state.calls.push(args.slice()); -fs.writeFileSync(statePath, JSON.stringify(state, null, 2)); function findPr(key) { return (state.prs || []).find( @@ -121,7 +118,7 @@ describe("maybeDeleteHeadBranch over gh (#1763)", () => { statePath = path.join(tmp, "gh-state.json"); fs.writeFileSync( statePath, - JSON.stringify({ prs: [{ number: 12, branch: "feat", state: "MERGED" }], calls: [] }), + JSON.stringify({ prs: [{ number: 12, branch: "feat", state: "MERGED" }] }), ); prevGh = process.env.CODER_GH_BIN; prevState = process.env.CODER_FAKE_GH_STATE; @@ -226,7 +223,7 @@ describe("closePr / mergePrAt delete the branch (#1763)", () => { statePath = path.join(tmp, "gh-state.json"); fs.writeFileSync( statePath, - JSON.stringify({ prs: [{ number: 12, branch: "feat", state: "OPEN" }], calls: [] }), + JSON.stringify({ prs: [{ number: 12, branch: "feat", state: "OPEN" }] }), ); prevGh = process.env.CODER_GH_BIN; prevState = process.env.CODER_FAKE_GH_STATE; diff --git a/electron/test/pr-completions.test.js b/electron/test/pr-completions.test.js new file mode 100644 index 000000000..b02e1a02c --- /dev/null +++ b/electron/test/pr-completions.test.js @@ -0,0 +1,165 @@ +"use strict"; +const { it, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +let spawn; +function setExecFile(fn) { spawn = fn; } +function setGithubApi() {} +const run = (cwd, args, bin) => new Promise(resolve => spawn(bin, args, { cwd }, (err, stdout, stderr) => resolve({ ok: !err, stdout, stderr }))); +const moduleStub = { exports: {} }; +vm.runInNewContext(fs.readFileSync(require.resolve('../prCompletions.js'), 'utf8'), { module: moduleStub, URL, require(name) { + if (name === './worktrees.js') return { gitTryAsync: (cwd, args) => run(cwd, args, 'git'), ghApiTryAsync: (cwd, args) => run(cwd, args, 'gh'), tailErr: (text, fallback) => text || fallback }; + if (name === './gitlab.js') return { gitlabRemote: require('../gitlab.js').gitlabRemote, glabApi: async (cwd, remote, method, endpoint) => { + const r = await run(cwd, ['api', '--hostname', remote.host, '-X', method, `projects/${encodeURIComponent(remote.path)}${endpoint}`], 'glab'); + return r.ok ? { ok: true, data: JSON.parse(r.stdout) } : { ok: false, reason: r.stderr }; + } }; + return require(name === './prLinks.js' ? '../prLinks.js' : name); +} }); +const { prCompletions } = moduleStub.exports; +afterEach(() => { spawn = null; }); +it('keeps GitHub reference search bounded to the selected repository and origin host', async () => { + const calls = []; + setGithubApi(false); + setExecFile((bin, args, opts, cb) => { + calls.push({ bin, args, cwd: opts.cwd }); + cb(null, args[0] === 'remote' ? 'git@github.com:owner/private.git\n' : JSON.stringify({ items: [{ number: 12, title: 'Fix' }] }), ''); + }); + const result = await prCompletions({ projectPath: '/repo', repo: 'owner/selected', kind: '#', query: '12' }); + assert.equal(result.ok, true); + assert.deepEqual(JSON.parse(JSON.stringify(result.items)), [{ value: '#12', label: 'Fix' }]); + const search = calls.find(c => c.args[0] === 'api'); + assert.match(decodeURIComponent(search.args[1]), /repo:owner\/selected 12/); + assert.deepEqual(Array.from(search.args.slice(-2)), ['--hostname', 'github.com']); + assert.match(search.args[1], /per_page=20&page=1/); +}); +it('uses GitLab project members and MR iid notation on the original host', async () => { + const calls = []; + setExecFile((bin, args, opts, cb) => { + calls.push(args); + cb(null, args[0] === 'remote' ? 'git@gitlab.example:team/private.git\n' : JSON.stringify([{ iid: 7, title: 'Patch' }]), ''); + }); + const result = await prCompletions({ projectPath: '/repo', kind: '!', query: '' }); + assert.deepEqual(JSON.parse(JSON.stringify(result.items)), [{ value: '!7', label: 'Patch' }]); + assert.deepEqual(calls[1].slice(0, 5), ['api', '--hostname', 'gitlab.example', '-X', 'GET']); + assert.match(calls[1][5], /^projects\/team%2Fprivate\/merge_requests\?/); +}); +it('never falls back to global user search and reports denied access', async () => { + setGithubApi(false); + setExecFile((bin, args, opts, cb) => { + if (args[0] === 'remote') return cb(null, 'git@github.com:owner/private.git\n', ''); + assert.match(args[1], /^repos\/owner\/private\/assignees\?/); + cb(new Error('denied'), '', 'HTTP 403: permission denied'); + }); + assert.equal((await prCompletions({ projectPath: '/repo', kind: '@', query: '' })).ok, false); +}); + +it('includes repository assignees who have never contributed', async () => { + setExecFile((bin, args, opts, cb) => { + if (args[0] === 'remote') return cb(null, 'git@github.com:owner/private.git', ''); + assert.match(args[1], /\/assignees\?/); + cb(null, JSON.stringify([{ login: 'maintainer', name: 'Review maintainer' }]), ''); + }); + const result = await prCompletions({ projectPath: '/repo', kind: '@', query: 'maint' }); + assert.equal(result.items[0].value, '@maintainer'); +}); + +it('finds numeric GitHub references independently of title text and deduplicates them', async () => { + const calls = []; + setExecFile((bin, args, opts, cb) => { + calls.push(args); + if (args[0] === 'remote') return cb(null, 'git@github.com:owner/private.git', ''); + cb(null, JSON.stringify(args[1].endsWith('/issues/42') + ? { number: 42, title: 'Unrelated title' } + : { items: [{ number: 142, title: 'Text 42 match' }] }), ''); + }); + const result = await prCompletions({ projectPath: '/repo', kind: '#', query: '42' }); + assert.equal(result.items[0].value, '#42'); + assert.equal(result.items[0].label, 'Unrelated title'); + assert.equal(result.items[1].value, '#142'); + assert.ok(calls.some(args => args[1] === 'repos/owner/private/issues/42')); + calls.length = 0; + await prCompletions({ projectPath: '/repo', kind: '#', query: '42', page: 2 }); + assert.equal(calls.some(args => args[1]?.endsWith('/issues/42')), false); +}); + +it('finds numeric GitLab issues and merge requests even without text search matches', async () => { + for (const kind of ['#', '!']) { + const calls = []; + setExecFile((bin, args, opts, cb) => { + calls.push(args); + if (args[0] === 'remote') return cb(null, 'git@gitlab.example:team/private.git', ''); + cb(null, JSON.stringify(args[5].endsWith('/42') ? { iid: 42, title: 'Unrelated title' } : []), ''); + }); + const result = await prCompletions({ projectPath: '/repo', kind, query: '42' }); + assert.equal(result.items[0].value, `${kind}42`); + assert.equal(result.items[0].label, 'Unrelated title'); + assert.equal(result.hasMore, false); + assert.ok(calls.some(args => args[5] === `projects/team%2Fprivate/${kind === '#' ? 'issues' : 'merge_requests'}/42`)); + calls.length = 0; + await prCompletions({ projectPath: '/repo', kind, query: '42', page: 2 }); + assert.equal(calls.some(args => args[5]?.endsWith('/42')), false); + } +}); + +it('preserves a full search page beside an exact hit and tolerates unavailable exact references', async () => { + let missing = false; + setExecFile((bin, args, opts, cb) => { + if (args[0] === 'remote') return cb(null, 'git@github.com:owner/private.git', ''); + if (args[1].endsWith('/issues/42')) return missing + ? cb(new Error('missing'), '', '404 not found') + : cb(null, JSON.stringify({ number: 42, title: 'Unrelated title' }), ''); + cb(null, JSON.stringify({ items: Array.from({ length: 20 }, (_, i) => ({ number: 420 + i, title: 'Matching 42' })) }), ''); + }); + const result = await prCompletions({ projectPath: '/repo', kind: '#', query: '42' }); + assert.equal(result.items.length, 21); + assert.equal(result.hasMore, true); + assert.equal(result.items.at(-1).value, '#439'); + missing = true; + const fallback = await prCompletions({ projectPath: '/repo', kind: '#', query: '42' }); + assert.equal(fallback.items.length, 20); + assert.equal(fallback.hasMore, true); +}); + +it('deduplicates an exact search hit and keeps exact lookup available when search fails', async () => { + let searchFails = false; + setExecFile((bin, args, opts, cb) => { + if (args[0] === 'remote') return cb(null, 'git@github.com:owner/private.git', ''); + const issue = { number: 42, title: 'Unrelated title' }; + if (args[1].endsWith('/issues/42')) return cb(null, JSON.stringify(issue), ''); + if (searchFails) return cb(new Error('unavailable'), '', '503 search unavailable'); + cb(null, JSON.stringify({ items: [issue] }), ''); + }); + for (const fail of [false, true]) { + searchFails = fail; + const result = await prCompletions({ projectPath: '/repo', kind: '#', query: '42' }); + assert.equal(result.items.length, 1); + assert.equal(result.items[0].value, '#42'); + assert.equal(result.hasMore, false); + } +}); + +it('rejects query qualifiers, whitespace, quoting and non-token Unicode before any repository or API call', async () => { + let calls = 0; + setExecFile(() => { calls++; throw new Error('must not reach network'); }); + for (const query of ['repo:other/private secret', 'repo:other', 'hello world', 'hello\tworld', 'hello\nworld', '"secret"', "'secret'", 'bob@example.com', 'café', '用户', 'x'.repeat(101), { query: 'escape' }]) { + const result = await prCompletions({ projectPath: '/repo', kind: '#', query }); + assert.equal(result.ok, false, String(query)); + assert.equal(result.reason, 'Invalid completion query'); + } + assert.equal(calls, 0); +}); + +it('accepts the same bounded ASCII token grammar as the textarea, including empty query', async () => { + const queries = []; + setExecFile((bin, args, opts, cb) => { + if (args[0] === 'remote') return cb(null, 'git@github.com:owner/private.git', ''); + queries.push(args[1]); + cb(null, JSON.stringify({ items: [] }), ''); + }); + for (const query of ['', 'maintainer-name', 'feature.name', 'under_score', 'Release123', 'x'.repeat(100)]) { + assert.equal((await prCompletions({ projectPath: '/repo', kind: '#', query })).ok, true); + } + assert.equal(queries.length, 6); + for (const endpoint of queries) assert.ok(decodeURIComponent(endpoint).includes('q=repo:owner/private ')); +}); diff --git a/electron/test/pr-gitlab-workspace.test.js b/electron/test/pr-gitlab-workspace.test.js new file mode 100644 index 000000000..f88164426 --- /dev/null +++ b/electron/test/pr-gitlab-workspace.test.js @@ -0,0 +1,34 @@ +"use strict"; +const { it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); +const source = require.resolve('../prWorkspace.js'); +const localRequire = createRequire(source); +it('loads and edits GitLab descriptions and comments on the origin host/project', async () => { + const calls = []; + const mr = { iid: 7, web_url: 'https://gitlab.example/team/private/-/merge_requests/7', title: 'MR', description: '@alice !7', source_branch: 'feature', state: 'opened' }; + const mod = { exports: {} }; + vm.runInNewContext(fs.readFileSync(source, 'utf8'), { module: mod, require(name) { + if (name === './worktrees.js') return { ...localRequire(name), gitTryAsync: async () => ({ ok: true, stdout: 'git@gitlab.example:team/private.git' }) }; + if (name === './gitlab.js') return { ...localRequire(name), glabApi: async (cwd, remote, method, endpoint, fields) => { + calls.push({ cwd, remote, method, endpoint, fields }); + if (method === 'PUT') Object.assign(mr, fields); + return { ok: true, data: endpoint.includes('/notes') ? [] : mr }; + } }; + return localRequire(name); + } }); + const detail = await mod.exports.viewPrDetail('/repo', 7); + assert.equal(detail.pr.forge, 'gitlab'); + assert.equal(detail.pr.body, '@alice !7'); + const edited = await mod.exports.editPr('/repo', { prNumber: 7, body: '@bob #2 !8' }); + assert.equal(edited.pr.body, '@bob #2 !8'); + assert.equal((await mod.exports.commentPr('/repo', { prNumber: 7, body: '@bob !7' })).ok, true); + assert.equal(calls.find(c => c.method === 'POST').fields.body, '@bob !7'); + for (const call of calls) { + assert.equal(call.remote.host, 'gitlab.example'); + assert.equal(call.remote.path, 'team/private'); + assert.equal(call.cwd, '/repo'); + } +}); diff --git a/electron/test/provider-switch-context.test.js b/electron/test/provider-switch-context.test.js index 50c30e896..b6683c43b 100644 --- a/electron/test/provider-switch-context.test.js +++ b/electron/test/provider-switch-context.test.js @@ -130,6 +130,96 @@ describe("provider switch keeps context (#1493)", () => { return store.getThread(thread.id); } + it("fresh automation preflight failures retain the human session and context", async () => { + const thread = threadWithHistory(); + const automation = services.addAutomation(store, { projectId: project.id, name: "Fresh", prompt: "go", provider: thread.provider, model: thread.model, preset: "hourly", sessionMode: "fresh", destinationThreadId: thread.id }); + const { runNow } = require("../automations.js"); + process.env.CODER_AGENT_CMD = agentCmd(logFile); + const before = store.getMessages(thread.id).slice(); + store.updateThread(thread.id, { budget: { usd: 1 } }); + store.setUsage(thread.id, { costUsd: 2, inputTokens: 5, turns: 1 }); + await assert.rejects(runNow({ store, runner }, automation.id), /Thread budget reached/); + store.updateThread(thread.id, { budget: null }); + store.setSettings({ dailyBudgetUsd: 1 }); + const spendToday = store.getSpendToday; + store.getSpendToday = () => 2; + await assert.rejects(runNow({ store, runner }, automation.id), /Daily budget reached/); + store.getSpendToday = spendToday; + store.setSettings({ dailyBudgetUsd: null }); + delete process.env.CODER_AGENT_CMD; + const binEnv = getProvider(thread.provider).binEnv; + const priorBin = process.env[binEnv]; + process.env[binEnv] = path.join(tmpDir, "missing-provider"); + try { + await assert.rejects(runNow({ store, runner }, automation.id), /Provider binary not found/); + } finally { + if (priorBin === undefined) delete process.env[binEnv]; else process.env[binEnv] = priorBin; + } + assert.equal(store.getThread(thread.id).sessionId, "sess-claude"); + assert.equal(store.getThread(thread.id).contextBoundaryId, undefined); + assert.deepEqual(store.getMessages(thread.id), before); + assert.equal(store.getUsage(thread.id).costUsd, 2); + }); + + it("project-scoped fixed fires preserve per-run history and pending IDs across presentation and reload", async () => { + const thread = threadWithHistory(); + process.env.CODER_AGENT_CMD = agentCmd(logFile); + const automation = services.addAutomation(store, { projectId: project.id, scope: "project", name: "Shared review", prompt: "review", provider: thread.provider, model: thread.model, preset: "hourly", sessionMode: "continue", destinationThreadId: thread.id }); + const { runNow, listAutomationRuns, settleAutomationRuns } = require("../automations.js"); + await runNow({ store, runner }, automation.id); + const presented = services.listAutomations(store).find((a) => a.id === automation.id); + assert.ok(presented.pendingRunId); + assert.equal(presented.runHistory.length, 1); + const first = listAutomationRuns(store, automation.id).runs[0]; + assert.equal(first.threadId, thread.id); + assert.equal(first.runId, presented.pendingRunId); + assert.equal(first.pending, true); + store.saveNow(); + const restored = new Store(path.join(tmpDir, "store.json")); + assert.equal(services.listAutomations(restored).find((a) => a.id === automation.id).pendingRunId, first.runId); + assert.equal(listAutomationRuns(restored, automation.id).runs[0].id, first.id); + await waitFor(() => !runner.isRunning(thread.id)); + settleAutomationRuns({ store }); + await runNow({ store, runner }, automation.id); + await waitFor(() => !runner.isRunning(thread.id)); + settleAutomationRuns({ store }); + const runs = listAutomationRuns(store, automation.id).runs; + assert.equal(runs.length, 2); + assert.notEqual(runs[0].id, runs[1].id); + assert.ok(runs.every((r) => !r.pending)); + const definition = JSON.parse(fs.readFileSync(path.join(project.path, "solenta.json"), "utf8")).automations[0]; + assert.equal(definition.runHistory, undefined); + assert.equal(definition.pendingRunId, undefined); + store.saveNow(); + assert.deepEqual(listAutomationRuns(new Store(path.join(tmpDir, "store.json")), automation.id).runs, runs); + }); + + it("fixed automation continuation preserves context while explicit fresh mode excludes old history", async () => { + const thread = threadWithHistory(); + process.env.CODER_AGENT_CMD = agentCmd(logFile); + const automation = services.addAutomation(store, { + projectId: project.id, name: "Review", prompt: "AUTOMATION REQUEST", provider: thread.provider, + model: thread.model, preset: "hourly", sessionMode: "continue", destinationThreadId: thread.id, + }); + const { runNow, settleAutomationRuns, listAutomationRuns } = require("../automations.js"); + await runNow({ store, runner }, automation.id); + await waitFor(() => !runner.isRunning(thread.id)); + assert.equal(store.getThread(thread.id).contextBoundaryId, undefined); + assert.equal(store.getThread(thread.id).sessionId, "sess-claude"); + settleAutomationRuns({ store }); + services.updateAutomation(store, { id: automation.id, sessionMode: "fresh" }); + await runNow({ store, runner }, automation.id); + await waitFor(() => !runner.isRunning(thread.id)); + settleAutomationRuns({ store }); + assert.ok(store.getThread(thread.id).contextBoundaryId); + assert.equal(store.getThread(thread.id).freshContextPending, false); + assert.ok(store.getMessages(thread.id).some((m) => m.text === "PARSER HALF DONE")); + assert.ok(!store.getContextMessages(thread.id).some((m) => m.text === "PARSER HALF DONE")); + assert.equal(store.getThreads().length, 1); + assert.equal(listAutomationRuns(store, automation.id).runs.length, 2); + assert.notEqual(listAutomationRuns(store, automation.id).runs[0].id, listAutomationRuns(store, automation.id).runs[1].id); + }); + it("fresh context retains visible history and identity across reload without old handoff", async () => { const thread = threadWithHistory(); store.updateThread(thread.id, { handoffFrom: "old-source", replayContext: true, diff --git a/electron/test/run-queue.test.js b/electron/test/run-queue.test.js index 3be9416ef..9da56f099 100644 --- a/electron/test/run-queue.test.js +++ b/electron/test/run-queue.test.js @@ -53,6 +53,23 @@ describe("run queue (maxConcurrentRuns)", () => { else process.env.CODER_SIMULATE = prevSimulate; }); + it("a fixed fresh automation skips a full run slot without resetting or queuing its destination", async () => { + const [a, b] = ids; + const target = store.getThread(b); + store.updateThread(b, { sessionId: "human-session" }); + store.appendMessage(b, { id: "human", role: "assistant", text: "HUMAN CONTEXT", createdAt: 1 }); + const automation = services.addAutomation(store, { projectId: target.projectId, name: "Fresh", prompt: "go", provider: target.provider, model: target.model, preset: "hourly", sessionMode: "fresh", destinationThreadId: b }); + await runner.startRun({ threadId: a, prompt: "occupy slot" }); + const before = store.getMessages(b).slice(); + await assert.rejects(require("../automations.js").runNow({ store, runner }, automation.id), /run slot.*skipped/); + assert.equal(store.getThread(b).sessionId, "human-session"); + assert.equal(store.getThread(b).contextBoundaryId, undefined); + assert.equal(store.getThread(b).runQueue, undefined); + assert.deepEqual(store.getMessages(b), before); + assert.equal(store.getAutomation(automation.id).pendingRunThreadId, null); + assert.equal(store.getAutomation(automation.id).consecutiveFailures, 0); + }); + it("defaults to 4 and validates", () => { assert.equal(new Store(path.join(tmpDir, "x.json")).getSettings().maxConcurrentRuns, 4); assert.throws(() => store.setSettings({ maxConcurrentRuns: 0 })); diff --git a/electron/webScopes.js b/electron/webScopes.js index 6da8d6c8c..9c36e92a1 100644 --- a/electron/webScopes.js +++ b/electron/webScopes.js @@ -98,6 +98,7 @@ const BY_SCOPE = { "git:mergeOptions", "git:listPrs", "git:prTemplate", + "git:prCompletions", "git:prDetail", "git:prDiff", "git:prViewed", diff --git a/package-lock.json b/package-lock.json index 33904ed23..7bd56b4fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,9 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "ws": "8.21.3", - "yauzl": "^3.4.0" + "yauzl": "^3.4.0", + "unified": "^11.0.5", + "remark-parse": "^11.0.0" }, "devDependencies": { "@types/react": "^19.1.2", diff --git a/package.json b/package.json index 70d00dccd..5493b2377 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "ws": "8.21.3", - "yauzl": "^3.4.0" + "yauzl": "^3.4.0", + "unified": "^11.0.5", + "remark-parse": "^11.0.0" }, "devDependencies": { "@types/react": "^19.1.2", diff --git a/scripts/test-electron.js b/scripts/test-electron.js index e2bddcd0c..fc8540b45 100644 --- a/scripts/test-electron.js +++ b/scripts/test-electron.js @@ -80,6 +80,8 @@ const WIN32_FILES = [ "electron/test/reasoning-effort.test.js", "electron/test/ultracode.test.js", "electron/test/stop-on-settle.test.js", + "electron/test/pr-completions.test.js", + "electron/test/pr-gitlab-workspace.test.js", "electron/test/rewind.test.js", "electron/test/session-record.test.js", "electron/test/secrets.test.js", diff --git a/site-content/docs.html b/site-content/docs.html index 92657da2e..da81478e3 100644 --- a/site-content/docs.html +++ b/site-content/docs.html @@ -1124,6 +1124,20 @@

Automations

+ Session mode defaults to a new thread for every run. + Choose Continue a fixed thread to keep its provider conversation, + or Fixed thread, fresh agent context to start each run with clean + agent context while keeping visible history, workspace and accumulated usage. + Select a destination in the same project with the same provider and model, + default permissions, and no account override, additional grants or routing. + Busy destinations and unresolved input are skipped without pausing the automation. + Automation budgets can tighten a fixed thread's limits and count its existing + lifetime spend, tokens and turns; fresh context does not reset these counters. + Project definitions persist sessionMode (new-thread, + continue or fresh) and destinationThreadId. + A destination missing on another machine fails visibly rather than creating a thread. +

+

Use Edit to change an automation in place. Its recent runs link to the corresponding transcripts, including live and quota-wait runs. Changing a name, prompt or model keeps the schedule's diff --git a/site/docs/automations/index.html b/site/docs/automations/index.html index 92a7481aa..5abd5eb43 100644 --- a/site/docs/automations/index.html +++ b/site/docs/automations/index.html @@ -102,6 +102,20 @@ into a Build workflow when the shape of it is worth keeping.

+

+ Session mode defaults to a new thread for every run. + Choose Continue a fixed thread to keep its provider conversation, + or Fixed thread, fresh agent context to start each run with clean + agent context while keeping visible history, workspace and accumulated usage. + Select a destination in the same project with the same provider and model, + default permissions, and no account override, additional grants or routing. + Busy destinations and unresolved input are skipped without pausing the automation. + Automation budgets can tighten a fixed thread's limits and count its existing + lifetime spend, tokens and turns; fresh context does not reset these counters. + Project definitions persist sessionMode (new-thread, + continue or fresh) and destinationThreadId. + A destination missing on another machine fails visibly rather than creating a thread. +

Use Edit to change an automation in place. Its recent runs link to the corresponding transcripts, including live and diff --git a/site/llms-full.txt b/site/llms-full.txt index 370cd018a..9b03b55bc 100644 --- a/site/llms-full.txt +++ b/site/llms-full.txt @@ -363,6 +363,8 @@ Automations run a prompt on a schedule: hourly, daily at an hour, or weekly. Eac A finished thread can be **repeated** rather than retyped: put its prompt on a schedule as a new automation, or distill the run into a [Build workflow](https://solenta.app/docs/workflows) when the shape of it is worth keeping. +**Session mode** defaults to a new thread for every run. Choose **Continue a fixed thread** to keep its provider conversation, or **Fixed thread, fresh agent context** to start each run with clean agent context while keeping visible history, workspace and accumulated usage. Select a destination in the same project with the same provider and model, default permissions, and no account override, additional grants or routing. Busy destinations and unresolved input are skipped without pausing the automation. Automation budgets can tighten a fixed thread's limits and count its existing lifetime spend, tokens and turns; fresh context does not reset these counters. Project definitions persist `sessionMode` (`new-thread`, `continue` or `fresh`) and `destinationThreadId`. A destination missing on another machine fails visibly rather than creating a thread. + Use **Edit** to change an automation in place. Its recent runs link to the corresponding transcripts, including live and quota-wait runs. Changing a name, prompt or model keeps the schedule's identity; changing the schedule recalculates its next run. History retention removes completed old runs, preserving unfinished work. You can also describe an automation in a thread. The agent creates it and a card links to it with **Open in Automations**. diff --git a/site/sitemap.xml b/site/sitemap.xml index 6c9e66fff..45d76d819 100644 --- a/site/sitemap.xml +++ b/site/sitemap.xml @@ -6,7 +6,7 @@ https://solenta.app/docs/analytics2026-11-02 https://solenta.app/docs/architecture2026-11-02 https://solenta.app/docs/ask2026-11-02 - https://solenta.app/docs/automations2026-11-02 + https://solenta.app/docs/automations2026-11-07 https://solenta.app/docs/cli-sessions2026-11-02 https://solenta.app/docs/codex2026-11-02 https://solenta.app/docs/dev-servers2026-11-02 diff --git a/site/solenta.schema.json b/site/solenta.schema.json index a33cb1ce0..b67fe997d 100644 --- a/site/solenta.schema.json +++ b/site/solenta.schema.json @@ -72,6 +72,8 @@ "additionalProperties": false, "required": ["id", "name", "prompt", "provider", "preset"], "properties": { + "sessionMode": { "enum": ["new-thread", "continue", "fresh"] }, + "destinationThreadId": { "type": ["string", "null"] }, "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,47}$" }, "name": { "type": "string", "minLength": 1 }, "prompt": { "type": "string", "minLength": 1, "maxLength": 4000 }, diff --git a/src/components/AutomationsView.tsx b/src/components/AutomationsView.tsx index 16618e402..a83546ac6 100644 --- a/src/components/AutomationsView.tsx +++ b/src/components/AutomationsView.tsx @@ -48,7 +48,7 @@ export interface AutomationsViewProps { * thread when a retained id has since been deleted. Omit while the * list is still loading — treat runs as openable. */ - liveThreads?: Array<{ id: string; status: ThreadStatus }>; + liveThreads?: Array<{ id: string; status: ThreadStatus; projectId?: string; title?: string; provider?: string; model?: string | null; archived?: boolean; permissionMode?: string }>; /** Open this row in the form (automation_create card, #1599). */ focusId?: string | null; onCreate: (input: AutomationWrite) => Promise | void; @@ -63,6 +63,8 @@ export interface AutomationsViewProps { /** Same keys `updateAutomation` uses to decide whether to rewrite solenta.json. */ function definitionChanged( fields: { + sessionMode?: "new-thread" | "continue" | "fresh"; + destinationThreadId?: string | null; name: string; prompt: string; provider: string; @@ -78,6 +80,8 @@ function definitionChanged( existing: AutomationInfo, ): boolean { return ( + (fields.sessionMode || "new-thread") !== (existing.sessionMode || "new-thread") || + (fields.destinationThreadId || null) !== (existing.destinationThreadId || null) || fields.name !== existing.name || fields.prompt !== existing.prompt || fields.provider !== existing.provider || @@ -104,7 +108,7 @@ function liveThreadKey( threads: AutomationsViewProps["liveThreads"], ): string { if (!threads) return ""; - return threads.map((t) => t.id).join("\n"); + return threads.map((t) => `${t.id}:${t.status}`).join("\n"); } function AutomationRunHistory({ @@ -117,7 +121,7 @@ function AutomationRunHistory({ automation: AutomationInfo; loadRuns: (id: string) => Promise; onSelectThread?: (id: string) => void; - liveThreads?: Array<{ id: string; status: ThreadStatus }>; + liveThreads?: Array<{ id: string; status: ThreadStatus; projectId?: string; title?: string; provider?: string; model?: string | null; archived?: boolean; permissionMode?: string }>; now: number; }) { const [result, setResult] = useState(null); @@ -145,7 +149,7 @@ function AutomationRunHistory({ return () => { cancelled = true; }; - }, [automation.id, idsKey]); + }, [automation.id, automation.lastRunAt, automation.pendingRunThreadId, idsKey]); const liveById = useMemo(() => { const map = new Map(); @@ -156,7 +160,7 @@ function AutomationRunHistory({ const resolved = (result?.runs ?? []).map((run) => { const missing = liveLoaded && !liveById.has(run.threadId); - const status = liveById.get(run.threadId) ?? run.status; + const status = !run.id || run.pending ? (liveById.get(run.threadId) ?? run.status) : run.status; return { ...run, status, missing }; }); const latest = resolved[0] ?? null; @@ -224,7 +228,7 @@ function AutomationRunHistory({ {expanded ? (