diff --git a/src/connectors/feishu/manifest.ts b/src/connectors/feishu/manifest.ts
index 067b9ab..3f9f1c1 100644
--- a/src/connectors/feishu/manifest.ts
+++ b/src/connectors/feishu/manifest.ts
@@ -1,19 +1,11 @@
import { defineManifest } from "../define.ts";
import type { SettingField } from "../types.ts";
-const folder: SettingField = {
- key: "folder",
- kind: "text",
- label: "Folder to work in",
- help: "An absolute path to a checkout on this host. The agent runs here, so it can read the code you are asking about.",
- placeholder: "/Users/you/code/your-project",
-};
-
const chat: SettingField = {
key: "chat",
kind: "text",
label: "Chat ID",
- help: "Leave empty to reply to the message that asked. Set a chat ID (oc_…) when the work started somewhere else.",
+ help: "Empty replies where it was asked. Or a chat ID (oc_…).",
placeholder: "oc_...",
};
@@ -64,28 +56,32 @@ export const feishuManifest = defineManifest({
},
],
},
- workflows: [
+ triggers: [
{
id: "feishu.ask",
- name: "Do what I ask the bot",
- summary:
- "Runs your agent when someone @mentions the bot in a group it is in, and answers in the thread.",
- trigger: "the bot is @mentioned in a group it is in",
- writes: "a reply in the Feishu thread",
+ when: "The Feishu / Lark bot is mentioned",
+ trigger: "someone @mentions the Feishu / Lark bot in a group it is in",
runsIn: "folder",
- settings: [folder],
- prompt: ASK_PROMPT,
- guidancePlaceholder:
- "Anything the agent should know about this project before it starts. For example: run pnpm test before saying a change works.",
+ settings: [],
answer: "text",
actionId: "feishu.reply",
pollEveryMs: 60_000,
},
],
+ workflows: [
+ {
+ id: "feishu.ask",
+ name: "Do what I ask the Feishu / Lark bot",
+ summary:
+ "Runs the agent when someone @mentions the Feishu / Lark bot in a group, and answers in the thread.",
+ triggerId: "feishu.ask",
+ prompt: ASK_PROMPT,
+ },
+ ],
actions: [
{
id: "feishu.reply",
- name: "Reply in Feishu",
+ name: "Reply in Feishu / Lark",
summary: "Post a message back to the thread that asked, or to a chat you name.",
target: [chat],
accepts: ["text"],
diff --git a/src/connectors/github/manifest.ts b/src/connectors/github/manifest.ts
index 0473a33..0eaec50 100644
--- a/src/connectors/github/manifest.ts
+++ b/src/connectors/github/manifest.ts
@@ -7,7 +7,7 @@ const repositories: SettingField = {
key: "repositories",
kind: "string_list",
label: "Repositories",
- help: "owner/name, one per line. Wildcards work: acme/* or */*. Leave empty for every repository you can see.",
+ help: "owner/name, one per line. acme/* works. Empty means all.",
placeholder: "acme/web",
};
@@ -22,7 +22,7 @@ const ignoreBots: SettingField = {
key: "ignoreBots",
kind: "boolean",
label: "Skip pull requests opened by bots",
- help: "Dependabot and friends open a great many, and each one costs a full run.",
+ help: "Such as Dependabot.",
default: true,
};
@@ -52,7 +52,7 @@ const issueTarget: SettingField = {
key: "issue",
kind: "text",
label: "Issue or pull request",
- help: "A GitHub URL. Leave empty to write back to whatever triggered the loop.",
+ help: "Empty replies where it was asked. Or a GitHub issue URL.",
placeholder: "https://github.com/acme/web/issues/12",
};
@@ -76,37 +76,22 @@ const REVIEW_PROMPT = [
"guessing.",
].join("\n");
-const PLAN_PROMPT = [
- "Write a short implementation plan for this issue.",
+const REPLY_PROMPT = [
+ "Answer this issue in a comment, the way a teammate who knows this codebase would.",
"",
- "Say what should change, in which files where you can tell, and what to watch",
- "out for. Stay inside what the issue actually supports: if it is too vague to",
- "plan, say what is missing rather than inventing the requirements.",
+ "Work out what the issue is asking for. If it is a question, answer it from the",
+ "code rather than from memory. If it reports a bug, find the likely cause and",
+ "point at the files and lines involved. If it asks for a change, say how you",
+ "would make it, where, and what to watch out for.",
"",
- "Do not write the implementation.",
-].join("\n");
-
-const IMPLEMENT_PROMPT = [
- "Implement this issue in the checkout you are in.",
- "",
- "Read enough of the surrounding code first that what you write looks like it",
- "belongs: the same patterns, the same names, the same way of handling errors.",
- "Where the project has tests for work like this, add one.",
- "",
- "Stay inside what the issue asks for. A change that also tidies three other",
- "things is a change nobody can review. If the issue is too vague to implement,",
- "or doing it properly needs a decision that is not yours to make, reply with",
- "exactly NOTHING_TO_DO rather than guessing: an unwanted pull request costs",
- "more attention than a missing one.",
- "",
- "Run the project's tests if you can work out how, and say in your description",
- "whether you did and what happened.",
+ "Read the code; do not change it. If the issue is too vague to answer, say what",
+ "is missing rather than inventing it.",
].join("\n");
export const githubManifest = defineManifest({
id: "github",
name: "GitHub",
- tagline: "Pick up review requests and assigned issues.",
+ tagline: "Pick up reviews and issues sent to your team's shared GitHub account.",
docsUrl: "https://docs.github.com/rest",
icon: "Github",
accent: "bg-neutral-900 text-white",
@@ -114,60 +99,55 @@ export const githubManifest = defineManifest({
kind: "oauth_redirect",
scopes: GITHUB_SCOPES,
needsAppRegistration: true,
+ // The Connection is the team's agent on GitHub: whatever it writes appears
+ // under its name, and the work it picks up is whatever the team sends to
+ // it. An account made for this is cleanest, but a teammate's own works too
+ // if they are happy for it to be shared.
+ note: "Connect the account the team will send work to. An account made for this is cleanest, but your own works too if you are happy to share it. Reviews and comments appear under its name, and it sees only the repositories it can access. Sign in to GitHub as that account first; GitHub authorizes whichever account the browser is signed in as.",
},
- workflows: [
+ triggers: [
{
id: "github.review_requested",
- name: "Review pull requests I am asked to review",
- summary: "Reads the change and posts a review whenever someone asks for yours.",
+ when: "A GitHub review is requested",
// Team requests matter more than they sound: in most repositories with a
// CODEOWNERS file, review arrives addressed to a team rather than a person.
- trigger: "your review is requested, either directly or through a team you belong to",
+ trigger: "someone requests a review from the connected account, directly or through its team",
// review-requested covers teams; user-review-requested would not.
watches: "is:open is:pr review-requested:@me",
- writes: "a review on the pull request, as a comment rather than an approval",
settings: [repositories, ignoreDrafts, ignoreBots, sizeLimit],
- prompt: REVIEW_PROMPT,
- guidancePlaceholder:
- "Anything specific to your team. For example: we require a test for every new endpoint.",
- answer: "review",
runsIn: "checkout",
+ answer: "review",
actionId: "github.submit_review",
},
{
id: "github.issue_assigned",
- name: "Plan issues assigned to me",
- summary: "Posts a short implementation plan when an issue lands on you.",
- trigger: "an issue is assigned to you",
- // is:issue matters: without it this would pick up your own pull requests.
+ when: "A GitHub issue is assigned",
+ trigger: "an issue is assigned to the connected account",
+ // is:issue matters: without it this would pick up pull requests too.
watches: "is:open is:issue assignee:@me",
- writes: "a comment on the issue",
settings: [repositories],
- prompt: PLAN_PROMPT,
- guidancePlaceholder: "Anything specific to this codebase worth knowing before planning.",
- answer: "text",
runsIn: "checkout",
+ answer: "text",
actionId: "github.post_issue_comment",
},
+ ],
+ workflows: [
+ {
+ id: "github.review_requested",
+ name: "Review pull requests",
+ summary: "Posts a review when someone requests one from the connected account.",
+ triggerId: "github.review_requested",
+ prompt: REVIEW_PROMPT,
+ },
{
- id: "github.issue_implement",
- name: "Implement issues assigned to me",
- summary: "Writes the change and opens a draft pull request.",
- trigger: "an issue is assigned to you",
- watches: "is:open is:issue assignee:@me",
- writes: "a draft pull request",
- // The only workflow that writes code, and so the only one whose agent
- // gets somewhere to write. Kept apart from "Plan issues assigned to me"
- // rather than replacing it: asking for a plan and asking for the change
- // are different jobs, and which one an issue deserves is a judgement
- // about the issue.
- runsIn: "checkout",
- settings: [repositories],
- prompt: IMPLEMENT_PROMPT,
- guidancePlaceholder:
- "How work is done here. For example: every new endpoint needs a test, and we do not add dependencies without asking.",
- answer: "code",
- actionId: "github.open_pull_request",
+ // The id is the one "Plan issues assigned to me" had. Planning was one
+ // kind of answer to an issue; replying covers it and the rest, and loops
+ // made for planning keep the prompt they copied.
+ id: "github.issue_assigned",
+ name: "Reply to issues",
+ summary: "Answers an issue in a comment when it is assigned to the connected account.",
+ triggerId: "github.issue_assigned",
+ prompt: REPLY_PROMPT,
},
],
actions: [
@@ -191,22 +171,14 @@ export const githubManifest = defineManifest({
// a review arrives here with its findings written into the prose.
accepts: ["text"],
},
- {
- id: "github.open_pull_request",
- name: "Open a draft pull request",
- summary: "Open a draft pull request for the branch the agent already pushed.",
- // The agent cloned and pushed with the machine's git. This action only
- // names the pull request through the Connection.
- target: [],
- accepts: ["code"],
- },
],
// Nothing to configure per account: what the account can see is what GitHub
// decides, and every narrowing choice belongs to a loop.
settings: [],
- // The redirect authorizes whichever account the browser is already signed in
- // as, so a second account is out of reach without signing out of GitHub
- // first. Connections are still rows, so this is a product decision only.
+ // One shared account per team. The redirect also authorizes whichever account
+ // the browser is already signed in as, so a second one is out of reach
+ // without signing out of GitHub first. Connections are still rows, so this
+ // is a product decision only.
allowsMultipleAccounts: false,
byHand: { kind: "link", placeholder: "https://github.com/acme/web/pull/123" },
});
diff --git a/src/connectors/github/poll.ts b/src/connectors/github/poll.ts
index dcc12ef..a3ac1ea 100644
--- a/src/connectors/github/poll.ts
+++ b/src/connectors/github/poll.ts
@@ -60,15 +60,15 @@ function candidates(items: SearchItem[], repositories: string[]): Candidate[] {
}
/**
- * What the workflow says it watches for, which is what gets asked. Taking the
+ * What the trigger says it watches for, which is what gets asked. Taking the
* query from the manifest is the point: it is shown on the loop page, and a
* search that quietly differed from the one on screen would be the worst kind
* of wrong.
*/
-function query(workflowId: string): string {
- const workflow = githubManifest.workflows.find((entry) => entry.id === workflowId);
- if (!workflow?.watches) throw new Error(`GitHub cannot watch for ${workflowId}.`);
- return workflow.watches;
+function query(triggerId: string): string {
+ const trigger = githubManifest.triggers.find((entry) => entry.id === triggerId);
+ if (!trigger?.watches) throw new Error(`GitHub cannot watch for ${triggerId}.`);
+ return trigger.watches;
}
export async function pollGithub(input: {
diff --git a/src/connectors/gmail/api.ts b/src/connectors/gmail/api.ts
index b618a29..c25c2d7 100644
--- a/src/connectors/gmail/api.ts
+++ b/src/connectors/gmail/api.ts
@@ -2,11 +2,21 @@ import { TransientError } from "../errors.ts";
const BASE = "https://gmail.googleapis.com/gmail/v1/users/me";
-async function request
(accessToken: string, path: string): Promise {
+async function request(
+ accessToken: string,
+ path: string,
+ init: { method?: string; body?: unknown } = {},
+): Promise {
let res: Response;
try {
res = await fetch(`${BASE}${path}`, {
- headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
+ method: init.method ?? "GET",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: "application/json",
+ ...(init.body === undefined ? {} : { "Content-Type": "application/json" }),
+ },
+ ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
});
} catch (error) {
throw new TransientError(
@@ -21,6 +31,10 @@ async function request(accessToken: string, path: string): Promise {
// and "slow down", which will. Gmail says which in the body.
const backoff = res.status === 403 && /rateLimitExceeded|userRateLimitExceeded/i.test(detail);
if (res.status === 429 || res.status >= 500 || backoff) throw new TransientError(message);
+ // A connection made before sending was offered has no send scope.
+ if (res.status === 403 && /insufficient.*scope|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(detail)) {
+ throw new Error("This Gmail connection cannot send mail. Reconnect Gmail to allow it.");
+ }
throw new Error(message);
}
return (await res.json()) as T;
@@ -91,3 +105,15 @@ export function getMessageHeaders(accessToken: string, id: string): Promise {
return request(accessToken, `/messages/${id}?format=full`);
}
+
+/** Send a message already written as RFC 2822 text, in a thread when one is given. */
+export function sendMessage(
+ accessToken: string,
+ raw: string,
+ threadId?: string,
+): Promise<{ id: string; threadId?: string }> {
+ return request(accessToken, "/messages/send", {
+ method: "POST",
+ body: threadId ? { raw, threadId } : { raw },
+ });
+}
diff --git a/src/connectors/gmail/mail.test.ts b/src/connectors/gmail/mail.test.ts
index 3f19645..49c9f86 100644
--- a/src/connectors/gmail/mail.test.ts
+++ b/src/connectors/gmail/mail.test.ts
@@ -3,6 +3,7 @@ import {
addressOf,
attachmentNames,
bodyText,
+ composeMail,
headerOf,
htmlToText,
mailContext,
@@ -218,3 +219,24 @@ describe("mailContext", () => {
expect(mailContext(mail).body).toContain("(this message has no readable text)");
});
});
+
+describe("composeMail", () => {
+ const decode = (raw: string) =>
+ Buffer.from(raw.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
+
+ it("writes a plain-text message Gmail can send", () => {
+ const text = decode(composeMail({ to: "team@example.com", subject: "Daily check", body: "All fine." }));
+ expect(text).toContain("To: team@example.com\r\n");
+ expect(text).toContain("Subject: Daily check\r\n");
+ expect(text).toContain('Content-Type: text/plain; charset="UTF-8"');
+ const body = text.split("\r\n\r\n")[1]!.replace(/\r\n/g, "");
+ expect(Buffer.from(body, "base64").toString("utf8")).toBe("All fine.");
+ });
+
+ it("encodes a subject and body that are not ASCII", () => {
+ const text = decode(composeMail({ to: "a@example.com", subject: "每日检查", body: "一切正常" }));
+ expect(text).toContain(`Subject: =?UTF-8?B?${Buffer.from("每日检查").toString("base64")}?=`);
+ const body = text.split("\r\n\r\n")[1]!.replace(/\r\n/g, "");
+ expect(Buffer.from(body, "base64").toString("utf8")).toBe("一切正常");
+ });
+});
diff --git a/src/connectors/gmail/mail.ts b/src/connectors/gmail/mail.ts
index 5ab2af3..d97cbb1 100644
--- a/src/connectors/gmail/mail.ts
+++ b/src/connectors/gmail/mail.ts
@@ -151,3 +151,37 @@ export function mailContext(message: GmailMessage): { name: string; body: string
].join("\n"),
};
}
+
+/** Base64url, as Gmail wants a raw message. */
+function base64url(text: string): string {
+ return Buffer.from(text, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+/** A header value that may hold any text, encoded so non-ASCII survives. */
+function encodedWord(text: string): string {
+ return /^[\x20-\x7e]*$/.test(text) ? text : `=?UTF-8?B?${Buffer.from(text, "utf8").toString("base64")}?=`;
+}
+
+/**
+ * A plain-text email as Gmail's `raw`. The body is base64 so any line length
+ * and any language go through untouched.
+ */
+export function composeMail(input: {
+ to: string;
+ subject: string;
+ body: string;
+ /** The Message-ID being answered, so mail clients thread it. */
+ inReplyTo?: string;
+}): string {
+ const lines = [
+ `To: ${input.to}`,
+ `Subject: ${encodedWord(input.subject)}`,
+ ...(input.inReplyTo ? [`In-Reply-To: ${input.inReplyTo}`, `References: ${input.inReplyTo}`] : []),
+ "MIME-Version: 1.0",
+ 'Content-Type: text/plain; charset="UTF-8"',
+ "Content-Transfer-Encoding: base64",
+ "",
+ Buffer.from(input.body, "utf8").toString("base64").replace(/.{76}/g, "$&\r\n"),
+ ];
+ return base64url(lines.join("\r\n"));
+}
diff --git a/src/connectors/gmail/manifest.ts b/src/connectors/gmail/manifest.ts
index 0390bcb..7d7e6dc 100644
--- a/src/connectors/gmail/manifest.ts
+++ b/src/connectors/gmail/manifest.ts
@@ -2,83 +2,59 @@ import { defineManifest } from "../define.ts";
import type { SettingField } from "../types.ts";
/**
- * Reading is the whole of it. Gmail has no send scope here on purpose: an
- * agent that can reply as you, to anyone, is a different and much larger thing
- * to agree to than one that reads your inbox and tells you about it.
+ * Read, and send. Sending is only ever to the connected account itself, the
+ * way a bot answers whoever asked it; the agent never holds the token.
*/
-export const GMAIL_SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"];
+export const GMAIL_SCOPES = [
+ "https://www.googleapis.com/auth/gmail.readonly",
+ "https://www.googleapis.com/auth/gmail.send",
+];
-/**
- * Gmail's own search, appended to what the workflow already asks. This is the
- * setting that makes the difference between a useful loop and a firehose, so
- * it leads.
- */
+/** Gmail's own search, added to what the trigger already asks. */
const extraQuery: SettingField = {
key: "extraQuery",
kind: "text",
- label: "Narrow it down",
- help: "Gmail search terms, added to the query above. For example: from:bank.example.com, or subject:invoice, or -from:notifications@github.com.",
- placeholder: "from:boss@example.com",
+ label: "Only mail matching",
+ help: "Gmail search terms, such as from:billing@vendor.com or subject:invoice.",
+ placeholder: "from:alerts@example.com",
};
/**
- * A cap on how many mails one look can turn into runs, which is a cost limit
- * rather than a filter: every mail past it is still noticed on the next poll,
- * because nothing has marked it as dealt with.
+ * Whether a quiet mailbox stays quiet. Without it every receipt costs a run.
+ * The triage itself lives in the dispatcher and reads this key.
*/
+const needsMeOnly: SettingField = {
+ key: "needsMeOnly",
+ kind: "boolean",
+ label: "Skip mail that needs nothing",
+ help: "A quick look first decides which mail asks for something. The rest is kept, not run.",
+ default: true,
+};
+
+/** A cost limit rather than a filter: anything past it is seen on the next look. */
const perPoll: SettingField = {
key: "maxPerPoll",
kind: "select",
- label: "At most, per look",
- help: "Each mail costs a full agent run. A quiet mailbox never reaches this; a busy one should be narrowed above rather than raised here.",
+ label: "At most, per check",
options: [
- { value: "3", label: "3 messages" },
- { value: "10", label: "10 messages" },
- { value: "25", label: "25 messages" },
+ { value: "3", label: "3 emails" },
+ { value: "10", label: "10 emails" },
+ { value: "25", label: "25 emails" },
],
default: "10",
};
-/**
- * The setting that decides whether a quiet mailbox is quiet. Without it every
- * receipt arrives as a notification saying a receipt arrived, which costs a
- * run each and trains you to stop looking.
- */
-const needsMeOnly: SettingField = {
- key: "needsMeOnly",
- kind: "boolean",
- label: "Only tell me when it needs me",
- help: "One cheap look at everything that arrived decides which mails are actually asking something of you, from the sender, subject and first line. The rest wait in the backlog with a reason, so you can still run one if this got it wrong.",
- default: true,
-};
-
const skipOwn: SettingField = {
key: "skipOwn",
kind: "boolean",
- label: "Skip mail I sent",
- help: "Your own messages show up in threads you are part of, and being told about what you just sent is not useful.",
+ label: "Skip mail sent from this account",
default: true,
};
-const TELL_ME_PROMPT = [
- "Read the email in EMAIL.md and tell me what it says.",
- "",
- "You are writing a phone notification, not a summary document. Two or three",
- "sentences. No headings, no bullet lists, no preamble like \"this email is\".",
- "Lead with who it is from and what they want.",
- "",
- "Say plainly if it needs something from me and by when. If there is a",
- "deadline, an amount of money, a date, or a link I have to act on, include it",
- "exactly as written rather than describing it.",
- "",
- "If it is an automated notification, a newsletter, or otherwise needs nothing",
- "from me, say so in one line and stop.",
-].join("\n");
-
export const gmailManifest = defineManifest({
id: "gmail",
name: "Gmail",
- tagline: "Read new mail and have an agent tell you what matters.",
+ tagline: "Start a loop when an email arrives, or email an answer to the connected account.",
docsUrl: "https://developers.google.com/gmail/api/guides",
icon: "Gmail",
// Gmail's own red, now that the mark is Gmail's own too. A near miss on a
@@ -89,46 +65,41 @@ export const gmailManifest = defineManifest({
scopes: GMAIL_SCOPES,
needsAppRegistration: true,
},
- workflows: [
+ triggers: [
{
id: "gmail.new_mail",
- name: "Tell me about new mail",
- summary: "Reads mail as it arrives and sends you a short account of it somewhere you look.",
- trigger: "mail arrives in your inbox",
+ when: "An email arrives",
+ trigger: "an email arrives in the connected inbox",
// A day's window rather than "unread": reading a mail on your phone
// should not decide whether the loop ever saw it. What stops a mail
- // being handled twice is its id, not its state, so re-seeing yesterday's
- // costs nothing and a poller that was off for an hour misses nothing.
+ // being handled twice is its id, not its state.
watches: "in:inbox newer_than:1d",
- writes: "a message telling you what arrived",
- // Nothing to check out: the mail is the whole of the work, and it is
- // handed over as a file.
+ // The mail is the whole of the work, handed over as a file.
runsIn: "temp",
settings: [extraQuery, needsMeOnly, perPoll, skipOwn],
- prompt: TELL_ME_PROMPT,
- guidancePlaceholder:
- "What you care about in your mail. For example: I only need to know about anything involving money or a deadline.",
answer: "text",
- // Gmail cannot write, so this answers on WeChat, which is the point of
- // the workflow rather than an accident of it: mail you are already
- // reading in Gmail does not need telling about. `source` would mean
- // nothing here, since no chat started this.
- actionConnectorId: "wechat",
- actionId: "wechat.reply",
- actionTarget: { to: "me" },
- // Half an hour, because mail turns up one piece at a time and the whole
- // of deciding what is worth a run is having several to compare. Looking
- // every two minutes meant almost always finding exactly one, which is
- // not a batch, so every newsletter got an agent to itself.
- //
- // The cost of waiting is being told half an hour late about something
- // that sat unread for hours before it was sent.
+ // Kept in Inbox until a loop says where it should go.
+ actionConnectorId: "schedule",
+ actionId: "schedule.record",
+ // Mail turns up one piece at a time, and deciding what deserves a run
+ // needs several to compare.
pollEveryMs: 30 * 60_000,
},
],
- // Read-only, so there is nothing to offer. A loop built on Gmail writes
- // through whichever connector it picks.
- actions: [],
+ // No ready-made job: what to do with mail is the team's to say, in a
+ // custom loop.
+ workflows: [],
+ actions: [
+ {
+ id: "gmail.send",
+ name: "Email this account",
+ summary:
+ "Send the answer to the connected account's own inbox, in the same thread when it started from an email there.",
+ target: [],
+ // A review sent here reads as prose, with file and line written in.
+ accepts: ["text"],
+ },
+ ],
settings: [],
// The redirect authorizes whichever account the browser is signed in as, so
// reaching a second one means signing out of Google first.
diff --git a/src/connectors/gmail/oauth.test.ts b/src/connectors/gmail/oauth.test.ts
index c9801a0..aed4ca0 100644
--- a/src/connectors/gmail/oauth.test.ts
+++ b/src/connectors/gmail/oauth.test.ts
@@ -34,9 +34,9 @@ describe("buildAuthorizeUrl", () => {
expect(url().searchParams.get("prompt")).toBe("consent");
});
- it("asks only to read mail", () => {
+ it("asks to read and to send mail, and nothing more", () => {
expect(url().searchParams.get("scope")).toBe(
- "https://www.googleapis.com/auth/gmail.readonly",
+ "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.send",
);
});
});
diff --git a/src/connectors/gmail/poll.ts b/src/connectors/gmail/poll.ts
index 18544f3..c29adba 100644
--- a/src/connectors/gmail/poll.ts
+++ b/src/connectors/gmail/poll.ts
@@ -11,9 +11,9 @@ import { addressOf, headerOf, mailRef, permalink, summarise } from "./mail.ts";
* worst kind of wrong.
*/
function baseQuery(workflowId: string): string {
- const workflow = gmailManifest.workflows.find((entry) => entry.id === workflowId);
- if (!workflow?.watches) throw new Error(`Gmail cannot watch for ${workflowId}.`);
- return workflow.watches;
+ const trigger = gmailManifest.triggers.find((entry) => entry.id === workflowId);
+ if (!trigger?.watches) throw new Error(`Gmail cannot watch for ${workflowId}.`);
+ return trigger.watches;
}
/**
diff --git a/src/connectors/gmail/runtime.test.ts b/src/connectors/gmail/runtime.test.ts
new file mode 100644
index 0000000..3355fcc
--- /dev/null
+++ b/src/connectors/gmail/runtime.test.ts
@@ -0,0 +1,61 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("./api.ts", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getProfile: vi.fn(async () => ({ emailAddress: "team@example.com" })),
+ getMessage: vi.fn(async () => ({
+ id: "m1",
+ threadId: "t1",
+ payload: {
+ headers: [
+ { name: "Subject", value: "Invoice overdue" },
+ { name: "Message-ID", value: "" },
+ ],
+ },
+ })),
+ sendMessage: vi.fn(async () => ({ id: "sent1" })),
+ };
+});
+
+const { sendMessage } = await import("./api.ts");
+const { gmailRuntime } = await import("./runtime.ts");
+
+const credential = { accessToken: "token", expiresAt: Date.now() + 60_000 };
+const decode = (raw: string) =>
+ Buffer.from(raw.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
+
+beforeEach(() => vi.mocked(sendMessage).mockClear());
+
+describe("gmail.send", () => {
+ it("emails the connected account, not anyone the loop names", async () => {
+ await gmailRuntime.applyAction!({
+ actionId: "gmail.send",
+ target: {},
+ source: { connectorId: "schedule", kind: "occurrence", ref: "daily", title: "Daily check", url: "" },
+ body: "All fine.",
+ credential,
+ });
+
+ const [, raw, threadId] = vi.mocked(sendMessage).mock.calls[0]!;
+ expect(decode(raw)).toContain("To: team@example.com\r\n");
+ expect(decode(raw)).toContain("Subject: Daily check\r\n");
+ expect(threadId).toBeUndefined();
+ });
+
+ it("answers in the thread of the email that started the run", async () => {
+ await gmailRuntime.applyAction!({
+ actionId: "gmail.send",
+ target: {},
+ source: { connectorId: "gmail", kind: "email", ref: "mail#m1", title: "Invoice overdue", url: "" },
+ body: "Paid on Friday.",
+ credential,
+ });
+
+ const [, raw, threadId] = vi.mocked(sendMessage).mock.calls[0]!;
+ expect(threadId).toBe("t1");
+ expect(decode(raw)).toContain("Subject: Re: Invoice overdue\r\n");
+ expect(decode(raw)).toContain("In-Reply-To: \r\n");
+ });
+});
diff --git a/src/connectors/gmail/runtime.ts b/src/connectors/gmail/runtime.ts
index e8e235b..24291d8 100644
--- a/src/connectors/gmail/runtime.ts
+++ b/src/connectors/gmail/runtime.ts
@@ -1,7 +1,7 @@
import { defineRuntime } from "../define.ts";
import { oauthAppRegistration } from "../oauth-app.ts";
-import { getMessage, getProfile } from "./api.ts";
-import { mailContext, mailRef, permalink, summarise } from "./mail.ts";
+import { getMessage, getProfile, sendMessage } from "./api.ts";
+import { composeMail, headerOf, mailContext, mailRef, permalink, summarise } from "./mail.ts";
import { pollGmail, type MailPayload } from "./poll.ts";
import {
buildAuthorizeUrl,
@@ -51,7 +51,7 @@ function accountOf(emailAddress: string) {
}
/**
- * Which message this task is about. The poll keeps the id, and a pasted link
+ * Which message this task is about. A task keeps the id, and a pasted link
* carries one too, so either is enough to fetch the mail again.
*/
function messageIdFrom(url: string, payload: unknown): string {
@@ -93,12 +93,9 @@ export const gmailRuntime = defineRuntime({
async poll({ workflowId, settings, credential }) {
const { accessToken } = await usableToken(credential as GmailCredential);
- // Which mailbox this is, so mail from yourself can be told apart. Cheap,
- // and the alternative is storing it on the credential where it would go
- // stale without anything noticing.
+ // Which mailbox this is, so mail from itself can be told apart.
const profile = await getProfile(accessToken);
- // No cursor: Gmail is asked what matches now and answers completely, so
- // there is no position to keep.
+ // No cursor: Gmail is asked what matches now and answers completely.
return {
signals: await pollGmail({
workflowId,
@@ -109,8 +106,35 @@ export const gmailRuntime = defineRuntime({
};
},
- // No applyAction: this connector reads. A loop built on it answers through
- // whichever connector it is pointed at.
+ /**
+ * Answers go to the connected account itself, the way a bot answers
+ * whoever asked it. When the run started from an email in this inbox, the
+ * answer joins that thread.
+ */
+ async applyAction({ actionId, source, body, credential }) {
+ if (actionId !== "gmail.send") throw new Error(`Gmail cannot ${actionId}.`);
+ const { accessToken } = await usableToken(credential as GmailCredential);
+ const profile = await getProfile(accessToken);
+
+ let subject = source.title || "From Loopable";
+ let threadId: string | undefined;
+ let inReplyTo: string | undefined;
+ const sourceId = source.connectorId === "gmail" ? source.ref.replace(/^mail#/, "") : "";
+ if (sourceId) {
+ const original = await getMessage(accessToken, sourceId);
+ threadId = original.threadId;
+ inReplyTo = headerOf(original, "Message-ID") || undefined;
+ const said = headerOf(original, "Subject");
+ if (said) subject = /^re:/i.test(said) ? said : `Re: ${said}`;
+ }
+
+ const sent = await sendMessage(
+ accessToken,
+ composeMail({ to: profile.emailAddress, subject, body, inReplyTo }),
+ threadId,
+ );
+ return { url: permalink(sent.id) };
+ },
auth: {
async startAuthorization({ redirectUri, state }) {
diff --git a/src/connectors/manifests.ts b/src/connectors/manifests.ts
index 4dcf49e..4de9dbe 100644
--- a/src/connectors/manifests.ts
+++ b/src/connectors/manifests.ts
@@ -8,6 +8,7 @@ import type {
ActionDescriptor,
ConnectorId,
ConnectorManifest,
+ TriggerDescriptor,
WorkflowDescriptor,
} from "./types.ts";
@@ -47,11 +48,52 @@ export function connectionNoun(id: ConnectorId): "account" | "bot" {
return connectorManifest(id)?.auth.kind === "token" ? "bot" : "account";
}
+export function connectorTrigger(
+ connectorId: ConnectorId,
+ triggerId: string,
+): TriggerDescriptor | undefined {
+ return connectorManifest(connectorId)?.triggers.find((entry) => entry.id === triggerId);
+}
+
+/** A trigger with nothing on top: what a custom loop runs. */
+function customOn(trigger: TriggerDescriptor): WorkflowDescriptor {
+ return {
+ ...trigger,
+ triggerId: trigger.id,
+ name: trigger.when,
+ summary: `When ${trigger.trigger}.`,
+ prompt: "",
+ custom: true,
+ };
+}
+
+/**
+ * What a loop runs, by the id it stores: a workflow, merged with the trigger
+ * it sits on, or a trigger alone for a custom loop.
+ */
export function connectorWorkflow(
connectorId: ConnectorId,
workflowId: string,
): WorkflowDescriptor | undefined {
- return connectorManifest(connectorId)?.workflows.find((entry) => entry.id === workflowId);
+ const manifest = connectorManifest(connectorId);
+ if (!manifest) return undefined;
+ const defined = manifest.workflows.find((entry) => entry.id === workflowId);
+ if (defined) {
+ const trigger = manifest.triggers.find((entry) => entry.id === defined.triggerId);
+ if (!trigger) return undefined;
+ return {
+ ...trigger,
+ ...defined,
+ actionId: defined.actionId ?? trigger.actionId,
+ actionConnectorId: defined.actionConnectorId ?? trigger.actionConnectorId,
+ actionTarget: defined.actionTarget ?? trigger.actionTarget,
+ pollEveryMs: defined.pollEveryMs ?? trigger.pollEveryMs,
+ triggerId: trigger.id,
+ custom: false,
+ };
+ }
+ const trigger = manifest.triggers.find((entry) => entry.id === workflowId);
+ return trigger ? customOn(trigger) : undefined;
}
export function connectorAction(
@@ -61,12 +103,68 @@ export function connectorAction(
return connectorManifest(connectorId)?.actions.find((entry) => entry.id === actionId);
}
-/** Every workflow on offer, for the page that asks which one to turn on. */
+/**
+ * Keeping the answer in Inbox is the clock's action, because the clock is the
+ * one connector with nowhere of its own to write. To a person it is not a
+ * Schedule thing at all, so it is named for where the answer stays.
+ */
+const INBOX_ONLY = { connectorId: "schedule", actionId: "schedule.record" } as const;
+
+export function isInboxOnly(connectorId: string, actionId: string): boolean {
+ return connectorId === INBOX_ONLY.connectorId && actionId === INBOX_ONLY.actionId;
+}
+
+/** What to call a connector as a place answers go. */
+export function writerName(connectorId: ConnectorId): string {
+ if (connectorId === INBOX_ONLY.connectorId) return "Inbox";
+ return connectorManifest(connectorId)?.name ?? connectorId;
+}
+
+/** Where a loop's answer goes, in a few words: "GitHub · Submit a review", or "Inbox only". */
+export function destinationLabel(connectorId: ConnectorId, actionId: string): string {
+ if (isInboxOnly(connectorId, actionId)) return "Inbox only";
+ const action = connectorAction(connectorId, actionId);
+ return action ? `${writerName(connectorId)} · ${action.name}` : "No destination";
+}
+
+/** Connectors that can write, Inbox first, for the question of where an answer goes. */
+export function writers(): ConnectorManifest[] {
+ const all = CONNECTOR_MANIFESTS.filter((entry) => entry.actions.length > 0);
+ return [
+ ...all.filter((entry) => entry.id === INBOX_ONLY.connectorId),
+ ...all.filter((entry) => entry.id !== INBOX_ONLY.connectorId),
+ ];
+}
+
+/**
+ * Every workflow on offer, for the page that asks which one to turn on.
+ *
+ * The clock leads: it needs no account, and a scheduled job is the easiest
+ * first loop to try. The rest follow the connectors' own order.
+ */
export function allWorkflows(): Array<{
connector: ConnectorManifest;
workflow: WorkflowDescriptor;
}> {
- return CONNECTOR_MANIFESTS.flatMap((connector) =>
- connector.workflows.map((workflow) => ({ connector, workflow })),
+ const ordered = [
+ ...CONNECTOR_MANIFESTS.filter((connector) => connector.id === "schedule"),
+ ...CONNECTOR_MANIFESTS.filter((connector) => connector.id !== "schedule"),
+ ];
+ return ordered.flatMap((connector) =>
+ connector.workflows.map((entry) => ({
+ connector,
+ workflow: connectorWorkflow(connector.id, entry.id)!,
+ })),
+ );
+}
+
+/** Every trigger on offer, the clock first, for choosing what starts a custom loop. */
+export function allTriggers(): Array<{ connector: ConnectorManifest; trigger: TriggerDescriptor }> {
+ const ordered = [
+ ...CONNECTOR_MANIFESTS.filter((connector) => connector.id === "schedule"),
+ ...CONNECTOR_MANIFESTS.filter((connector) => connector.id !== "schedule"),
+ ];
+ return ordered.flatMap((connector) =>
+ connector.triggers.map((trigger) => ({ connector, trigger })),
);
}
diff --git a/src/connectors/schedule/manifest.ts b/src/connectors/schedule/manifest.ts
index 944e956..e338889 100644
--- a/src/connectors/schedule/manifest.ts
+++ b/src/connectors/schedule/manifest.ts
@@ -29,7 +29,7 @@ const at: SettingField = {
key: "at",
kind: "text",
label: "At",
- help: "On a 24-hour clock, in this host's own time zone. Only the minutes are used by an hourly loop.",
+ help: "24-hour time, in this host's time zone.",
placeholder: "09:00",
};
@@ -37,19 +37,11 @@ const weekday: SettingField = {
key: "weekday",
kind: "select",
label: "On",
- help: "Only used by a loop that runs once a week.",
+ help: "For weekly loops.",
options: WEEKDAY_NAMES.map((name, index) => ({ value: String(index), label: name })),
default: "1",
};
-const folder: SettingField = {
- key: "folder",
- kind: "text",
- label: "Folder to work in",
- help: "An absolute path. Nothing arrives to be looked at on a schedule, so this is the whole of what the agent is given.",
- placeholder: "/Users/you/code/web",
-};
-
/**
* Deliberately a frame rather than a job. Every other workflow ships a prompt
* because "review a pull request" is a problem worth solving once and getting
@@ -80,30 +72,35 @@ export const scheduleManifest = defineManifest({
allowsMultipleAccounts: false,
byHand: { kind: "now" },
settings: [],
- workflows: [
+ triggers: [
{
id: "schedule.recurring",
- name: "Do something on a schedule",
- summary: "Runs an agent in a folder you name, and keeps what it says.",
+ when: "On a schedule",
trigger: "the time you set comes round",
// No query to show. A clock is not something one can be written for,
// and an invented one shown as if it were real is worse than nothing.
- writes: "a note in the log",
runsIn: "folder",
- settings: [every, at, weekday, folder],
- prompt: RECURRING_PROMPT,
- guidancePlaceholder:
- "Anything that holds every time this runs. For example: this repository releases on Thursdays, so say if anything is unmerged by Wednesday evening.",
+ settings: [every, at, weekday],
answer: "text",
actionId: "schedule.record",
},
],
+ workflows: [
+ {
+ id: "schedule.recurring",
+ name: "Do something on a schedule",
+ summary:
+ "Runs the agent at the times you set, in a folder you name. The answer stays in Inbox, or goes to Slack, Lark, or GitHub.",
+ triggerId: "schedule.recurring",
+ prompt: RECURRING_PROMPT,
+ },
+ ],
actions: [
{
id: "schedule.record",
- name: "Keep it in the log",
+ name: "Keep in Inbox",
summary:
- "Leaves the answer here and sends it nowhere. Every run is recorded anyway, so this is what to use until it is worth pushing somewhere.",
+ "Keeps the answer in Inbox and sends it nowhere.",
target: [],
accepts: ["text", "review"],
},
diff --git a/src/connectors/slack/api.test.ts b/src/connectors/slack/api.test.ts
index 331e7c1..865c539 100644
--- a/src/connectors/slack/api.test.ts
+++ b/src/connectors/slack/api.test.ts
@@ -19,6 +19,7 @@ describe("authTest", () => {
team: "Acme",
team_id: "T1",
user_id: "Ubot",
+ user: "loopable",
bot_id: "B1",
});
});
@@ -30,6 +31,7 @@ describe("authTest", () => {
teamUrl: "https://acme.slack.com/",
botUserId: "Ubot",
botId: "B1",
+ botName: "loopable",
});
});
diff --git a/src/connectors/slack/api.ts b/src/connectors/slack/api.ts
index b2ba863..aa96ae4 100644
--- a/src/connectors/slack/api.ts
+++ b/src/connectors/slack/api.ts
@@ -14,6 +14,8 @@ export type SlackCredential = {
teamUrl: string;
botUserId: string;
botId: string;
+ /** The bot's own name in the workspace, such as loopable. Absent on older connections. */
+ botName?: string;
};
export type SlackChannel = {
@@ -80,8 +82,9 @@ export async function authTest(botToken: string): Promise {
const teamId = typeof body.team_id === "string" ? body.team_id : "";
const botUserId = typeof body.user_id === "string" ? body.user_id : "";
const botId = typeof body.bot_id === "string" ? body.bot_id : "";
+ const botName = typeof body.user === "string" ? body.user : "";
if (!teamId || !botUserId) throw new Error("Slack did not return which workspace this bot belongs to.");
- return { botToken, teamId, team, teamUrl, botUserId, botId };
+ return { botToken, teamId, team, teamUrl, botUserId, botId, botName };
}
export async function listConversations(credential: SlackCredential): Promise {
diff --git a/src/connectors/slack/manifest.ts b/src/connectors/slack/manifest.ts
index 40170d9..a6335c5 100644
--- a/src/connectors/slack/manifest.ts
+++ b/src/connectors/slack/manifest.ts
@@ -1,19 +1,11 @@
import { defineManifest } from "../define.ts";
import type { SettingField } from "../types.ts";
-const folder: SettingField = {
- key: "folder",
- kind: "text",
- label: "Folder to work in",
- help: "An absolute path to a checkout on this host. The agent runs here, so it can read the code you are asking about.",
- placeholder: "/Users/you/code/your-project",
-};
-
const channel: SettingField = {
key: "channel",
kind: "text",
label: "Channel",
- help: "Leave empty to reply in the thread that asked. Set a channel ID when the work started somewhere else.",
+ help: "Empty replies where it was asked. Or a channel ID.",
placeholder: "C0123456789",
};
@@ -50,23 +42,27 @@ export const slackManifest = defineManifest({
},
],
},
- workflows: [
+ triggers: [
{
id: "slack.ask",
- name: "Do what I ask the bot",
- summary: "Runs your agent when someone @mentions the bot or DMs it, and answers in the thread.",
- trigger: "the bot is @mentioned, or someone DMs it",
- writes: "a reply in the Slack thread",
+ when: "The Slack bot is mentioned",
+ trigger: "someone @mentions the Slack bot in a channel it is in, or DMs it",
runsIn: "folder",
- settings: [folder],
- prompt: ASK_PROMPT,
- guidancePlaceholder:
- "Anything the agent should know about this project before it starts. For example: run pnpm test before saying a change works.",
+ settings: [],
answer: "text",
actionId: "slack.reply",
pollEveryMs: 60_000,
},
],
+ workflows: [
+ {
+ id: "slack.ask",
+ name: "Do what I ask the Slack bot",
+ summary: "Runs the agent when someone @mentions the Slack bot or DMs it, and answers in the thread.",
+ triggerId: "slack.ask",
+ prompt: ASK_PROMPT,
+ },
+ ],
actions: [
{
id: "slack.reply",
diff --git a/src/connectors/slack/runtime.test.ts b/src/connectors/slack/runtime.test.ts
index 3b8f85b..ad45ec1 100644
--- a/src/connectors/slack/runtime.test.ts
+++ b/src/connectors/slack/runtime.test.ts
@@ -54,7 +54,7 @@ describe("connectWithFields", () => {
);
});
- it("saves the workspace the token belongs to", async () => {
+ it("names the connection by its bot and the workspace it is in", async () => {
givenSlack({
auth: {
ok: true,
@@ -62,14 +62,15 @@ describe("connectWithFields", () => {
team: "Acme",
team_id: "T1",
user_id: "Ubot",
+ user: "loopable",
bot_id: "B1",
},
});
await expect(slackRuntime.auth.connectWithFields!({ botToken: " xoxb-good " })).resolves.toEqual({
- credential: account,
+ credential: { ...account, botName: "loopable" },
account: {
id: "T1",
- label: "Acme",
+ label: "@loopable · Acme",
url: "https://acme.slack.com/",
},
});
diff --git a/src/connectors/slack/runtime.ts b/src/connectors/slack/runtime.ts
index 291434e..b26f929 100644
--- a/src/connectors/slack/runtime.ts
+++ b/src/connectors/slack/runtime.ts
@@ -79,7 +79,11 @@ function destination(
function accountOf(credential: SlackCredential) {
return {
id: credential.teamId,
- label: credential.team || credential.teamId,
+ // The bot, then the workspace it is in: "@loopable · Acme". The workspace
+ // alone read as if it were the bot's name.
+ label: [credential.botName ? `@${credential.botName}` : null, credential.team || credential.teamId]
+ .filter(Boolean)
+ .join(" · "),
url: credential.teamUrl || undefined,
};
}
diff --git a/src/connectors/types.ts b/src/connectors/types.ts
index 8539755..a55d568 100644
--- a/src/connectors/types.ts
+++ b/src/connectors/types.ts
@@ -24,6 +24,11 @@ export type AuthDescriptor =
scopes: string[];
/** This install needs an app registration before anyone can connect. */
needsAppRegistration: boolean;
+ /**
+ * Which account to sign in as, since the redirect authorizes whoever the
+ * browser is already signed in as and the consent screen does not ask.
+ */
+ note?: string;
}
| {
kind: "token";
@@ -103,34 +108,35 @@ export type SettingField =
};
/**
- * A whole job, named the way a person would name it: watch for this, ask an
- * agent that, write the answer there. A loop is one instance of a workflow
- * with its knobs set.
+ * Something a connector can watch for: what starts a loop. "Starts when" in
+ * the UI.
*
- * The prompt lives here rather than on the loop because "review a pull
- * request" is a problem the connector should solve once and get right, instead
- * of every person rediscovering it in an empty box. A loop may add to it, and
- * most never will.
+ * It owns everything about the run that only makes sense alongside the code
+ * that watches and reads: the query, where the agent runs, what shape the
+ * answer must take. A custom loop is a trigger and nothing else; a workflow
+ * is a trigger with a prompt somebody already got right.
*/
-export type WorkflowDescriptor = {
+export type TriggerDescriptor = {
+ /**
+ * The same id the poll is asked about. A workflow on this trigger shares it
+ * when there is exactly one, which is what keeps loops made before triggers
+ * were their own thing pointing at something that still exists.
+ */
id: string;
- name: string;
- summary: string;
- /** What arrives, as a sentence a loop card can read back. */
+ /** In a few words, for choosing a trigger: "A review is requested". */
+ when: string;
+ /** What arrives, as a sentence a loop can read back. */
trigger: string;
/**
* The same thing exactly, in whatever the service itself understands. Shown
- * so that "when your review is requested" can be checked rather than taken
- * on trust, and read by the connector when it polls, so what a loop says it
- * watches for cannot drift from what it asks.
+ * so that it can be checked rather than taken on trust, and read by the
+ * connector when it polls, so what a loop says it watches for cannot drift
+ * from what it asks.
*
* Absent when there is no query to show: a stream of messages is not
- * something one can be written for, and an invented one shown as if it were
- * real is worse than saying nothing.
+ * something one can be written for.
*/
watches?: string;
- /** What gets written, as a sentence. */
- writes: string;
/**
* Where the agent runs. "temp" is a scratch directory holding the context
* files and nothing else. "folder" is a directory the loop names, for work
@@ -141,45 +147,65 @@ export type WorkflowDescriptor = {
runsIn?: "temp" | "folder" | "checkout";
/** The knobs a loop may set, in the connector's own words. */
settings: SettingField[];
- /** Owned here. A loop's guidance is appended, never substituted. */
- prompt: string;
- guidancePlaceholder?: string;
/**
* What shape the answer takes. "text" is one block of prose to post;
* "review" is a summary plus findings that get attached to lines.
*/
answer: AnswerShape;
- /** Which action a loop starts out carrying the answer with. */
+ /** Where an answer goes until a loop says otherwise. */
actionId: string;
/**
- * Which connector that action belongs to, when it is not this one.
- *
- * Most workflows answer where they read, and leave this alone. Some cannot:
- * a connector that only reads has nowhere to put an answer, and a workflow
- * whose entire point is to tell you about something somewhere you are
- * actually looking has to name that somewhere to be any use on the day it
- * is turned on. It is still only a starting point, and a loop may point
- * anywhere once it exists.
+ * Which connector that action belongs to, when it is not this one: a
+ * connector that only reads, or a trigger whose answer belongs elsewhere.
*/
actionConnectorId?: ConnectorId;
/**
- * What that action should be told about where to write. Against the
- * action's own `target` fields, and needed when the action's default cannot
- * apply: "whoever asked" means nothing to a loop that no person started.
+ * What that action should be told about where to write, when its default
+ * cannot apply: "whoever asked" means nothing to a loop no person started.
*/
actionTarget?: ConnectionSettings;
/**
* How long a new loop should leave between looks, when looking as often as
- * the dispatcher does is the wrong pace for this workflow. A starting point like
- * the rest of these, and the loop's own once it exists.
- *
- * Worth setting where arriving one at a time is the problem rather than the
- * point: a workflow that weighs a batch to decide what deserves an agent has
- * no batch to weigh if it is handed each item the moment it lands.
+ * the dispatcher does is the wrong pace. A starting point, and the loop's
+ * own once it exists.
*/
pollEveryMs?: number;
};
+/**
+ * A whole job, named the way a person would name it: one trigger, a prompt
+ * the connector got right once instead of every person rediscovering it in an
+ * empty box, and where the answer goes. A loop copies the prompt and owns it.
+ */
+export type WorkflowDefinition = {
+ id: string;
+ name: string;
+ summary: string;
+ triggerId: string;
+ prompt: string;
+ /** Overrides of the trigger's starting points. */
+ actionId?: string;
+ actionConnectorId?: ConnectorId;
+ actionTarget?: ConnectionSettings;
+ pollEveryMs?: number;
+};
+
+/**
+ * A trigger, and the workflow on it when there is one, as the one thing a
+ * loop runs. A loop names this by `workflowId`, which is a workflow's id or,
+ * for a custom loop, a trigger's.
+ */
+export type WorkflowDescriptor = Omit & {
+ id: string;
+ triggerId: string;
+ name: string;
+ summary: string;
+ /** Empty for a custom loop, which brings its own. */
+ prompt: string;
+ /** True when there is no workflow, only the trigger. */
+ custom: boolean;
+};
+
export type ConnectorManifest = {
id: ConnectorId;
name: string;
@@ -191,7 +217,11 @@ export type ConnectorManifest = {
/** Tailwind class for the card icon tile. */
accent: string;
auth: AuthDescriptor;
- workflows: WorkflowDescriptor[];
+ /** What can start a loop: "Starts when". */
+ triggers: TriggerDescriptor[];
+ /** Ready-made jobs, each on one of the triggers above. */
+ workflows: WorkflowDefinition[];
+ /** Where an answer can go: "Send to". */
actions: ActionDescriptor[];
settings: SettingField[];
/** Connecting more than one account of this connector is meaningful. */
diff --git a/src/connectors/wechat/manifest.ts b/src/connectors/wechat/manifest.ts
index f333ea1..962af12 100644
--- a/src/connectors/wechat/manifest.ts
+++ b/src/connectors/wechat/manifest.ts
@@ -1,18 +1,5 @@
import type { ConnectorManifest, SettingField } from "../types.ts";
-/**
- * Where the agent runs. Asking a phone "what broke the build" is only worth
- * anything if the agent can read the build, so this workflow works in a real
- * checkout rather than an empty directory.
- */
-const folder: SettingField = {
- key: "folder",
- kind: "text",
- label: "Folder to work in",
- help: "An absolute path to a checkout on this host. The agent runs here, so it can read the code you are asking about.",
- placeholder: "/Users/you/code/your-project",
-};
-
/**
* A bot bound to your account can be messaged by other people, and this
* workflow runs a coding agent on a runner. Answering only yourself is the
@@ -40,7 +27,7 @@ const recipient: SettingField = {
key: "to",
kind: "select",
label: "Who to send it to",
- help: "WeChat only reliably delivers a reply to a conversation you started. Sending to yourself out of the blue is refused unless you have written to the bot in the last half day or so, which makes this a poor fit for anything that has to arrive overnight.",
+ help: "\"Me\" only arrives if you messaged the bot in the last half day.",
options: [
{ value: "source", label: "Whoever asked" },
{ value: "me", label: "Me" },
@@ -82,24 +69,28 @@ export const wechatManifest: ConnectorManifest = {
kind: "qr_scan",
note: "Scanning binds a bot to your WeChat account. Loopable can then read what people send that bot and answer as it, and nothing else on your account.",
},
- workflows: [
+ triggers: [
{
id: "wechat.ask",
- name: "Do what I ask the bot",
- summary: "Runs your agent on a runner when you message the bot, and answers in the chat.",
- trigger: "you send the bot a message",
+ when: "The WeChat bot gets a message",
+ trigger: "someone sends the WeChat bot a message",
// No query to show: messages arrive on a stream rather than being found
// by asking, so there is nothing here that could be checked.
- writes: "a reply in the chat",
runsIn: "folder",
- settings: [folder, askers],
- prompt: ASK_PROMPT,
- guidancePlaceholder:
- "Anything the agent should know about this project before it starts. For example: run pnpm test before saying a change works.",
+ settings: [askers],
answer: "text",
actionId: "wechat.reply",
},
],
+ workflows: [
+ {
+ id: "wechat.ask",
+ name: "Do what I ask the WeChat bot",
+ summary: "Runs the agent when someone messages the WeChat bot, and answers in the chat.",
+ triggerId: "wechat.ask",
+ prompt: ASK_PROMPT,
+ },
+ ],
actions: [
{
id: "wechat.reply",
diff --git a/src/lib/domain.ts b/src/lib/domain.ts
index 007b90e..00d6d9b 100644
--- a/src/lib/domain.ts
+++ b/src/lib/domain.ts
@@ -169,6 +169,8 @@ export type LoopReadiness = {
availableAgentIds: string[];
/** The same, but only on a runner that shares Loopable's disk. */
hostAgentIds: string[];
+ /** The same, but only on a runner new enough to take a folder job. */
+ folderAgentIds: string[];
};
export type RunnerInventoryEntry = {
@@ -194,6 +196,11 @@ export type RunnerView = {
status: "online" | "offline";
inventory: RunnerInventoryEntry[];
lastSeenAt: string | null;
+ /** Its loopable-cli version, or null for a runner too old to say. */
+ version: string | null;
+ protocol: number;
+ /** Older than this App: it may not take every job, and should be upgraded. */
+ outdated: boolean;
};
/** An agent as the browser sees it: settings, plus which runners have it. */
diff --git a/src/lib/gaps.test.ts b/src/lib/gaps.test.ts
index 3abea55..2ffe4a4 100644
--- a/src/lib/gaps.test.ts
+++ b/src/lib/gaps.test.ts
@@ -7,6 +7,7 @@ const ready: LoopReadiness = {
defaultAgentId: "cursor-agent",
availableAgentIds: ["cursor-agent"],
hostAgentIds: ["cursor-agent"],
+ folderAgentIds: ["cursor-agent"],
};
function loop(over: Partial = {}): LoopView {
@@ -117,7 +118,7 @@ describe("issuesFor", () => {
).toEqual([]);
});
- it("still pins a folder loop to a runner on this host", () => {
+ it("pins a loop with an old absolute folder to a runner on this host", () => {
expect(
issuesFor(
{ ...ready, hostAgentIds: [] },
@@ -125,10 +126,42 @@ describe("issuesFor", () => {
name: "Daily look",
connectorId: "schedule",
workflowId: "schedule.recurring",
+ settings: { folder: "/Users/me/code/web" },
actionConnectorId: "wechat",
actionId: "wechat.reply",
}),
),
).toEqual(["Daily look needs a runner on this host with Cursor Agent signed in."]);
});
+
+ it("asks for an upgrade when only an older runner has the agent", () => {
+ expect(
+ issuesFor(
+ { ...ready, folderAgentIds: [] },
+ loop({
+ name: "Daily look",
+ connectorId: "schedule",
+ workflowId: "schedule.recurring",
+ settings: { folder: "code/web" },
+ actionConnectorId: "wechat",
+ actionId: "wechat.reply",
+ }),
+ ),
+ ).toEqual(["Daily look needs a newer runner. Upgrade the runners with npm install -g loopable-cli."]);
+ });
+
+ it("lets a loop with a relative folder run on any runner", () => {
+ expect(
+ issuesFor(
+ { ...ready, hostAgentIds: [] },
+ loop({
+ connectorId: "schedule",
+ workflowId: "schedule.recurring",
+ settings: { folder: "code/web" },
+ actionConnectorId: "wechat",
+ actionId: "wechat.reply",
+ }),
+ ),
+ ).toEqual([]);
+ });
});
diff --git a/src/lib/gaps.ts b/src/lib/gaps.ts
index 3bbc01c..9ece3c7 100644
--- a/src/lib/gaps.ts
+++ b/src/lib/gaps.ts
@@ -10,9 +10,16 @@ function wantedAgent(loop: LoopView, readiness: LoopReadiness): string | null {
return loop.agentId ?? readiness.defaultAgentId;
}
-function needsHost(loop: LoopView): boolean {
+/**
+ * Which runners a loop can use. A folder saved as an absolute path, from
+ * before folders were relative, ties it to the host; a relative one needs a
+ * runner new enough to look for it under its own home.
+ */
+function runnersFor(loop: LoopView): "host" | "folder" | "any" {
const runsIn = connectorWorkflow(loop.connectorId, loop.workflowId)?.runsIn;
- return runsIn === "folder";
+ if (runsIn !== "folder") return "any";
+ const folder = loop.settings.folder;
+ return typeof folder === "string" && folder.trim().startsWith("/") ? "host" : "folder";
}
/**
@@ -22,7 +29,13 @@ export function issuesFor(readiness: LoopReadiness, loop: LoopView): string[] {
if (!loop.enabled) return [];
const issues: string[] = [];
const agentId = wantedAgent(loop, readiness);
- const pool = needsHost(loop) ? readiness.hostAgentIds : readiness.availableAgentIds;
+ const needs = runnersFor(loop);
+ const pool =
+ needs === "host"
+ ? readiness.hostAgentIds
+ : needs === "folder"
+ ? readiness.folderAgentIds
+ : readiness.availableAgentIds;
if (!agentId) {
issues.push(
loop.agentId
@@ -31,9 +44,11 @@ export function issuesFor(readiness: LoopReadiness, loop: LoopView): string[] {
);
} else if (!pool.includes(agentId)) {
issues.push(
- needsHost(loop)
+ needs === "host"
? `${loop.name} needs a runner on this host with ${agentName(agentId)} signed in.`
- : `${loop.name} uses ${agentName(agentId)}, which no online runner has signed in.`,
+ : needs === "folder" && readiness.availableAgentIds.includes(agentId)
+ ? `${loop.name} needs a newer runner. Upgrade the runners with npm install -g loopable-cli.`
+ : `${loop.name} uses ${agentName(agentId)}, which no online runner has signed in.`,
);
}
return issues;
diff --git a/src/lib/time.ts b/src/lib/time.ts
new file mode 100644
index 0000000..4696c04
--- /dev/null
+++ b/src/lib/time.ts
@@ -0,0 +1,10 @@
+/** "just now", "5m ago", "3h ago", or a date. */
+export function ago(iso: string): string {
+ const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
+ if (seconds < 60) return "just now";
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.round(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return new Date(iso).toLocaleDateString();
+}
diff --git a/src/lib/version.test.ts b/src/lib/version.test.ts
new file mode 100644
index 0000000..9a42868
--- /dev/null
+++ b/src/lib/version.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import { olderThan } from "./version.ts";
+
+describe("olderThan", () => {
+ it("compares x.y.z numerically", () => {
+ expect(olderThan("0.2.1", "0.3.0")).toBe(true);
+ expect(olderThan("0.10.0", "0.9.9")).toBe(false);
+ expect(olderThan("0.3.0", "0.3.0")).toBe(false);
+ });
+
+ it("treats no version as older", () => {
+ expect(olderThan(null, "0.3.0")).toBe(true);
+ });
+});
diff --git a/src/lib/version.ts b/src/lib/version.ts
new file mode 100644
index 0000000..5f21aca
--- /dev/null
+++ b/src/lib/version.ts
@@ -0,0 +1,39 @@
+import pkg from "../../package.json" with { type: "json" };
+
+/** This build's version, as published to npm. */
+export const VERSION: string = pkg.version;
+
+/**
+ * What a runner understands about a job, as a number that only goes up.
+ *
+ * The package version says which build a runner is; this says whether it can
+ * take a given job, which is the question that matters when the App and a
+ * runner on another machine were upgraded on different days. Raise it when a
+ * job needs a runner to do something an older one would get wrong, and say
+ * what in the list below.
+ *
+ * 1. Up to 0.2.x, which did not report a protocol at all.
+ * 2. A folder is relative to the runner's home, and the prompt names context
+ * files by a placeholder the runner fills in with its own workspace.
+ */
+export const RUNNER_PROTOCOL = 2;
+
+/** What a runner that says nothing is assumed to speak. */
+export const LEGACY_RUNNER_PROTOCOL = 1;
+
+/** The lowest protocol a folder job can go to. */
+export const FOLDER_JOB_PROTOCOL = 2;
+
+/** Whether `a` is an older x.y.z than `b`. Anything unreadable counts as older. */
+export function olderThan(a: string | null, b: string): boolean {
+ if (!a) return true;
+ const pa = a.split(".").map((part) => Number.parseInt(part, 10));
+ const pb = b.split(".").map((part) => Number.parseInt(part, 10));
+ for (let i = 0; i < 3; i++) {
+ const x = pa[i] ?? 0;
+ const y = pb[i] ?? 0;
+ if (Number.isNaN(x)) return true;
+ if (x !== y) return x < y;
+ }
+ return false;
+}
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index b4a7026..a476c97 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -31,7 +31,7 @@ function AppLayout() {
{/* No header: the only thing it held was the sidebar toggle, which now
lives in the sidebar itself, above what it collapses. */}
-
+
{/* Capped and centred. Every page here is a list of sentences, and
stretched to a wide monitor they run past the width an eye can
track back from, while the button belonging to a row ends up a
diff --git a/src/routes/agents/index.tsx b/src/routes/agents/index.tsx
index fb85345..c450fab 100644
--- a/src/routes/agents/index.tsx
+++ b/src/routes/agents/index.tsx
@@ -1,10 +1,11 @@
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
-import { Bot, ChevronDown, MousePointerClick, PlayCircle, Terminal, type LucideIcon } from "lucide-react";
+import { ChevronDown, PlayCircle } from "lucide-react";
+import { ProductIcon } from "@/components/connector-icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
+import { List, PageHeader } from "@/components/page";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -26,8 +27,6 @@ import {
saveAgent,
} from "@/server/functions/agents.ts";
-const ICONS: Record
= { Terminal, MousePointerClick, Bot };
-
const MODE_LABELS: Record = {
read_only: "Read only",
workspace_write: "Can write in the workspace",
@@ -38,26 +37,23 @@ export const Route = createFileRoute("/agents/")({
component: AgentsPage,
});
-export function AgentsPage() {
+function AgentsPage() {
const { agents } = Route.useLoaderData();
useLiveRefresh();
return (
-
+
-
+
{AGENT_MANIFESTS.map((manifest) => {
const view = agents.find((agent) => agent.agentId === manifest.id);
return view ? : null;
})}
-
+
);
}
@@ -77,7 +73,6 @@ function presence(view: AgentView): string {
function AgentRow({ manifest, view }: { manifest: AgentManifest; view: AgentView }) {
const router = useRouter();
- const Icon = ICONS[manifest.icon] ?? Bot;
const [open, setOpen] = useState(view.isDefault && !view.defaultIsImplicit);
const [mode, setMode] = useState(view.settings.permissionMode);
const [model, setModel] = useState(view.settings.model ?? "");
@@ -98,28 +93,24 @@ function AgentRow({ manifest, view }: { manifest: AgentManifest; view: AgentView
};
return (
-
-
-
-
-
+
+
+
setOpen((value) => !value)}
>
- {manifest.name}
+ {manifest.name}
{view.isDefault ? (
- {view.defaultIsImplicit ? "Default (only one signed in)" : "Default"}
+ Default
) : null}
- {presence(view)}
+ {presence(view)}
{!view.isDefault ? (
@@ -135,7 +126,7 @@ function AgentRow({ manifest, view }: { manifest: AgentManifest; view: AgentView
})
}
>
- Default
+ Make default
) : null}
{open ? (
-
- {manifest.tagline}
- {!online ? {manifest.installHint}
: null}
+
+ {!online ?
{manifest.installHint}
: null}
@@ -245,8 +235,8 @@ function AgentRow({ manifest, view }: { manifest: AgentManifest; view: AgentView
{testOutput ? (
{testOutput}
) : null}
-
+
) : null}
-
+
);
}
diff --git a/src/routes/api/runners/heartbeat.ts b/src/routes/api/runners/heartbeat.ts
index b4d2253..0c42e8f 100644
--- a/src/routes/api/runners/heartbeat.ts
+++ b/src/routes/api/runners/heartbeat.ts
@@ -10,8 +10,15 @@ export const Route = createFileRoute("/api/runners/heartbeat")({
POST: async ({ request }) => {
const runnerId = await runnerIdForToken(bearer(request));
if (!runnerId) return Response.json({ error: "Unknown runner." }, { status: 401 });
- const body = (await request.json()) as { inventory?: RunnerInventoryEntry[] };
- const view = await touchRunner(runnerId, body.inventory ?? []);
+ const body = (await request.json()) as {
+ inventory?: RunnerInventoryEntry[];
+ version?: string;
+ protocol?: number;
+ };
+ const view = await touchRunner(runnerId, body.inventory ?? [], {
+ version: body.version,
+ protocol: body.protocol,
+ });
return Response.json(view);
},
},
diff --git a/src/routes/api/runners/hello.ts b/src/routes/api/runners/hello.ts
index dc2866a..c26b878 100644
--- a/src/routes/api/runners/hello.ts
+++ b/src/routes/api/runners/hello.ts
@@ -10,11 +10,15 @@ export const Route = createFileRoute("/api/runners/hello")({
const body = (await request.json()) as {
hostname?: string;
inventory?: Parameters
[0]["inventory"];
+ version?: string;
+ protocol?: number;
};
const result = await joinRunner({
joinToken: bearer(request),
hostname: body.hostname ?? "unknown",
inventory: body.inventory ?? [],
+ version: body.version,
+ protocol: body.protocol,
});
return Response.json(result);
} catch (error) {
diff --git a/src/routes/connectors/$connectorId.tsx b/src/routes/connectors/$connectorId.tsx
index 2fe508f..b94deb2 100644
--- a/src/routes/connectors/$connectorId.tsx
+++ b/src/routes/connectors/$connectorId.tsx
@@ -1,7 +1,7 @@
import { Link, createFileRoute, notFound, useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
-import { ArrowLeft, ExternalLink, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
+import { ChevronRight, Plus, RefreshCw, RotateCcw } from "lucide-react";
import { ConnectionSettingsForm } from "@/components/connection-settings-form";
import { ConnectionStatusBadge } from "@/components/connection-status";
import { ConnectorIcon } from "@/components/connector-icon";
@@ -9,27 +9,32 @@ import { QrConnect } from "@/components/qr-connect";
import { TokenConnect } from "@/components/token-connect";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Empty, List, PageHeader } from "@/components/page";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Separator } from "@/components/ui/separator";
-import { connectionNoun, connectorManifest } from "@/connectors/manifests.ts";
+import { connectionNoun, connectorManifest, destinationLabel } from "@/connectors/manifests.ts";
import { registrableCallbackUrl } from "@/lib/callback.ts";
import type { ConnectionView } from "@/lib/domain.ts";
+import { ago } from "@/lib/time.ts";
import {
disconnectConnection,
getConnectorOverview,
verifyConnection,
} from "@/server/functions/connectors.ts";
+import { getLoopsPage } from "@/server/functions/loops.ts";
export const Route = createFileRoute("/connectors/$connectorId")({
loader: async ({ params }) => {
const manifest = connectorManifest(params.connectorId);
if (!manifest) throw notFound();
- const overview = await getConnectorOverview();
+ const [overview, page] = await Promise.all([getConnectorOverview(), getLoopsPage()]);
const state = overview.find((entry) => entry.connectorId === params.connectorId);
if (!state) throw notFound();
- return state;
+ // Loops that watch this connector, or write through it.
+ const loops = page.loops.filter(
+ (loop) => loop.connectorId === params.connectorId || loop.actionConnectorId === params.connectorId,
+ );
+ return { ...state, loops };
},
component: ConnectorDetailPage,
});
@@ -45,45 +50,48 @@ function ConnectorDetailPage() {
const [scanning, setScanning] = useState(false);
const [pasting, setPasting] = useState(false);
+ const connectButton =
+ state.readiness.ready && canAddAccount ? (
+ scans ? (
+ setScanning((open) => !open)}>
+ {scanning ? "Cancel" : "Connect"}
+
+ ) : tokens ? (
+ setPasting((open) => !open)}>{pasting ? "Cancel" : "Add bot"}
+ ) : (
+ }>
+ {state.connections.length > 0 ? "Add account" : "Connect"}
+
+ )
+ ) : null;
+
return (
-
-
- Connectors
-
+
}
+ title={manifest.name}
+ description={manifest.tagline}
+ actions={connectButton}
+ />
-
-
-
-
{manifest.name}
-
{manifest.tagline}
-
- {state.readiness.ready && canAddAccount ? (
- scans ? (
- setScanning((open) => !open)}>
- {scanning ? "Cancel" : "Connect"}
-
- ) : tokens ? (
- setPasting((open) => !open)}>
- {pasting ? "Cancel" : "Add bot"}
-
- ) : (
- }>
- {state.connections.length > 0 ? "Add another account" : "Connect"}
-
- )
- ) : null}
-
+ {!state.readiness.ready ? (
+
+ Not available in this build
+
+ {state.readiness.reason}
+ {state.readiness.fixHint ? {state.readiness.fixHint} : null}
+ {manifest.auth.kind === "oauth_redirect" ? (
+
+ Callback URL: {registrableCallbackUrl(connectorId)}
+
+ ) : null}
+
+
+ ) : null}
{scanning && manifest.auth.kind === "qr_scan" ? (
-
setScanning(false)}
- />
+ setScanning(false)} />
) : null}
{pasting && manifest.auth.kind === "token" ? (
@@ -96,113 +104,113 @@ function ConnectorDetailPage() {
/>
) : null}
- {!state.readiness.ready ? (
-
- {manifest.name} is not available in this build
-
- {state.readiness.reason}
- {state.readiness.fixHint ? {state.readiness.fixHint} : null}
- {manifest.auth.kind === "oauth_redirect" ? (
-
- Callback URL to register: {registrableCallbackUrl(connectorId)}
-
- ) : null}
-
-
- ) : null}
-
- {state.connections.length === 0 && state.readiness.ready && !scanning && !pasting ? (
-
-
- No {connectionNoun(connectorId)} connected yet.
- {manifest.auth.kind === "oauth_redirect" ? (
- <>
- {" "}
- Connecting opens {manifest.name} and asks for{" "}
- {manifest.auth.scopes.join(", ")}.
- >
- ) : null}
- {manifest.auth.kind === "qr_scan" ? " Connecting shows a code to scan." : null}
- {manifest.auth.kind === "token"
- ? " Connecting asks for app credentials, not a personal login."
- : null}
-
-
+ {state.connections.length > 0 ? (
+
+ {state.connections.map((connection) => (
+
+ ))}
+
+ ) : state.readiness.ready && !scanning && !pasting ? (
+
+ No {connectionNoun(connectorId)} connected yet.
+ {/* The account a redirect signs in as is whichever one the browser
+ already holds, so this is the moment to say which it should be. */}
+ {manifest.auth.kind === "oauth_redirect" && manifest.auth.note ? (
+ {manifest.auth.note}
+ ) : null}
+
) : null}
- {state.connections.map((connection) => (
-
- ))}
-
-
-
- What this connector can do
-
-
- ({
- id: workflow.id,
- name: workflow.name,
- summary: workflow.summary,
- }))}
- />
-
- ({
- id: action.id,
- name: action.name,
- summary: action.summary,
- }))}
- />
- {manifest.docsUrl ? (
-
- API documentation
-
-
+
+
+
Loops
+ {manifest.triggers.length > 0 ? (
+
}>
+
+ New loop
+
) : null}
-
-
+
+ {state.loops.length === 0 ? (
+
No loop uses {manifest.name} yet.
+ ) : (
+
+ {state.loops.map((loop) => (
+
+
+
{loop.name}
+
+ {loop.connectorId === connectorId
+ ? destinationLabel(loop.actionConnectorId, loop.actionId)
+ : `Answers here, from ${connectorManifest(loop.connectorId)?.name ?? loop.connectorId}`}
+
+
+ {loop.enabled ? null : Off }
+
+
+ ))}
+
+ )}
+
+
+
+ ({
+ id: trigger.id,
+ name: trigger.when,
+ summary: `When ${trigger.trigger}.`,
+ }))}
+ />
+ ({
+ id: workflow.id,
+ name: workflow.name,
+ summary: workflow.summary,
+ }))}
+ />
+ ({
+ id: action.id,
+ name: action.name,
+ summary: action.summary,
+ }))}
+ />
+
);
}
function CapabilityList({
title,
- note,
empty,
items,
}: {
title: string;
- note?: string;
- /** What to say when there are none, since a bare heading reads as a bug. */
empty: string;
items: Array<{ id: string; name: string; summary: string }>;
}) {
return (
-
-
{title}
- {note && items.length > 0 ? (
-
{note}
- ) : null}
+
+
{title}
{items.length === 0 ? (
-
{empty}
+
{empty}
) : (
-
+
{items.map((item) => (
-
- {item.name}
- — {item.summary}
+
+ {item.name}
+ {item.summary}
))}
@@ -211,7 +219,7 @@ function CapabilityList({
);
}
-function ConnectionCard({
+function ConnectionRow({
connection,
manifest,
}: {
@@ -247,68 +255,57 @@ function ConnectionCard({
};
return (
-
-
-
+
+
+
{connection.accountLabel.slice(0, 2).toUpperCase()}
-
+
- {connection.accountLabel}
+ {connection.accountLabel}
-
- {connection.lastSyncedAt
- ? `Last checked ${new Date(connection.lastSyncedAt).toLocaleString()}`
- : "Not checked yet"}
+
+ {[
+ connection.lastSyncedAt ? `Checked ${ago(connection.lastSyncedAt)}` : "Not checked yet",
+ connection.scopes.length > 0 ? connection.scopes.join(", ") : null,
+ ]
+ .filter(Boolean)
+ .join(" · ")}
-
run("verify")} disabled={busy !== null}>
-
- Verify
-
{broken ? (
}
>
Reconnect
) : null}
+
run("verify")} disabled={busy !== null}>
+
+ Verify
+
run("disconnect")}
+ onClick={() => {
+ if (window.confirm(`Disconnect ${connection.accountLabel}?`)) void run("disconnect");
+ }}
disabled={busy !== null}
>
-
Disconnect
-
-
- {connection.lastError ? (
-
- {connection.lastError}
-
- ) : null}
+
- {connection.scopes.length > 0 ? (
-
-
Granted access
-
- {connection.scopes.map((scope) => (
-
- {scope}
-
- ))}
-
-
- ) : null}
+ {connection.lastError ? (
+
{connection.lastError}
+ ) : null}
-
-
-
+
+
);
}
diff --git a/src/routes/connectors/index.tsx b/src/routes/connectors/index.tsx
index 2831a87..37cb3d4 100644
--- a/src/routes/connectors/index.tsx
+++ b/src/routes/connectors/index.tsx
@@ -1,12 +1,10 @@
import { Link, createFileRoute } from "@tanstack/react-router";
import { useEffect } from "react";
import { toast } from "sonner";
-import { ArrowRight, CircleAlert } from "lucide-react";
+import { ChevronRight, CircleAlert } from "lucide-react";
import { ConnectionStatusBadge } from "@/components/connection-status";
import { ConnectorIcon } from "@/components/connector-icon";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { List, PageHeader } from "@/components/page";
import { CONNECTOR_MANIFESTS, connectionNoun, needsAccount } from "@/connectors/manifests.ts";
import { getConnectorOverview } from "@/server/functions/connectors.ts";
@@ -37,16 +35,10 @@ function ConnectorsPage() {
return (
-
+
-
- {/* A clock offers workflows but no account, and a card whose only
- button is "Set up" would lead nowhere. */}
+ {/* A clock needs no account, so it has nothing to set up here. */}
+
{CONNECTOR_MANIFESTS.filter((manifest) => needsAccount(manifest.id)).map((manifest) => {
const state = overview.find((entry) => entry.connectorId === manifest.id);
const connections = state?.connections ?? [];
@@ -54,50 +46,40 @@ function ConnectorsPage() {
const unavailable = readiness && !readiness.ready ? readiness : null;
return (
-
-
-
-
-
-
{manifest.name}
- {connections.length > 0 ? (
-
- {connections.length} {connectionNoun(manifest.id)}
- {connections.length > 1 ? "s" : ""}
-
- ) : null}
+
+
+
+
{manifest.name}
+ {unavailable ? (
+
+
+ {unavailable.reason}
+
+ ) : connections.length > 0 ? (
+
+ {connections.map((connection) => (
+
+ {connection.accountLabel}
+
+
+ ))}
-
{manifest.tagline}
-
-
}>
- {connections.length > 0 ? "Manage" : "Set up"}
-
-
-
-
- {(connections.length > 0 || unavailable) && (
-
- {unavailable ? (
-
-
- {unavailable.reason}
-
- ) : null}
- {connections.map((connection) => (
-
- {connection.accountLabel}
-
- {connection.lastError ? (
- {connection.lastError}
- ) : null}
-
- ))}
-
- )}
-
+ ) : (
+
+ No {connectionNoun(manifest.id)} yet
+
+ )}
+
+
+
);
})}
-
+
);
}
diff --git a/src/routes/inbox/$taskId.tsx b/src/routes/inbox/$taskId.tsx
index 7f7ef64..a0b6275 100644
--- a/src/routes/inbox/$taskId.tsx
+++ b/src/routes/inbox/$taskId.tsx
@@ -1,16 +1,19 @@
import { Link, createFileRoute, notFound, useRouter } from "@tanstack/react-router";
-import { ArrowLeft, CircleStop, ExternalLink, FileCode, Loader } from "lucide-react";
+import { ChevronRight, CircleStop, ExternalLink, FileCode, Loader } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
+import { PageHeader } from "@/components/page";
import { KIND_LABEL, TaskStateLabel, taskTitle } from "@/components/task-list";
import { useLiveTasks } from "@/components/use-live-tasks.ts";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { connectorAction, connectorManifest } from "@/connectors/manifests.ts";
+import { agentManifest } from "@/agents/manifests.ts";
+import { connectorManifest, destinationLabel } from "@/connectors/manifests.ts";
import { isTaskActive, type TaskView } from "@/lib/domain.ts";
import type { Finding } from "@/lib/review.ts";
+import { ago } from "@/lib/time.ts";
import { getTaskById, getTaskLog, stopTask } from "@/server/functions/tasks.ts";
export const Route = createFileRoute("/inbox/$taskId")({
@@ -24,72 +27,66 @@ export const Route = createFileRoute("/inbox/$taskId")({
function TaskPage() {
const { task, log } = Route.useLoaderData();
- // The connector that writes, which is not always the one that was read: a
- // loop may answer on a service it does not watch.
+ // The connector that writes, which is not always the one that was read.
const writer = connectorManifest(task.actionConnectorId);
- const action = connectorAction(task.actionConnectorId, task.actionId);
+ const agent = task.agentId ? agentManifest(task.agentId) : undefined;
+ const live = isTaskActive(task.state);
useLiveTasks([task]);
return (
-
+ {task.dryRun ?
Dry run : null}
+
+ {live ?
: null}
+ >
+ }
>
-
- Inbox
-
-
-
+
+ {task.sourceKind === "prompt" ? (
+ {KIND_LABEL.prompt}
+ ) : task.sourceUrl ? (
+
+ {task.sourceRef}
+
+ ) : (
+ {task.sourceRef}
+ )}
+ {task.loopId ? (
+ <>
+ ·
+
+ {task.loopName}
+
+ >
+ ) : null}
+ {agent ? · {agent.name} : null}
+ · {ago(task.createdAt)}
+
+
{task.error ? (
{task.state === "failed"
- ? "This run failed"
+ ? "Failed"
: task.state === "cancelled"
- ? "Stopped before it finished"
- : "Waiting to try again"}
+ ? "Stopped"
+ : "Will try again"}
{task.error}
) : null}
- {task.state === "skipped" ? (
-
- Nothing was written
-
- The agent judged there was nothing worth posting, so Loopable stayed quiet.
-
-
- ) : null}
-
{task.output ? (
-
- {task.state === "done" ? "What was written" : "What the agent wrote"}
+
+ {task.state === "done" ? "Written" : task.state === "skipped" ? "Nothing to write" : "Result"}
{task.resultUrl ? (
}
>
- See it on {writer?.name ?? task.actionConnectorId}
+ Open in {writer?.name ?? task.actionConnectorId}
) : null}
- {task.output}
+ {task.output}
{task.comments.length > 0 ? : null}
+ ) : task.state === "skipped" ? (
+
The agent had nothing to say, so nothing was written.
) : null}
- {log ?
: null}
+ {log ?
: null}
-
-
- How this ran
-
-
-
- {task.sourceKind === "prompt" ? (
- {KIND_LABEL.prompt}
- ) : task.sourceUrl ? (
-
- {KIND_LABEL[task.sourceKind]} {task.sourceRef}
-
-
- ) : (
-
- {KIND_LABEL[task.sourceKind]} {task.sourceRef}
-
- )}
-
+
+
+ {task.sourceKind === "prompt" ? null : (
+ {destinationLabel(task.actionConnectorId, task.actionId)}
+ )}
+ {new Date(task.createdAt).toLocaleString()}
+ {task.durationMs ? {(task.durationMs / 1000).toFixed(1)}s
: null}
+ {task.attempts > 1 ? {task.attempts}
: null}
{task.prompt ? (
-
-
Prompt
-
+
+
{task.prompt}
-
- ) : null}
- {task.sourceKind === "prompt" ? null : (
-
- {action?.name ?? task.actionId}
- {task.actionConnectorId === task.connectorId
- ? null
- : ` on ${writer?.name ?? task.actionConnectorId}`}
- )}
- {task.agentId ?? "unknown"}
- {new Date(task.createdAt).toLocaleString()}
- {task.attempts > 1 ? {task.attempts}
: null}
- {task.durationMs ? (
- {(task.durationMs / 1000).toFixed(1)}s
) : null}
{task.agentCommand ? (
-
-
Command
-
- {task.agentCommand}
-
-
+
+ {task.agentCommand}
+
) : null}
-
-
+
+
);
}
+/** A titled block that opens and closes, for what is only sometimes worth reading. */
+function Fold({
+ title,
+ open,
+ aside,
+ children,
+}: {
+ title: string;
+ open?: boolean;
+ aside?: React.ReactNode;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+ {aside}
+
+ {children}
+
+ );
+}
+
/**
* The findings that go on the lines rather than in the body. Shown separately
* because that is what they are: on GitHub they will be spread across the
@@ -181,9 +170,7 @@ function Comments({ comments }: { comments: Finding[] }) {
return (
- {comments.length === 1
- ? "One comment, on the line it is about"
- : `${comments.length} comments, each on the line it is about`}
+ {comments.length === 1 ? "1 line comment" : `${comments.length} line comments`}
{comments.map((comment, index) => (
@@ -193,7 +180,7 @@ function Comments({ comments }: { comments: Finding[] }) {
{comment.path}:{comment.startLine ? `${comment.startLine}-${comment.line}` : comment.line}
-
+
{comment.body}
@@ -207,7 +194,7 @@ function Comments({ comments }: { comments: Finding[] }) {
* tells you nothing; this is what tells you the run is alive, and afterwards
* it is the only place that explains a failure.
*/
-function AgentLog({ log, live }: { log: string; live: boolean }) {
+function AgentLog({ log, live, open }: { log: string; live: boolean; open: boolean }) {
const box = useRef
(null);
// Stay at the bottom while it is still being written, the way a tail does.
@@ -216,25 +203,25 @@ function AgentLog({ log, live }: { log: string; live: boolean }) {
}, [live, log]);
return (
-
-
- Agent log
- {live ? (
-
+
- still running
+ Running
- ) : null}
-
-
-
- {log}
-
-
-
+ ) : null
+ }
+ >
+
+ {log}
+
+
);
}
@@ -268,9 +255,9 @@ function StopButton({ task }: { task: TaskView }) {
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
-
-
{label}
-
{children}
+
+
{label}
+ {children}
);
}
diff --git a/src/routes/inbox/index.tsx b/src/routes/inbox/index.tsx
index 439efd1..ba717c5 100644
--- a/src/routes/inbox/index.tsx
+++ b/src/routes/inbox/index.tsx
@@ -1,11 +1,19 @@
-import { Link, createFileRoute, useRouter } from "@tanstack/react-router";
-import { Play } from "lucide-react";
+import { createFileRoute, useRouter } from "@tanstack/react-router";
+import { MessageSquarePlus, Play } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
+import { PageHeader } from "@/components/page";
import { TaskList } from "@/components/task-list";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader } from "@/components/ui/card";
-import { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
import {
Select,
SelectContent,
@@ -26,34 +34,20 @@ function InboxPage() {
return (
-
- Inbox
-
- Every run, in the order it happened, whether it wrote something or decided there was
- nothing to say.
-
-
-
-
-
-
}
/>
-
- {tasks.length === 0 ? (
-
-
- Go to loops
-
-
- ) : null}
+
);
}
-function PromptRunBox({ agents }: { agents: Array<{ agentId: string; name: string }> }) {
+/** Ask an agent something once. The reply stays in Inbox; nothing is written back. */
+function AskAgent({ agents }: { agents: Array<{ agentId: string; name: string }> }) {
const router = useRouter();
+ const [open, setOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [agentId, setAgentId] = useState(agents[0]?.agentId ?? "");
const [running, setRunning] = useState(false);
@@ -72,6 +66,7 @@ function PromptRunBox({ agents }: { agents: Array<{ agentId: string; name: strin
await runPrompt({ data: { prompt, agentId } });
await router.invalidate();
setPrompt("");
+ setOpen(false);
toast.success("Queued");
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
@@ -81,49 +76,52 @@ function PromptRunBox({ agents }: { agents: Array<{ agentId: string; name: strin
};
return (
-
- Run a prompt
-
-
- Prompt
-
-
-
-
Agent
- {agents.length === 0 ? (
-
- Start a runner with an agent signed in, then you can run a prompt.
-
- ) : (
-
value && setAgentId(value)}>
-
-
- {(value: string) => agents.find((agent) => agent.agentId === value)?.name ?? value}
-
-
-
- {agents.map((agent) => (
-
- {agent.name}
-
- ))}
-
-
- )}
-
-
+
+ }>
+
+ Ask an agent
+
+
+
+ Ask an agent
+ Runs once. The reply stays in Inbox.
+
+
-
-
+
+
+
);
}
diff --git a/src/routes/loops/$loopId.tsx b/src/routes/loops/$loopId.tsx
index 519606f..696aa10 100644
--- a/src/routes/loops/$loopId.tsx
+++ b/src/routes/loops/$loopId.tsx
@@ -1,24 +1,41 @@
-import { Link, createFileRoute, notFound, useRouter } from "@tanstack/react-router";
+import { createFileRoute, notFound, useNavigate, useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
-import { ArrowLeft, CircleAlert, Eye, Play, RefreshCw } from "lucide-react";
+import { ChevronRight, CircleAlert, Play, RefreshCw, Trash2 } from "lucide-react";
+import { ConnectorIcon } from "@/components/connector-icon";
import { LoopForm } from "@/components/loop-form";
+import { LinkTabs, List, PageHeader, Section } from "@/components/page";
import { TaskList } from "@/components/task-list";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { connectorManifest } from "@/connectors/manifests.ts";
-import type { LoopPollState } from "@/lib/domain.ts";
+import { agentManifest } from "@/agents/manifests.ts";
+import { connectorManifest, destinationLabel } from "@/connectors/manifests.ts";
+import type { LoopPollState, LoopView } from "@/lib/domain.ts";
import { issuesFor } from "@/lib/gaps.ts";
+import { ago } from "@/lib/time.ts";
import { useLiveRefresh } from "@/components/use-live-tasks.ts";
-import { getLoopById, getLoopPollState, getLoopReadiness, lookLoopNow, runLoopBacklog } from "@/server/functions/loops.ts";
+import {
+ getLoopById,
+ getLoopPollState,
+ getLoopReadiness,
+ lookLoopNow,
+ removeLoop,
+ runLoopBacklog,
+ toggleLoop,
+} from "@/server/functions/loops.ts";
import { getLoopTasks, runLoopNow } from "@/server/functions/tasks.ts";
+type Tab = "tasks" | "settings";
+
export const Route = createFileRoute("/loops/$loopId")({
+ // The tab lives in the URL so either one can be linked to. Settings is the
+ // default and stays out of the URL.
+ validateSearch: (search: Record
): { tab?: Tab } =>
+ search.tab === "tasks" ? { tab: "tasks" } : {},
loader: async ({ params }) => {
const loop = await getLoopById({ data: { id: params.loopId } });
if (!loop) throw notFound();
@@ -32,27 +49,47 @@ export const Route = createFileRoute("/loops/$loopId")({
component: LoopPage,
});
+function useAction() {
+ const router = useRouter();
+ const [busy, setBusy] = useState(null);
+ const run = async (key: string, work: () => Promise) => {
+ setBusy(key);
+ try {
+ const message = await work();
+ await router.invalidate();
+ if (message) toast.success(message);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : String(error));
+ } finally {
+ setBusy(null);
+ }
+ };
+ return { busy, run };
+}
+
function LoopPage() {
const { loop, tasks, poll, readiness } = Route.useLoaderData();
+ const { tab = "settings" } = Route.useSearch();
useLiveRefresh();
const issues = issuesFor(readiness, loop);
+ const connector = connectorManifest(loop.connectorId);
return (
-
: null}
+ title={loop.name}
+ actions={
}
>
-
- Loops
-
-
{loop.name}
+
+
{issues.length > 0 ? (
- This loop cannot run yet
-
+ Cannot run yet
+
{issues.map((line) => (
{line}
))}
@@ -60,254 +97,252 @@ function LoopPage() {
) : null}
-
-
- {/* Opening a loop is nearly always about changing it; its runs are in the
- inbox too. */}
-
-
- Settings
- Tasks
-
+ {loop.enabled && poll.pollError ? (
+
+
+ The last check failed
+ {poll.pollError}
+
+ ) : null}
-
-
-
+
+ Tasks
+ {tasks.length > 0 ? (
+ {tasks.length}
+ ) : null}
+ >
+ ),
+ to: "/loops/$loopId",
+ params: { loopId: loop.id },
+ search: { tab: "tasks" },
+ active: tab === "tasks",
+ },
+ ]}
+ />
-
-
-
-
-
+ {tab === "settings" ? (
+
+
+
+
+ ) : (
+
+
+
+
+
+ )}
);
}
-function LookNowButton({ loopId }: { loopId: string }) {
- const router = useRouter();
- const [busy, setBusy] = useState(false);
+/** One line under the title: where it answers, with what, and when it last looked. */
+function Summary({ loop, poll }: { loop: LoopView; poll: LoopPollState }) {
+ const agent = loop.agentId ? agentManifest(loop.agentId) : undefined;
+ const parts = [
+ destinationLabel(loop.actionConnectorId, loop.actionId),
+ agent?.name ?? "Default agent",
+ loop.enabled ? (poll.polledAt ? `Checked ${ago(poll.polledAt)}` : "Not checked yet") : null,
+ ].filter(Boolean);
return (
- {
- setBusy(true);
- try {
- const report = await lookLoopNow({ data: { id: loopId } });
- await router.invalidate();
- if (report.error) {
- toast.error(report.error);
- } else {
- toast.success("Looked");
- }
- } catch (error) {
- toast.error(error instanceof Error ? error.message : String(error));
- } finally {
- setBusy(false);
- }
- }}
- >
-
- {busy ? "Looking..." : "Look now"}
-
+
+ {loop.enabled ? null : Off }
+ {parts.join(" · ")}
+
);
}
-function when(iso: string): string {
- return new Date(iso).toLocaleString();
+function HeaderActions({ loop }: { loop: LoopView }) {
+ const { busy, run } = useAction();
+ const byHand = connectorManifest(loop.connectorId)?.byHand.kind ?? "none";
+
+ return (
+ <>
+ {byHand === "now" ? (
+
+ run("run", async () => {
+ await runLoopNow({ data: { loopId: loop.id, url: "", dryRun: false } });
+ return "Queued";
+ })
+ }
+ >
+
+ Run now
+
+ ) : (
+
+ run("look", async () => {
+ const report = await lookLoopNow({ data: { id: loop.id } });
+ if (report.error) throw new Error(report.error);
+ return "Checked";
+ })
+ }
+ >
+
+ Check now
+
+ )}
+
+
+ run("toggle", async () => {
+ await toggleLoop({ data: { id: loop.id, enabled } });
+ return enabled ? "Turned on" : "Turned off";
+ })
+ }
+ />
+ {loop.enabled ? "On" : "Off"}
+
+ >
+ );
}
/**
- * Whether the loop is really watching is the one thing a page cannot work out
- * for itself, and the thing a person most wants to know after turning one on.
+ * What matched but was never run: open before the loop existed, or held back
+ * by one of its rules. Neither runs by itself, so it stays folded into one
+ * line, with the reason said in the line.
*/
-function Watching({
- loop,
- poll,
-}: {
- loop: { id: string; enabled: boolean };
- poll: LoopPollState;
-}) {
- const router = useRouter();
- const [busy, setBusy] = useState(false);
-
- if (!loop.enabled) {
- return (
-
- This loop is off
- Nothing is being watched for until you turn it back on.
-
- );
- }
+function NotRun({ loop, poll }: { loop: LoopView; poll: LoopPollState }) {
+ const { busy, run } = useAction();
+ if (poll.backlog.length === 0) return null;
- if (poll.pollError) {
- return (
-
-
- The last look did not work
-
- {poll.pollError}
-
-
-
- );
- }
+ const held = poll.backlog.filter((item) => item.hold).length;
+ const before = poll.backlog.length - held;
+ const reasons = [
+ before > 0 ? `${before} ${before === 1 ? "was" : "were"} open before this loop` : null,
+ held > 0 ? `${held} held back by a rule` : null,
+ ].filter(Boolean);
return (
-
-
-
-
-
-
- {poll.polledAt
- ? `Watching. Last looked ${when(poll.polledAt)}.`
- : "Not looked yet. The dispatcher checks every couple of minutes; the first look records what is already waiting without running it."}
-
-
-
-
-
- {poll.backlog.length > 0 ? (
-
-
-
-
- {poll.backlog.length === 1
- ? "One thing is waiting"
- : `${poll.backlog.length} things are waiting`}
-
-
- Matched, but not run: either it was already there before the loop was made, or
- it was held back for the reason shown. Run them if you want them dealt with too.
-
-
-
{
- setBusy(true);
- try {
- const { queued } = await runLoopBacklog({ data: { id: loop.id } });
- await router.invalidate();
- toast.success(queued === 1 ? "Queued 1 run" : `Queued ${queued} runs`);
- } catch (error) {
- toast.error(error instanceof Error ? error.message : String(error));
- } finally {
- setBusy(false);
- }
- }}
- >
- {busy ? "Queueing..." : "Run these too"}
-
+ // The browser may restore a fold it remembers as open before React takes
+ // over, which is harmless and not worth a warning.
+
+
+
+ {poll.backlog.length} not run · {reasons.join(", ")}
+
+
+ {poll.backlog.map((item) => (
+
+
+ {item.sourceRef} {item.sourceTitle}
+ {item.hold ? (
+ · {item.hold}
+ ) : null}
-
- {poll.backlog.slice(0, 8).map((item) => (
-
- {item.sourceRef} · {item.sourceTitle}
- {item.hold ? · {item.hold} : null}
-
- ))}
- {poll.backlog.length > 8 ? and {poll.backlog.length - 8} more : null}
-
+
+ run(item.key, async () => {
+ await runLoopBacklog({ data: { id: loop.id, key: item.key } });
+ return "Queued";
+ })
+ }
+ >
+
+ Run
+
- ) : null}
-
-
+ ))}
+
+
);
}
-/**
- * A loop that runs by itself still has to be shaped, and waiting for a real
- * signal to arrive is a slow way to do it. Dry run means that shaping leaves
- * no marks on a real repository.
- */
-function RunBox({ loop }: { loop: { id: string; connectorId: string } }) {
- const router = useRouter();
+/** Run the loop on one link, by hand, without waiting for something to arrive. */
+function TryOnLink({ loop }: { loop: LoopView }) {
+ const { busy, run } = useAction();
const [url, setUrl] = useState("");
const [dryRun, setDryRun] = useState(true);
- const [running, setRunning] = useState(false);
const manifest = connectorManifest(loop.connectorId);
- const byHand = manifest?.byHand ?? { kind: "none" as const };
+ if (manifest?.byHand.kind !== "link") return null;
- const run = async () => {
- setRunning(true);
- try {
- await runLoopNow({ data: { loopId: loop.id, url: url.trim(), dryRun } });
- await router.invalidate();
- setUrl("");
- toast.success(dryRun ? "Queued as a dry run" : "Queued");
- } catch (error) {
- toast.error(error instanceof Error ? error.message : String(error));
- } finally {
- setRunning(false);
- }
- };
+ return (
+
+ );
+}
- // Nothing here can start a run, so the card would be a box that only makes
- // errors. Saying why is more use than offering one.
- if (byHand.kind === "none") {
- return (
-
-
- This loop can only run on something arriving, so there is nothing to start by hand. What
- it does with what arrives is worth trying on the first one, with dry run left on.
-
-
- );
- }
+function DeleteLoop({ loop }: { loop: LoopView }) {
+ const navigate = useNavigate();
+ const [busy, setBusy] = useState(false);
return (
-
-
-
- {byHand.kind === "now" ? (
- <>
-
Run it now, without waiting for the time
-
-
- Runs exactly as it would when the clock came round, and does not count as that
- run: the next scheduled one still happens.
-
-
-
- Run
-
-
- >
- ) : (
- <>
-
Try this loop on a {manifest?.name ?? ""} link
-
-
setUrl(event.target.value)}
- />
-
-
- Run
-
-
- >
- )}
-
-
-
-
Dry run
-
- {dryRun
- ? "Prepare the result and show it to you, without writing anything."
- : "Write the result back, exactly as the loop would on its own."}
-
-
-
-
-
-
+
+
+
Its runs are deleted with it.
+
{
+ if (!window.confirm(`Delete ${loop.name}?`)) return;
+ setBusy(true);
+ try {
+ await removeLoop({ data: { id: loop.id } });
+ toast.success(`Deleted ${loop.name}`);
+ await navigate({ to: "/loops" });
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : String(error));
+ setBusy(false);
+ }
+ }}
+ >
+
+ Delete loop
+
+
+
);
}
diff --git a/src/routes/loops/index.tsx b/src/routes/loops/index.tsx
index 4238aac..8a733e3 100644
--- a/src/routes/loops/index.tsx
+++ b/src/routes/loops/index.tsx
@@ -1,15 +1,22 @@
import { Link, createFileRoute, useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
-import { ArrowDown, ArrowUp, CircleAlert, Plus, Trash2 } from "lucide-react";
+import { ArrowDown, ArrowUp, CircleAlert, MoreHorizontal, Plus, Trash2 } from "lucide-react";
import { ConnectorIcon } from "@/components/connector-icon";
+import { Empty, List, PageHeader } from "@/components/page";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch";
import { agentManifest } from "@/agents/manifests.ts";
-import { connectorAction, connectorManifest, connectorWorkflow } from "@/connectors/manifests.ts";
+import { connectorManifest, connectorWorkflow, destinationLabel } from "@/connectors/manifests.ts";
import type { LoopReadiness, LoopView } from "@/lib/domain.ts";
import { gapsFor, issuesFor } from "@/lib/gaps.ts";
import { useLiveRefresh } from "@/components/use-live-tasks.ts";
@@ -26,96 +33,75 @@ function LoopsPage() {
return (
-
+
}>
+
+ New loop
+
+ }
+ />
{loops.length === 0 ? (
-
-
-
- No loops yet, so nothing is being watched for.
-
- }>
- See what Loopable can do
+ }>
+ New loop
-
-
- ) : null}
-
- {loops.map((loop, index) => (
-
- ))}
+ }
+ >
+ No loops yet.
+
+ ) : (
+
+ {loops.map((loop, index) => (
+
+ ))}
+
+ )}
);
}
-/** Says what a loop still needs, without standing in the way of writing one. */
+/** What still stands between these loops and running. */
function Gaps({ readiness, loops }: { readiness: LoopReadiness; loops: LoopView[] }) {
const missing = gapsFor(readiness, loops);
if (missing.length === 0) return null;
return (
- Loops can be written, but nothing will run yet
-
+
+ Nothing will run yet
+
{missing.map((line) => (
{line}
))}
-
-
- Connectors
-
-
- Agents
-
-
- Runners
-
-
);
}
-/**
- * Where this loop writes, in its own words rather than the workflow's. A loop
- * may have been pointed somewhere else since it was made, and the card is the
- * one place someone scanning a list would notice.
- */
-function writesOf(loop: LoopView): string {
- const action = connectorAction(loop.actionConnectorId, loop.actionId);
- if (!action) return `it writes with ${loop.actionId}, which is no longer offered`;
- // Named only when it differs from where the loop watches, since saying
- // "on GitHub" twice in one sentence reads as though something moved.
- const writer = connectorManifest(loop.actionConnectorId);
- const where =
- loop.actionConnectorId === loop.connectorId
- ? ""
- : ` on ${writer?.name ?? loop.actionConnectorId}`;
- return `it will ${action.name.toLowerCase()}${where}`;
+/** Where this loop answers and with what, in a few words. */
+function summaryOf(loop: LoopView): string {
+ if (!connectorWorkflow(loop.connectorId, loop.workflowId)) return "Workflow no longer offered";
+ const agent = loop.agentId ? agentManifest(loop.agentId) : undefined;
+ return [destinationLabel(loop.actionConnectorId, loop.actionId), agent?.name ?? "Default agent"].join(
+ " · ",
+ );
}
-function LoopCard({
+function LoopRow({
loop,
readiness,
position,
@@ -130,8 +116,6 @@ function LoopCard({
}) {
const router = useRouter();
const manifest = connectorManifest(loop.connectorId);
- const workflow = connectorWorkflow(loop.connectorId, loop.workflowId);
- const agent = loop.agentId ? agentManifest(loop.agentId) : undefined;
const issues = issuesFor(readiness, loop);
const [busy, setBusy] = useState(false);
@@ -149,80 +133,80 @@ function LoopCard({
};
return (
-
-
- {position}
- {manifest ? (
-
- ) : null}
-
-
-
- {loop.name}
-
- {loop.enabled ? null : Off }
- {issues.length > 0 ? Cannot run : null}
-
-
- {workflow
- ? `When ${workflow.trigger}, ${writesOf(loop)}${agent ? `, using ${agent.name}` : ""}.`
- : `Built on ${loop.workflowId}, which is no longer offered.`}
-
- {issues.length > 0 ? (
-
-
- {issues[0]}
-
- ) : null}
+
+
+ {position}
+
+ {manifest ? (
+
+ ) : null}
+
+
+ {loop.name}
+ {loop.enabled ? null : Off }
-
-
0 ? (
+
+
+ {issues[0]}
+
+ ) : (
+ {summaryOf(loop)}
+ )}
+
+
+ run(
+ () => toggleLoop({ data: { id: loop.id, enabled } }),
+ enabled ? `${loop.name} is on` : `${loop.name} is off`,
+ )
+ }
+ />
+
+ }
+ >
+
+
+
+ run(() => reorderLoop({ data: { id: loop.id, direction: "up" } }))}
>
-
-
+ run(() => reorderLoop({ data: { id: loop.id, direction: "down" } }))}
>
-
-
- run(
- () => toggleLoop({ data: { id: loop.id, enabled } }),
- enabled ? `${loop.name} is on` : `${loop.name} is off`,
- )
- }
- />
- run(() => removeLoop({ data: { id: loop.id } }), `Deleted ${loop.name}`)}
+ Move down
+
+
+ {
+ if (!window.confirm(`Delete ${loop.name}? Its runs are deleted with it.`)) return;
+ void run(() => removeLoop({ data: { id: loop.id } }), `Deleted ${loop.name}`);
+ }}
>
-
-
-
- {/* What the loop does is the workflow's, and is said above. The only
- thing here worth repeating is what this person added to it. */}
- {loop.guidance ? (
-
- {loop.guidance}
-
- ) : null}
-
+ Delete
+
+
+
+
);
}
diff --git a/src/routes/loops/new.tsx b/src/routes/loops/new.tsx
index 4b9170e..6c2a24c 100644
--- a/src/routes/loops/new.tsx
+++ b/src/routes/loops/new.tsx
@@ -1,35 +1,52 @@
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
-import { ArrowLeft, ArrowRight } from "lucide-react";
+import { PencilLine } from "lucide-react";
import { ConnectorIcon } from "@/components/connector-icon";
import { LoopForm } from "@/components/loop-form";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
-import { allWorkflows, connectorManifest, connectorWorkflow } from "@/connectors/manifests.ts";
+import { Field, PageHeader } from "@/components/page";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { allTriggers, allWorkflows, connectorManifest, connectorWorkflow } from "@/connectors/manifests.ts";
+import type { ConnectorManifest, WorkflowDescriptor } from "@/connectors/types.ts";
+import type { LoopView } from "@/lib/domain.ts";
import { draftForWorkflow } from "@/lib/loop-draft.ts";
import { getLoopsPage } from "@/server/functions/loops.ts";
+type Search = { connector?: string; workflow?: string; custom?: boolean };
+
export const Route = createFileRoute("/loops/new")({
// Which workflow is being shaped lives in the URL rather than in state, so
// the back button steps out of the form and a half-filled one is never
// something on the server has to remember.
- validateSearch: (search: Record
) => {
- const result: { connector?: string; workflow?: string } = {};
+ validateSearch: (search: Record): Search => {
+ const result: Search = {};
if (typeof search.connector === "string") result.connector = search.connector;
if (typeof search.workflow === "string") result.workflow = search.workflow;
+ if (search.custom === true || search.custom === "true" || search.custom === 1) result.custom = true;
return result;
},
loader: async () => ({ loops: (await getLoopsPage()).loops }),
component: NewLoopPage,
});
+/** The trigger a custom loop starts on before anything is picked. */
+const CUSTOM_DEFAULT = { connector: "slack", workflow: "slack.ask" };
+
function NewLoopPage() {
- const { connector, workflow } = Route.useSearch();
+ const { connector, workflow, custom } = Route.useSearch();
const chosen =
connector && workflow && connectorWorkflow(connector, workflow)
? { connector, workflow }
: null;
+ if (custom) {
+ const start = chosen ?? CUSTOM_DEFAULT;
+ return ;
+ }
return chosen ? (
) : (
@@ -38,105 +55,182 @@ function NewLoopPage() {
}
/**
- * The second step, and the one that makes a loop.
- *
- * Nothing has been written down yet: the draft is worked out here from the
- * workflow the URL names, and the loop begins when the form is saved. Picking
- * a workflow used to create it, which meant a look around this page left a
- * live loop behind, enabled and with none of its questions answered.
+ * The second step, and the one that makes a loop. Nothing is written down
+ * until the form is saved.
*/
function ShapeLoop({ connectorId, workflowId }: { connectorId: string; workflowId: string }) {
const workflow = connectorWorkflow(connectorId, workflowId)!;
const connector = connectorManifest(connectorId);
return (
-
-
-
- New loop
-
-
+
+
: null}
+ title={workflow.name}
+ description={workflow.summary}
+ />
);
}
/**
- * There is no blank loop to write any more. Every loop is one of the
- * workflows a connector offers, already knowing its own job, so choosing is
- * the first half of making one and the settings are the second.
+ * A loop with your own prompt. Underneath it is still one of the workflows,
+ * since a workflow is what knows how to watch for something and read the
+ * answer, but it starts with an empty prompt and a trigger you pick.
+ */
+function CustomLoop({ connectorId, workflowId }: { connectorId: string; workflowId: string }) {
+ const navigate = useNavigate();
+ const draft = {
+ ...draftForWorkflow(connectorId, workflowId),
+ name: "Custom loop",
+ prompt: "",
+ };
+
+ const picker = (
+
+ {
+ if (!value) return;
+ const [connector, workflow] = value.split("::") as [string, string];
+ void navigate({
+ to: "/loops/new",
+ search: { custom: true, connector, workflow },
+ replace: true,
+ });
+ }}
+ >
+
+
+ {() => {
+ const workflow = connectorWorkflow(connectorId, workflowId);
+ return workflow?.when ?? workflowId;
+ }}
+
+
+
+ {allTriggers().map(({ connector, trigger }) => (
+
+ {trigger.when}
+
+ ))}
+
+
+
+ );
+
+ return (
+
+
}
+ title="Custom loop"
+ description="Pick what starts it, then say what the agent should do."
+ />
+ {/* Keyed on the trigger: each one has its own fields and its own place
+ to answer, so changing it starts the form over. */}
+
+
+ );
+}
+
+function CustomIcon() {
+ return (
+
+
+
+ );
+}
+
+/** How many loops already use a workflow, said as a sentence rather than a count. */
+function inUse(loops: LoopView[], workflowId: string): string | null {
+ const count = loops.filter((loop) => loop.workflowId === workflowId).length;
+ if (count === 0) return null;
+ return count === 1 ? "You have 1 loop like this" : `You have ${count} loops like this`;
+}
+
+function ChoiceCard({
+ icon,
+ title,
+ summary,
+ note,
+ ...link
+}: {
+ icon: React.ReactNode;
+ title: string;
+ summary: string;
+ note?: string | null;
+ search: Search;
+}) {
+ return (
+
+ {icon}
+
+
{title}
+
{summary}
+ {note ?
{note}
: null}
+
+
+ );
+}
+
+function workflowCard(
+ connector: ConnectorManifest,
+ workflow: WorkflowDescriptor,
+ loops: LoopView[],
+) {
+ return (
+
}
+ title={workflow.name}
+ summary={workflow.summary}
+ note={inUse(loops, workflow.id)}
+ search={{ connector: connector.id, workflow: workflow.id }}
+ />
+ );
+}
+
+/**
+ * Every loop is one of the workflows a connector offers, or a custom one
+ * built on a workflow's trigger. Custom and Schedule come first: they are the
+ * two not tied to something arriving from a service.
*/
function ChooseWorkflow() {
const { loops } = Route.useLoaderData();
- const navigate = useNavigate();
+ const all = allWorkflows();
+ const scheduled = all.filter(({ connector }) => connector.id === "schedule");
+ const rest = all.filter(({ connector }) => connector.id !== "schedule");
return (
-
-
- Loops
-
-
- New loop
-
- Pick what you want done. You can narrow it down to certain repositories, and add anything
- particular to your team, on the next screen.
-
-
+
+
+
+
+
+
+ }
+ title="Custom loop"
+ summary="Pick a trigger and write your own prompt."
+ search={{ custom: true }}
+ />
+ {scheduled.map(({ connector, workflow }) => workflowCard(connector, workflow, loops))}
+
- {allWorkflows().map(({ connector, workflow }) => {
- const existing = loops.filter((loop) => loop.workflowId === workflow.id).length;
- return (
-
-
-
-
-
-
{workflow.name}
- {existing > 0 ? (
-
- {existing === 1 ? "Already have one" : `Already have ${existing}`}
-
- ) : null}
-
-
{workflow.summary}
-
-
-
- When {workflow.trigger}, it writes {workflow.writes}.
-
-
-
-
- void navigate({
- to: "/loops/new",
- search: { connector: connector.id, workflow: workflow.id },
- })
- }
- >
- Set up
-
-
-
- );
- })}
+
From a connector
+
+ {rest.map(({ connector, workflow }) => workflowCard(connector, workflow, loops))}
+
);
diff --git a/src/routes/runners/index.tsx b/src/routes/runners/index.tsx
index b6b3616..d9e8471 100644
--- a/src/routes/runners/index.tsx
+++ b/src/routes/runners/index.tsx
@@ -1,10 +1,11 @@
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { toast } from "sonner";
-import { Server } from "lucide-react";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Badge } from "@/components/ui/badge";
+import { Copy, RotateCcw, Server } from "lucide-react";
+import { Empty, List, PageHeader } from "@/components/page";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { agentManifest } from "@/agents/manifests.ts";
+import { cn } from "@/lib/utils";
+import { VERSION } from "@/lib/version.ts";
import { useLiveRefresh } from "@/components/use-live-tasks.ts";
import type { RunnerView } from "@/lib/domain.ts";
import { getRunnersPage, removeRunner, rotateRunnerJoinToken } from "@/server/functions/runners.ts";
@@ -14,107 +15,124 @@ export const Route = createFileRoute("/runners/")({
component: RunnersPage,
});
-function inventoryLines(runner: RunnerView) {
- if (runner.inventory.length === 0) {
- return
No coding agents found yet.
;
- }
- return (
-
- {runner.inventory.map((entry) => (
-
- {entry.agentId}
- {entry.signedIn ? " · signed in" : entry.installed ? " · not signed in" : " · missing"}
-
- ))}
-
- );
+/** Which agents this runner has, in a few words. */
+function agentsOn(runner: RunnerView): string {
+ if (runner.inventory.length === 0) return "No agents found";
+ return runner.inventory
+ .map((entry) => {
+ const name = agentManifest(entry.agentId)?.name ?? entry.agentId;
+ return entry.signedIn ? name : entry.installed ? `${name} (not signed in)` : `${name} (missing)`;
+ })
+ .join(", ");
}
function RunnersPage() {
const { runners, joinToken, origin } = Route.useLoaderData();
const router = useRouter();
useLiveRefresh();
+ const commands = [
+ "npm install -g loopable-cli",
+ `loopable runner --url ${origin} --token ${joinToken}`,
+ ].join("\n");
return (
-
-
+
+
-
- Join a runner
-
-
- Install the CLI once (npm install -g loopable-cli),
- sign in a coding agent on that host, then start a runner. Use this LAN IP so
- other machines can reach the App.
-
-
- {`loopable runner --url ${origin} --token ${joinToken}`}
-
-
- {
- try {
- await rotateRunnerJoinToken();
- await router.invalidate();
- toast.success("Join token rotated. Existing runners keep working.");
- } catch (error) {
- toast.error(error instanceof Error ? error.message : String(error));
- }
- }}
- >
- Rotate join token
-
-
-
-
+
+
+
Join a runner
+ {
+ if (!window.confirm("Rotate the join token? Runners already joined keep working.")) return;
+ try {
+ await rotateRunnerJoinToken();
+ await router.invalidate();
+ toast.success("Join token rotated");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : String(error));
+ }
+ }}
+ >
+
+ Rotate token
+
+
+
+
{commands}
+
{
+ await navigator.clipboard.writeText(commands);
+ toast.success("Copied");
+ }}
+ >
+
+
+
+
+ Needs Node 22+ and an agent CLI signed in on that machine. Skip the first line if the CLI
+ is already installed.
+
+
{runners.length === 0 ? (
-
-
- No runner has joined yet. Loops will wait until one is online.
-
-
+
No runner has joined yet. Tasks wait until one is online.
) : (
-
+
{runners.map((runner) => (
-
-
-
-
-
-
{runner.name}
-
- {runner.status}
-
-
-
{runner.hostname}
- {inventoryLines(runner)}
+
+
+
+
+
+
+ {runner.name}
+
+
+ {runner.status === "online" ? "Online" : "Offline"}
+
-
{
- try {
- await removeRunner({ data: { id: runner.id } });
- await router.invalidate();
- } catch (error) {
- toast.error(error instanceof Error ? error.message : String(error));
- }
- }}
- >
- Forget
-
-
-
+
+ {runner.hostname} · {runner.version ? `v${runner.version}` : "version unknown"} ·{" "}
+ {agentsOn(runner)}
+
+ {runner.outdated ? (
+
+ Older than this App (v{VERSION}), so it may not take every job. On that machine,
+ run npm install -g loopable-cli and restart the runner.
+
+ ) : null}
+
+
{
+ try {
+ await removeRunner({ data: { id: runner.id } });
+ await router.invalidate();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : String(error));
+ }
+ }}
+ >
+ Forget
+
+
))}
-
+
)}
);
diff --git a/src/runner/client.ts b/src/runner/client.ts
index f81238f..01dc117 100644
--- a/src/runner/client.ts
+++ b/src/runner/client.ts
@@ -5,9 +5,13 @@ import { join } from "node:path";
import { detectAgents } from "#/agents/inventory.ts";
import { runAgentJob } from "#/server/agent-runner.ts";
import type { AgentJob } from "#/server/agent-job.ts";
+import { RUNNER_PROTOCOL, VERSION } from "#/lib/version.ts";
type Hello = { runnerId: string; runnerToken: string; name: string };
+/** Said on joining and on every heartbeat, so the App knows which jobs this can take. */
+const BUILD = { version: VERSION, protocol: RUNNER_PROTOCOL };
+
async function json(url: string, token: string, path: string, body: unknown) {
const res = await fetch(`${url}${path}`, {
method: "POST",
@@ -60,12 +64,13 @@ export async function runRemoteLoop(input: { url: string; joinToken: string; log
const hello = (await json(url, input.joinToken, "/api/runners/hello", {
hostname: osHostname(),
inventory,
+ ...BUILD,
})) as Hello;
input.log(`joined as ${hello.name} (${hello.runnerId})`);
const token = hello.runnerToken;
async function heartbeat() {
- await json(url, token, "/api/runners/heartbeat", { inventory: await detectAgents() });
+ await json(url, token, "/api/runners/heartbeat", { inventory: await detectAgents(), ...BUILD });
}
await heartbeat();
diff --git a/src/server/agent-job.test.ts b/src/server/agent-job.test.ts
index 10ed052..6e2b795 100644
--- a/src/server/agent-job.test.ts
+++ b/src/server/agent-job.test.ts
@@ -1,13 +1,34 @@
import { describe, expect, it } from "vitest";
-import { filesFromWorkItem, isAgentNothing, jobRequiresHost } from "./agent-job.ts";
+import {
+ filesFromWorkItem,
+ isAgentNothing,
+ jobRequiresHost,
+ normalizeFolder,
+ resolveFolder,
+} from "./agent-job.ts";
describe("jobRequiresHost", () => {
- it("is only true when the job names a host folder", () => {
+ it("is only true for an old absolute folder", () => {
expect(jobRequiresHost({})).toBe(false);
+ expect(jobRequiresHost({ cwd: "code/web" })).toBe(false);
expect(jobRequiresHost({ cwd: "/Users/me/code" })).toBe(true);
});
});
+describe("folders", () => {
+ it("are kept relative to home, with or without ~/", () => {
+ expect(normalizeFolder(" ~/code/web ")).toBe("code/web");
+ expect(normalizeFolder("code/web")).toBe("code/web");
+ });
+
+ it("resolve under each runner's own home", () => {
+ expect(resolveFolder("code/web", "/Users/maya")).toBe("/Users/maya/code/web");
+ expect(resolveFolder("~/code/web", "/home/wei")).toBe("/home/wei/code/web");
+ // From before folders were relative: left as written.
+ expect(resolveFolder("/Users/me/code", "/home/wei")).toBe("/Users/me/code");
+ });
+});
+
describe("isAgentNothing", () => {
it("recognises the exact token, even with markdown wrapping", () => {
expect(isAgentNothing("NOTHING_TO_DO")).toBe(true);
diff --git a/src/server/agent-job.ts b/src/server/agent-job.ts
index 8c97fbb..8227abf 100644
--- a/src/server/agent-job.ts
+++ b/src/server/agent-job.ts
@@ -1,3 +1,5 @@
+import { isAbsolute, join } from "node:path";
+import { FOLDER_JOB_PROTOCOL } from "#/lib/version.ts";
import type { AgentSettingsValues } from "#/agents/types.ts";
export type AgentJobFile = { name: string; body: string };
@@ -34,11 +36,48 @@ export function isAgentNothing(text: string): boolean {
}
/**
- * Folder jobs name a path that only exists on Loopable's host.
- * A runner on another hostname cannot see that folder.
+ * Stands in a prompt for the runner's own workspace, which the dispatcher
+ * cannot know. The runner replaces it before the agent sees the prompt.
+ */
+export const WORKSPACE = "{{loopable:workspace}}";
+
+/**
+ * A loop's folder is relative to the home folder, because each runner keeps
+ * its checkouts under its own home. "~/code/web" and "code/web" are the same.
+ * An absolute path is from before that, and only means something on the host
+ * it was written on.
+ */
+export function normalizeFolder(folder: string): string {
+ const trimmed = folder.trim();
+ if (trimmed === "~") return "";
+ return trimmed.startsWith("~/") ? trimmed.slice(2) : trimmed;
+}
+
+export function folderIsLegacy(folder: string): boolean {
+ return isAbsolute(folder.trim());
+}
+
+/** Where a loop's folder is on the machine this runs on. */
+export function resolveFolder(folder: string, home: string): string {
+ const normal = normalizeFolder(folder);
+ return isAbsolute(normal) ? normal : join(home, normal);
+}
+
+/**
+ * Only an old absolute folder ties a job to Loopable's host. A relative one
+ * goes to any runner, which looks for it under its own home.
*/
export function jobRequiresHost(job: Pick
): boolean {
- return Boolean(job.cwd);
+ return Boolean(job.cwd && folderIsLegacy(job.cwd));
+}
+
+/**
+ * The oldest runner protocol that can do this job. A relative folder and the
+ * workspace placeholder are both protocol 2; an older runner would run the
+ * agent in the wrong place and hand it a path that does not exist.
+ */
+export function jobMinProtocol(job: Pick): number {
+ return job.cwd && !folderIsLegacy(job.cwd) ? FOLDER_JOB_PROTOCOL : 0;
}
export function filesFromWorkItem(item: { context: AgentJobFile[] }): AgentJobFile[] {
diff --git a/src/server/agent-runner.test.ts b/src/server/agent-runner.test.ts
index aeae137..b4365cb 100644
--- a/src/server/agent-runner.test.ts
+++ b/src/server/agent-runner.test.ts
@@ -8,6 +8,7 @@ process.env.LOOPABLE_HOME = home;
process.env.LOOPABLE_DB = join(home, "test.sqlite");
const { runAgentJob } = await import("./agent-runner.ts");
+const { WORKSPACE } = await import("./agent-job.ts");
const baseJob = {
taskId: "task-1",
@@ -42,6 +43,35 @@ describe("runAgentJob", () => {
);
});
+ it("fills in its own workspace where the prompt names context files", async () => {
+ const workspace = mkdtempSync(join(tmpdir(), "loopable-job-"));
+ const folder = mkdtempSync(join(tmpdir(), "loopable-folder-"));
+ await runAgentJob(
+ { ...baseJob, taskId: "task-4", cwd: folder, prompt: `Read ${WORKSPACE}/pr.md first.` },
+ {
+ workspace,
+ run: async (input) => {
+ expect(input.prompt).toBe(`Read ${workspace}/pr.md first.`);
+ return { ok: true, output: "ok", durationMs: 1, command: "codex exec" };
+ },
+ },
+ );
+ });
+
+ it("fails without running when this runner has no such folder", async () => {
+ const result = await runAgentJob(
+ { ...baseJob, taskId: "task-5", cwd: "no/such/folder-for-loopable-tests" },
+ {
+ workspace: mkdtempSync(join(tmpdir(), "loopable-job-")),
+ run: async () => {
+ throw new Error("should not run");
+ },
+ },
+ );
+ expect(result.ok).toBe(false);
+ expect(result.detail).toMatch(/There is no folder at .*no\/such\/folder-for-loopable-tests on this runner/);
+ });
+
it("runs in the folder the job names, even when a workspace is also given", async () => {
const folder = mkdtempSync(join(tmpdir(), "loopable-folder-"));
const workspace = mkdtempSync(join(tmpdir(), "loopable-job-"));
diff --git a/src/server/agent-runner.ts b/src/server/agent-runner.ts
index 3837a08..6609e44 100644
--- a/src/server/agent-runner.ts
+++ b/src/server/agent-runner.ts
@@ -1,8 +1,9 @@
-import { mkdirSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
import { join } from "node:path";
import { agentRuntime } from "#/agents/runtimes.ts";
import type { AgentRunResult } from "#/agents/types.ts";
-import type { AgentJob, AgentJobResult } from "./agent-job.ts";
+import { WORKSPACE, resolveFolder, type AgentJob, type AgentJobResult } from "./agent-job.ts";
import { runDir } from "./paths.ts";
export type RunAgentJobOptions = {
@@ -35,10 +36,23 @@ export async function runAgentJob(
}
const logFile = join(workspace, "agent.log");
const outputFile = join(workspace, "answer.txt");
- const cwd = job.cwd ?? workspace;
+ let cwd = workspace;
+ if (job.cwd) {
+ cwd = resolveFolder(job.cwd, homedir());
+ if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
+ return {
+ ok: false,
+ output: "",
+ detail: `There is no folder at ${cwd} on this runner.`,
+ durationMs: 0,
+ command: "",
+ };
+ }
+ }
const run = options.run ?? ((input) => agentRuntime(job.agentId).run(input));
const result = await run({
- prompt: job.prompt,
+ // Context files sit in this runner's workspace, which only it knows.
+ prompt: job.prompt.replaceAll(WORKSPACE, workspace),
cwd,
settings: job.settings,
outputFile,
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 33f7fcb..1dd8c6a 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -329,6 +329,10 @@ export const runners = sqliteTable("runners", {
status: text("status").$type<"online" | "offline">().notNull().default("offline"),
inventory: text("inventory", { mode: "json" }).$type().notNull().default([]),
lastSeenAt: integer("last_seen_at", { mode: "timestamp_ms" }),
+ /** The runner's loopable-cli version. Null for runners before 0.3. */
+ version: text("version"),
+ /** What jobs it understands; see RUNNER_PROTOCOL. Null means the first. */
+ protocol: integer("protocol"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
diff --git a/src/server/functions/agents.ts b/src/server/functions/agents.ts
index ceb3605..2e1f1c5 100644
--- a/src/server/functions/agents.ts
+++ b/src/server/functions/agents.ts
@@ -38,7 +38,7 @@ export const getAgentsPage = createServerFn({ method: "GET" }).handler(async ()
}));
export const saveAgent = createServerFn({ method: "POST" })
- .inputValidator(
+ .validator(
(data: {
agentId: string;
permissionMode: PermissionMode;
@@ -56,14 +56,14 @@ export const saveAgent = createServerFn({ method: "POST" })
});
export const chooseDefaultAgent = createServerFn({ method: "POST" })
- .inputValidator((data: { agentId: string }) => data)
+ .validator((data: { agentId: string }) => data)
.handler(async ({ data }) => {
await setDefaultAgent(data.agentId);
return await withRunners(await listAgents());
});
export const runAgentTest = createServerFn({ method: "POST" })
- .inputValidator((data: { agentId: string }) => data)
+ .validator((data: { agentId: string }) => data)
.handler(async ({ data }) => {
const result = await testAgent(data.agentId);
return {
diff --git a/src/server/functions/connectors.ts b/src/server/functions/connectors.ts
index e6774cf..c5e39ee 100644
--- a/src/server/functions/connectors.ts
+++ b/src/server/functions/connectors.ts
@@ -54,7 +54,7 @@ export const getConnectorOverview = createServerFn({ method: "GET" }).handler(
* provider's login works.
*/
export const beginQrLogin = createServerFn({ method: "POST" })
- .inputValidator((data: { connectorId: string }) => data)
+ .validator((data: { connectorId: string }) => data)
.handler(async ({ data }): Promise => {
const runtime = connectorRuntime(data.connectorId);
if (!runtime.auth.startQrLogin) {
@@ -73,7 +73,7 @@ export const beginQrLogin = createServerFn({ method: "POST" })
* confirmed login is saved here and the page is told only that it worked.
*/
export const continueQrLogin = createServerFn({ method: "POST" })
- .inputValidator((data: { connectorId: string; attempt: JsonValue }) => data)
+ .validator((data: { connectorId: string; attempt: JsonValue }) => data)
.handler(async ({ data }): Promise => {
const runtime = connectorRuntime(data.connectorId);
if (!runtime.auth.pollQrLogin) {
@@ -93,7 +93,7 @@ export const continueQrLogin = createServerFn({ method: "POST" })
* label, never the token back.
*/
export const connectWithToken = createServerFn({ method: "POST" })
- .inputValidator((data: { connectorId: string; fields: Record }) => data)
+ .validator((data: { connectorId: string; fields: Record }) => data)
.handler(async ({ data }) => {
const runtime = connectorRuntime(data.connectorId);
if (!runtime.auth.connectWithFields) {
@@ -105,16 +105,16 @@ export const connectWithToken = createServerFn({ method: "POST" })
});
export const disconnectConnection = createServerFn({ method: "POST" })
- .inputValidator((data: { id: string }) => data)
+ .validator((data: { id: string }) => data)
.handler(async ({ data }) => {
await removeConnection(data.id);
return { ok: true };
});
export const verifyConnection = createServerFn({ method: "POST" })
- .inputValidator((data: { id: string }) => data)
+ .validator((data: { id: string }) => data)
.handler(async ({ data }) => await checkConnection(data.id));
export const saveConnectionSettings = createServerFn({ method: "POST" })
- .inputValidator((data: { id: string; settings: ConnectionSettings }) => data)
+ .validator((data: { id: string; settings: ConnectionSettings }) => data)
.handler(async ({ data }) => await updateConnectionSettings(data.id, data.settings));
diff --git a/src/server/functions/loops.ts b/src/server/functions/loops.ts
index e730b70..1e8f9bd 100644
--- a/src/server/functions/loops.ts
+++ b/src/server/functions/loops.ts
@@ -27,39 +27,39 @@ export const getLoopsPage = createServerFn({ method: "GET" }).handler(async () =
export const getLoopReadiness = createServerFn({ method: "GET" }).handler(async () => await loopReadiness());
export const getLoopById = createServerFn({ method: "GET" })
- .inputValidator((data: { id: string }) => data)
+ .validator((data: { id: string }) => data)
.handler(async ({ data }) => await getLoop(data.id));
export const saveLoop = createServerFn({ method: "POST" })
- .inputValidator(edit)
+ .validator(edit)
.handler(async ({ data }) => {
const { id, ...values } = data;
return id ? await updateLoop(id, values) : await createLoop(values);
});
export const toggleLoop = createServerFn({ method: "POST" })
- .inputValidator((data: { id: string; enabled: boolean }) => data)
+ .validator((data: { id: string; enabled: boolean }) => data)
.handler(async ({ data }) => await setLoopEnabled(data.id, data.enabled));
export const removeLoop = createServerFn({ method: "POST" })
- .inputValidator((data: { id: string }) => data)
+ .validator((data: { id: string }) => data)
.handler(async ({ data }) => {
await deleteLoop(data.id);
return await listLoops();
});
export const reorderLoop = createServerFn({ method: "POST" })
- .inputValidator((data: { id: string; direction: "up" | "down" }) => data)
+ .validator((data: { id: string; direction: "up" | "down" }) => data)
.handler(async ({ data }) => await moveLoop(data.id, data.direction));
export const getLoopPollState = createServerFn({ method: "GET" })
- .inputValidator((data: { id: string }) => data)
+ .validator((data: { id: string }) => data)
.handler(async ({ data }) => await loopPollState(data.id));
export const runLoopBacklog = createServerFn({ method: "POST" })
- .inputValidator((data: { id: string }) => data)
- .handler(async ({ data }) => ({ queued: await runBacklog(data.id) }));
+ .validator((data: { id: string; key?: string }) => data)
+ .handler(async ({ data }) => ({ queued: await runBacklog(data.id, data.key) }));
export const lookLoopNow = createServerFn({ method: "POST" })
- .inputValidator((data: { id: string }) => data)
+ .validator((data: { id: string }) => data)
.handler(async ({ data }) => await lookNow(data.id));
diff --git a/src/server/functions/runners.ts b/src/server/functions/runners.ts
index 6c69c8b..7adc593 100644
--- a/src/server/functions/runners.ts
+++ b/src/server/functions/runners.ts
@@ -13,7 +13,7 @@ export const rotateRunnerJoinToken = createServerFn({ method: "POST" }).handler(
);
export const removeRunner = createServerFn({ method: "POST" })
- .inputValidator((data: { id: string }) => data)
+ .validator((data: { id: string }) => data)
.handler(async ({ data }) => {
await forgetRunner(data.id);
return await listRunners();
diff --git a/src/server/functions/tasks.ts b/src/server/functions/tasks.ts
index 228e797..544b79d 100644
--- a/src/server/functions/tasks.ts
+++ b/src/server/functions/tasks.ts
@@ -10,27 +10,27 @@ export const getInbox = createServerFn({ method: "GET" }).handler(async () => ({
}));
export const getLoopTasks = createServerFn({ method: "GET" })
- .inputValidator((data: { loopId: string }) => data)
+ .validator((data: { loopId: string }) => data)
.handler(async ({ data }) => await listTasks({ loopId: data.loopId }));
export const getTaskById = createServerFn({ method: "GET" })
- .inputValidator((data: { id: string }) => data)
+ .validator((data: { id: string }) => data)
.handler(async ({ data }) => await getTask(data.id));
export const getTaskLog = createServerFn({ method: "GET" })
- .inputValidator((data: { id: string }) => data)
+ .validator((data: { id: string }) => data)
.handler(async ({ data }) => await readTaskLog(data.id));
export const runPrompt = createServerFn({ method: "POST" })
- .inputValidator((data: { prompt: string; agentId: string }) => data)
+ .validator((data: { prompt: string; agentId: string }) => data)
.handler(async ({ data }) => await enqueuePrompt(data));
export const runLoopNow = createServerFn({ method: "POST" })
- .inputValidator((data: { loopId: string; url: string; dryRun: boolean }) => data)
+ .validator((data: { loopId: string; url: string; dryRun: boolean }) => data)
.handler(async ({ data }) => await enqueueTask(data));
export const stopTask = createServerFn({ method: "POST" })
- .inputValidator((data: { id: string }) => data)
+ .validator((data: { id: string }) => data)
.handler(async ({ data }) => await requestCancel(data.id));
/** Whether the dispatcher is up, and how hard it is allowed to pull. */
@@ -41,5 +41,5 @@ export const getDispatcherState = createServerFn({ method: "GET" }).handler(asyn
}));
export const pauseDispatcher = createServerFn({ method: "POST" })
- .inputValidator((data: { paused: boolean }) => data)
+ .validator((data: { paused: boolean }) => data)
.handler(async ({ data }) => await setDispatcherPaused(data.paused));
diff --git a/src/server/loops.test.ts b/src/server/loops.test.ts
index 7f11992..2b6050a 100644
--- a/src/server/loops.test.ts
+++ b/src/server/loops.test.ts
@@ -11,11 +11,11 @@ const { db, migrateIfNeeded } = await import("./db/client.ts");
const { loops } = await import("./db/schema.ts");
const { createLoop, getLoop, updateLoop } = await import("./loops.ts");
const { draftForWorkflow } = await import("#/lib/loop-draft.ts");
-const { githubManifest } = await import("#/connectors/github/manifest.ts");
+const { connectorWorkflow } = await import("#/connectors/manifests.ts");
await migrateIfNeeded();
-const REVIEW = githubManifest.workflows.find((entry) => entry.id === "github.review_requested")!;
+const REVIEW = connectorWorkflow("github", "github.review_requested")!;
/** Both steps at once: what the form does when somebody presses the button. */
function added(connectorId: string, workflowId: string, changes: Record = {}) {
@@ -72,6 +72,31 @@ describe("choosing a workflow", () => {
});
});
+describe("what a loop runs", () => {
+ it("is a workflow on top of its trigger", () => {
+ expect(REVIEW.custom).toBe(false);
+ expect(REVIEW.triggerId).toBe("github.review_requested");
+ expect(REVIEW.watches).toBe("is:open is:pr review-requested:@me");
+ expect(REVIEW.actionId).toBe("github.submit_review");
+ });
+
+ it("is a trigger alone when no workflow sits on it", () => {
+ const mail = connectorWorkflow("gmail", "gmail.new_mail")!;
+ expect(mail.custom).toBe(true);
+ expect(mail.prompt).toBe("");
+ // A trigger that only reads keeps its answer in Inbox until told otherwise.
+ expect(draftForWorkflow("gmail", "gmail.new_mail")).toMatchObject({
+ prompt: "",
+ actionConnectorId: "schedule",
+ actionId: "schedule.record",
+ });
+ });
+
+ it("is nothing for an id no connector knows", () => {
+ expect(connectorWorkflow("github", "github.nonsense")).toBeUndefined();
+ });
+});
+
describe("turning a workflow into a loop", () => {
it("takes a copy of what the workflow asks and where it writes", async () => {
const loop = await added("github", "github.review_requested");
@@ -97,22 +122,6 @@ describe("turning a workflow into a loop", () => {
// write, and not with what the box on screen said.
await expect(edited(id, { prompt: " " })).rejects.toThrow(/what the agent should do/);
});
-
- /**
- * A read-only connector has no action to fall back to, so a workflow on one
- * has to name where its answer goes or be useless the moment it is turned
- * on. It still only starts there; the loop can be pointed anywhere after.
- */
- it("starts on another connector when the workflow says so", async () => {
- const loop = await added("gmail", "gmail.new_mail");
-
- expect(loop.connectorId).toBe("gmail");
- expect(loop.actionConnectorId).toBe("wechat");
- expect(loop.actionId).toBe("wechat.reply");
- // "Whoever asked" is the action's own default and means nothing here,
- // since no person started this.
- expect(loop.actionTarget).toEqual({ to: "me" });
- });
});
/**
@@ -129,12 +138,18 @@ describe("a loop that has to work somewhere", () => {
);
});
- it("is saved once it has one", async () => {
+ it("is saved relative to the runner's home", async () => {
const loop = await added("schedule", "schedule.recurring", {
- settings: { every: "day", at: "09:00", weekday: "1", folder: "/Users/you/code/web" },
+ settings: { every: "day", at: "09:00", weekday: "1", folder: "~/code/web" },
});
- expect(loop.settings.folder).toBe("/Users/you/code/web");
+ expect(loop.settings.folder).toBe("code/web");
+ });
+
+ it("will not take an absolute path, which means nothing on another runner", async () => {
+ await expect(
+ added("schedule", "schedule.recurring", { settings: { folder: "/Users/you/code/web" } }),
+ ).rejects.toThrow(/relative to the runner's home/);
});
it("does not ask for one where the agent is given no checkout", async () => {
@@ -144,7 +159,7 @@ describe("a loop that has to work somewhere", () => {
});
it("does not ask for a local clone when the agent will clone the repository", async () => {
- await expect(added("github", "github.issue_implement")).resolves.toBeTruthy();
+ await expect(added("github", "github.issue_assigned")).resolves.toBeTruthy();
});
});
diff --git a/src/server/loops.ts b/src/server/loops.ts
index 4b9a727..4da3ee6 100644
--- a/src/server/loops.ts
+++ b/src/server/loops.ts
@@ -8,6 +8,8 @@ import { listAgents } from "./agents.ts";
import { availableAgentIds } from "./runners.ts";
import { db } from "./db/client.ts";
import { connections, loops, type Loop } from "./db/schema.ts";
+import { folderIsLegacy, normalizeFolder } from "./agent-job.ts";
+import { FOLDER_JOB_PROTOCOL } from "#/lib/version.ts";
const NAME_LIMIT = 80;
const PROMPT_LIMIT = 8000;
@@ -106,10 +108,15 @@ function validate(draft: LoopDraft): LoopDraft {
// A job that works in a checkout cannot be told which one later: it would
// sit enabled, come round on time and fail at the last step every time.
// Caught while the person is still looking at the box.
+ // Relative to the runner's home, because each runner keeps its checkouts
+ // under its own: /Users/maya/code/web means nothing on a Linux box.
+ let folder: string | undefined;
if (workflow.runsIn === "folder") {
- const folder = draft.settings.folder;
- if (typeof folder !== "string" || folder.trim() === "") {
- throw new Error("Say which folder this should work in.");
+ const given = typeof draft.settings.folder === "string" ? draft.settings.folder : "";
+ folder = normalizeFolder(given);
+ if (!folder) throw new Error("Say which folder this should work in.");
+ if (folderIsLegacy(folder)) {
+ throw new Error("Give the folder relative to the runner's home, such as code/web.");
}
}
@@ -118,7 +125,10 @@ function validate(draft: LoopDraft): LoopDraft {
name,
prompt,
guidance,
- settings: cleanConditions(workflow.settings, draft.settings),
+ settings: {
+ ...cleanConditions(workflow.settings, draft.settings),
+ ...(folder ? { folder } : {}),
+ },
actionTarget: cleanConditions(action.target, draft.actionTarget),
};
}
@@ -228,6 +238,7 @@ export async function loopReadiness(): Promise {
.all();
const available = await availableAgentIds();
const host = await availableAgentIds(true);
+ const folder = await availableAgentIds(false, FOLDER_JOB_PROTOCOL);
const chosen = agents.find((agent) => agent.isDefault)?.agentId ?? null;
const defaultAgentId =
(chosen && available.includes(chosen) ? chosen : null) ??
@@ -237,5 +248,6 @@ export async function loopReadiness(): Promise {
defaultAgentId,
availableAgentIds: available,
hostAgentIds: host,
+ folderAgentIds: folder,
};
}
diff --git a/src/server/runner-api.ts b/src/server/runner-api.ts
index 268577b..bdecfb4 100644
--- a/src/server/runner-api.ts
+++ b/src/server/runner-api.ts
@@ -3,10 +3,10 @@ import { appendFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { and, eq, isNull, lte, or } from "drizzle-orm";
import { isHosted } from "#/lib/hosted.ts";
-import { jobRequiresHost, type AgentJob } from "./agent-job.ts";
+import { jobMinProtocol, jobRequiresHost, type AgentJob } from "./agent-job.ts";
import { db, rowsChanged } from "./db/client.ts";
import { tasks } from "./db/schema.ts";
-import { getRunner, heartbeatRunner, isRunnerOnline } from "./runners.ts";
+import { getRunner, heartbeatRunner, isRunnerOnline, protocolOf, type RunnerBuild } from "./runners.ts";
import { runDir } from "./paths.ts";
import { absorbAgentOutput, jobForTask, taskRow, updateTask } from "./tasks.ts";
import type { RunnerInventoryEntry } from "#/lib/domain.ts";
@@ -39,6 +39,17 @@ export async function claimAgentJob(runnerId: string): Promise
await updateTask(task.id, { leaseUntil: null });
return null;
}
+ // Only reached when the job was given to this runner before it was known
+ // to be too old. Nothing else will claim it, so it fails and says why
+ // rather than waiting for a runner that is never coming.
+ if (jobMinProtocol(job) > protocolOf(row)) {
+ await updateTask(task.id, {
+ state: "failed",
+ error: `${row.name} is too old for this job. Upgrade it with npm install -g loopable-cli, then run this again.`,
+ leaseUntil: null,
+ });
+ continue;
+ }
return job;
}
return null;
@@ -80,6 +91,6 @@ export async function completeAgentJob(
await absorbAgentOutput(taskId, result.output);
}
-export function touchRunner(runnerId: string, inventory: RunnerInventoryEntry[]) {
- return heartbeatRunner(runnerId, inventory);
+export function touchRunner(runnerId: string, inventory: RunnerInventoryEntry[], build: RunnerBuild = {}) {
+ return heartbeatRunner(runnerId, inventory, build);
}
diff --git a/src/server/runners.test.ts b/src/server/runners.test.ts
index 3fbee78..99b52f3 100644
--- a/src/server/runners.test.ts
+++ b/src/server/runners.test.ts
@@ -12,8 +12,9 @@ process.env.LOOPABLE_KEYCHAIN = "0";
const { db, migrateIfNeeded } = await import("./db/client.ts");
const { loops, runners, tasks } = await import("./db/schema.ts");
-const { forgetRunner, getJoinToken, heartbeatRunner, joinRunner, pickRunner } =
+const { forgetRunner, getJoinToken, heartbeatRunner, joinRunner, listRunners, pickRunner } =
await import("./runners.ts");
+const { FOLDER_JOB_PROTOCOL, RUNNER_PROTOCOL, VERSION } = await import("#/lib/version.ts");
await migrateIfNeeded();
@@ -67,6 +68,38 @@ describe("pickRunner", () => {
});
});
+describe("runner versions", () => {
+ it("counts a runner that says nothing as the first protocol, and out of date", async () => {
+ await givenRunner("old-box");
+ const [view] = await listRunners();
+ expect(view).toMatchObject({ version: null, protocol: 1, outdated: true });
+ });
+
+ it("keeps what a runner says on every heartbeat", async () => {
+ const { runnerId } = await givenRunner("new-box");
+ await heartbeatRunner(runnerId, CODEX, { version: VERSION, protocol: RUNNER_PROTOCOL });
+ const [view] = await listRunners();
+ expect(view).toMatchObject({ version: VERSION, protocol: RUNNER_PROTOCOL, outdated: false });
+ });
+
+ it("sends a job needing a newer protocol only to a runner that has it", async () => {
+ const old = await givenRunner("old-box");
+ await expect(
+ pickRunner({ agentId: "codex", requiresHost: false, minProtocol: FOLDER_JOB_PROTOCOL }),
+ ).rejects.toThrow(/too old for this job/);
+
+ const fresh = await givenRunner("new-box");
+ await heartbeatRunner(fresh.runnerId, CODEX, { version: VERSION, protocol: RUNNER_PROTOCOL });
+ expect(
+ await pickRunner({ agentId: "codex", requiresHost: false, minProtocol: FOLDER_JOB_PROTOCOL }),
+ ).toBe(fresh.runnerId);
+ // Anything else still goes to either.
+ expect([old.runnerId, fresh.runnerId]).toContain(
+ await pickRunner({ agentId: "codex", requiresHost: false }),
+ );
+ });
+});
+
describe("joinRunner", () => {
it("rejects a bad join token", async () => {
await expect(
diff --git a/src/server/runners.ts b/src/server/runners.ts
index 501afb6..5cbb080 100644
--- a/src/server/runners.ts
+++ b/src/server/runners.ts
@@ -2,12 +2,27 @@ import { randomBytes, randomUUID } from "node:crypto";
import { hostname as osHostname } from "node:os";
import { eq } from "drizzle-orm";
import type { RunnerInventoryEntry, RunnerView } from "#/lib/domain.ts";
+import { LEGACY_RUNNER_PROTOCOL, RUNNER_PROTOCOL, VERSION, olderThan } from "#/lib/version.ts";
import { db } from "./db/client.ts";
import { runners, tasks, type Runner } from "./db/schema.ts";
import { joinTokenKey, runnerTokenKey, secretStore } from "./secrets.ts";
const ONLINE_MS = 45_000;
+/** What a runner says it is, as it says it on joining and on every heartbeat. */
+export type RunnerBuild = { version?: string | null; protocol?: number | null };
+
+export function protocolOf(row: Pick): number {
+ return row.protocol ?? LEGACY_RUNNER_PROTOCOL;
+}
+
+function buildFields(build: RunnerBuild) {
+ return {
+ version: typeof build.version === "string" && build.version ? build.version : null,
+ protocol: typeof build.protocol === "number" ? build.protocol : null,
+ };
+}
+
export function isRunnerOnline(row: Runner, now = Date.now()): boolean {
if (row.status !== "online") return false;
if (!row.lastSeenAt) return false;
@@ -23,6 +38,9 @@ function toView(row: Runner, now = Date.now()): RunnerView {
status: online ? "online" : "offline",
inventory: row.inventory,
lastSeenAt: row.lastSeenAt?.toISOString() ?? null,
+ version: row.version,
+ protocol: protocolOf(row),
+ outdated: protocolOf(row) < RUNNER_PROTOCOL || olderThan(row.version, VERSION),
};
}
@@ -62,12 +80,17 @@ async function busyRunnerIds(): Promise> {
}
/** Agent ids signed in on an online runner. */
-export async function availableAgentIds(requiresHost = false): Promise {
+export async function availableAgentIds(requiresHost = false, minProtocol = 0): Promise {
const local = osHostname();
return [
...new Set(
(await listRunners())
- .filter((row) => row.status === "online" && (!requiresHost || row.hostname === local))
+ .filter(
+ (row) =>
+ row.status === "online" &&
+ (!requiresHost || row.hostname === local) &&
+ row.protocol >= minProtocol,
+ )
.flatMap((row) =>
row.inventory
.filter((entry) => entry.installed && entry.signedIn)
@@ -79,14 +102,21 @@ export async function availableAgentIds(requiresHost = false): Promise
/**
* Which runner should run this agent. Jobs that need a folder on Loopable's
- * disk only go to a runner whose hostname is this host.
+ * disk only go to a runner whose hostname is this host, and a job that needs
+ * something newer runners understand only goes to one that says it does.
*/
-export async function pickRunner(input: { agentId: string; requiresHost: boolean }): Promise {
+export async function pickRunner(input: {
+ agentId: string;
+ requiresHost: boolean;
+ minProtocol?: number;
+}): Promise {
const now = Date.now();
const local = osHostname();
- let candidates = (await db().select().from(runners).all()).filter(
+ const minProtocol = input.minProtocol ?? 0;
+ const capable = (await db().select().from(runners).all()).filter(
(row) => isRunnerOnline(row, now) && runnerCanRun(row, input.agentId),
);
+ let candidates = capable.filter((row) => protocolOf(row) >= minProtocol);
if (input.requiresHost) {
candidates = candidates.filter((row) => row.hostname === local);
}
@@ -98,6 +128,11 @@ export async function pickRunner(input: { agentId: string; requiresHost: boolean
if (input.requiresHost) {
throw new Error("This job needs a runner on the same host as Loopable.");
}
+ if (capable.length > 0) {
+ throw new Error(
+ "Every runner with this agent is too old for this job. Upgrade them with npm install -g loopable-cli.",
+ );
+ }
throw new Error("No runner is online that can run this agent.");
}
@@ -118,12 +153,13 @@ export async function rotateJoinToken(): Promise {
export async function heartbeatRunner(
runnerId: string,
inventory: RunnerInventoryEntry[],
+ build: RunnerBuild = {},
): Promise {
const row = await getRunner(runnerId);
if (!row) throw new Error("Unknown runner.");
await db()
.update(runners)
- .set({ status: "online", inventory, lastSeenAt: new Date() })
+ .set({ status: "online", inventory, lastSeenAt: new Date(), ...buildFields(build) })
.where(eq(runners.id, runnerId))
.run();
return toView((await getRunner(runnerId))!);
@@ -133,7 +169,7 @@ export async function joinRunner(input: {
joinToken: string;
hostname: string;
inventory: RunnerInventoryEntry[];
-}): Promise<{ runnerId: string; runnerToken: string; name: string }> {
+} & RunnerBuild): Promise<{ runnerId: string; runnerToken: string; name: string }> {
const expected = await getJoinToken();
if (!input.joinToken || input.joinToken !== expected) {
throw new Error("That join token is not valid.");
@@ -152,6 +188,7 @@ export async function joinRunner(input: {
inventory: input.inventory,
lastSeenAt: now,
hostname: input.hostname,
+ ...buildFields(input),
})
.where(eq(runners.id, id))
.run();
@@ -165,6 +202,7 @@ export async function joinRunner(input: {
status: "online",
inventory: input.inventory,
lastSeenAt: now,
+ ...buildFields(input),
})
.run();
}
diff --git a/src/server/signals.test.ts b/src/server/signals.test.ts
index 888bd8f..abcd778 100644
--- a/src/server/signals.test.ts
+++ b/src/server/signals.test.ts
@@ -246,6 +246,19 @@ describe("running the backlog", () => {
await pollAllLoops();
expect(await tasksFor("a")).toHaveLength(2);
});
+
+ it("queues one item by its key and leaves the rest waiting", async () => {
+ await givenLoop("a", 1);
+ answer = [pull(1), pull(2)];
+ await pollAllLoops();
+
+ const [first] = (await loopPollState("a")).backlog;
+ expect(await runBacklog("a", first!.key)).toBe(1);
+
+ expect(await tasksFor("a")).toHaveLength(1);
+ expect((await loopPollState("a")).backlog.map((item) => item.sourceRef)).toEqual(["acme/web#2"]);
+ await expect(runBacklog("a", first!.key)).rejects.toThrow(/no longer waiting/);
+ });
});
describe("how often to look", () => {
diff --git a/src/server/signals.ts b/src/server/signals.ts
index 7fcad57..00b90ed 100644
--- a/src/server/signals.ts
+++ b/src/server/signals.ts
@@ -72,8 +72,9 @@ export async function pollLoop(loop: Loop, claimed: Set): Promise {
}
/**
- * Run everything the loop is holding. Asked for explicitly, because it is the
- * one moment a loop does a month of work at once, and because something held
- * back for being too big is being run against the connector's advice.
+ * Run what the loop is holding: one item when `key` is given, or all of it.
+ * Asked for explicitly, because something held back for being too big is
+ * being run against the connector's advice.
*/
-export async function runBacklog(loopId: string): Promise {
+export async function runBacklog(loopId: string, key?: string): Promise {
const loop = await db().select().from(loops).where(eq(loops.id, loopId)).get();
if (!loop) throw new Error("Loop not found");
const waiting = await db()
.select()
.from(signals)
- .where(and(eq(signals.loopId, loopId), inArray(signals.outcome, [...WAITING])))
+ .where(
+ and(
+ eq(signals.loopId, loopId),
+ inArray(signals.outcome, [...WAITING]),
+ key === undefined ? undefined : eq(signals.key, key),
+ ),
+ )
.all();
+ if (key !== undefined && waiting.length === 0) throw new Error("That item is no longer waiting.");
for (const row of waiting) {
const task = await enqueueSignal({
diff --git a/src/server/tasks.test.ts b/src/server/tasks.test.ts
index 5b6da0e..42ec50b 100644
--- a/src/server/tasks.test.ts
+++ b/src/server/tasks.test.ts
@@ -67,6 +67,38 @@ vi.mock("#/connectors/runtimes.ts", () => ({
}),
}));
+// No shipped workflow writes code today, but the path that carries a pushed
+// branch to a pull request is still here. This gives the tests one to drive it.
+vi.mock("#/connectors/manifests.ts", async (importOriginal) => {
+ const actual = await importOriginal();
+ const writesCode = {
+ id: "github.test_write_code",
+ name: "Write code for an issue",
+ summary: "Test only.",
+ trigger: "an issue is assigned",
+ writes: "a draft pull request",
+ runsIn: "checkout" as const,
+ settings: [],
+ prompt: "Implement this issue.",
+ answer: "code" as const,
+ actionId: "github.open_pull_request",
+ };
+ const openPullRequest = {
+ id: "github.open_pull_request",
+ name: "Open a draft pull request",
+ summary: "Test only.",
+ target: [],
+ accepts: ["code" as const],
+ };
+ return {
+ ...actual,
+ connectorWorkflow: (connectorId: string, workflowId: string) =>
+ workflowId === writesCode.id ? writesCode : actual.connectorWorkflow(connectorId, workflowId),
+ connectorAction: (connectorId: string, actionId: string) =>
+ actionId === openPullRequest.id ? openPullRequest : actual.connectorAction(connectorId, actionId),
+ };
+});
+
vi.mock("./connections.ts", () => ({
credentialForConnector: async (id: string) => ({ credential: `credential for ${id}` }),
}));
@@ -93,6 +125,8 @@ const { enqueuePrompt, enqueueTask, getTask, jobForTask, runAssignedAgent, runTa
await import("./tasks.ts");
const { getJoinToken, joinRunner } = await import("./runners.ts");
const { branchFor } = await import("./checkout.ts");
+const { WORKSPACE } = await import("./agent-job.ts");
+const { RUNNER_PROTOCOL, VERSION } = await import("#/lib/version.ts");
await migrateIfNeeded();
@@ -146,6 +180,8 @@ beforeEach(async () => {
inventory: [
{ agentId: "cursor-agent", installed: true, version: "1", signedIn: true, detail: "ok" },
],
+ version: VERSION,
+ protocol: RUNNER_PROTOCOL,
});
});
@@ -189,8 +225,8 @@ describe("what the agent is asked", () => {
expect(job.settings.permissionMode).toBe("workspace_write");
});
- it("names a host folder as cwd and leaves the rest to the prompt", async () => {
- const folder = mkdtempSync(join(tmpdir(), "loopable-folder-"));
+ it("names the folder relative to home, and context files by the runner's workspace", async () => {
+ const folder = "code/web";
await db()
.insert(loops)
.values({
@@ -216,8 +252,8 @@ describe("what the agent is asked", () => {
const job = await jobForTask(queued.id);
expect("runsIn" in job).toBe(false);
- expect(job.cwd).toBe(folder);
- expect(job.prompt).toContain(`Read ${join(home, "runs", queued.id, "PULL_REQUEST.md")} first.`);
+ expect(job.cwd).toBe("code/web");
+ expect(job.prompt).toContain(`Read ${WORKSPACE}/PULL_REQUEST.md first.`);
});
});
@@ -367,7 +403,7 @@ describe("a loop that writes code", () => {
name: "Implement things",
priority: 1,
connectorId: "github",
- workflowId: "github.issue_implement",
+ workflowId: "github.test_write_code",
prompt: PROMPT,
settings: {},
actionConnectorId: "github",
@@ -534,7 +570,8 @@ describe("a prompt from the inbox", () => {
const done = await driveTask(queued.id);
expect(asked).toBe("Say hello in one word.");
- expect(done.state).toBe("prepared");
+ // Done, not prepared: there was never anything to write.
+ expect(done.state).toBe("done");
expect(done.output).toBe("hello");
expect(writes).toHaveLength(0);
});
diff --git a/src/server/tasks.ts b/src/server/tasks.ts
index 7f1c267..c8a3a85 100644
--- a/src/server/tasks.ts
+++ b/src/server/tasks.ts
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
-import { isAbsolute, join } from "node:path";
+import { join } from "node:path";
import { desc, eq } from "drizzle-orm";
import { agentManifest } from "#/agents/manifests.ts";
import { connectorAction, connectorManifest, connectorWorkflow } from "#/connectors/manifests.ts";
@@ -16,7 +16,15 @@ import type {
import type { JsonValue, TaskView, WorkItemKind } from "#/lib/domain.ts";
import { REVIEW_FORMAT, anchorFindings, parseReview, reviewBody, type Finding } from "#/lib/review.ts";
import { isHosted } from "#/lib/hosted.ts";
-import { AGENT_NOTHING, isAgentNothing, type AgentJob } from "./agent-job.ts";
+import {
+ AGENT_NOTHING,
+ WORKSPACE,
+ folderIsLegacy,
+ isAgentNothing,
+ normalizeFolder,
+ type AgentJob,
+} from "./agent-job.ts";
+import { FOLDER_JOB_PROTOCOL } from "#/lib/version.ts";
import { listAgents, settingsFor } from "./agents.ts";
import { branchFor } from "./checkout.ts";
import { credentialForConnector } from "./connections.ts";
@@ -362,12 +370,13 @@ export function isCancellation(error: unknown): boolean {
* left to the agent, which would otherwise run somewhere unexpected and answer
* confidently about the wrong code.
*/
+/**
+ * The loop's folder as it is stored: relative to a runner's home. Whether it
+ * exists is the runner's to find out, since only it can look.
+ */
function folderFor(loop: Loop): string {
- const folder = typeof loop.settings.folder === "string" ? loop.settings.folder.trim() : "";
+ const folder = typeof loop.settings.folder === "string" ? normalizeFolder(loop.settings.folder) : "";
if (!folder) throw new Error("This loop has no folder set. Set one and try again.");
- if (!isAbsolute(folder)) throw new Error(`The folder must be an absolute path: ${folder}`);
- if (!existsSync(folder)) throw new Error(`There is no folder at ${folder}.`);
- if (!statSync(folder).isDirectory()) throw new Error(`${folder} is not a folder.`);
return folder;
}
@@ -549,9 +558,15 @@ async function prepareTask(id: string, signal?: AbortSignal): Promise
logPath = join(workspace, "agent.log");
writeFileSync(logPath, "", { flag: "a" });
}
+ const folder =
+ workflow.runsIn === "folder" && typeof loop.settings.folder === "string"
+ ? loop.settings.folder
+ : null;
const runnerId = await pickRunner({
agentId,
- requiresHost: workflow.runsIn === "folder",
+ requiresHost: folder !== null && folderIsLegacy(folder),
+ // A relative folder needs a runner that resolves it under its own home.
+ minProtocol: folder !== null && !folderIsLegacy(folder) ? FOLDER_JOB_PROTOCOL : 0,
});
await updateTask(id, {
state: "awaiting_agent",
@@ -580,7 +595,6 @@ export async function jobForTask(id: string): Promise {
const { task, loop, workflow, item } = await loadWork(id);
if (!task.agentId) throw new Error("This task has no agent yet.");
- const workspace = runDir(id);
const cwd = workflow.runsIn === "folder" ? folderFor(loop) : undefined;
const branch = workflow.answer === "code" ? branchFor({ ref: item.ref, taskId: id }) : undefined;
const settings = { ...(await settingsFor(task.agentId)) };
@@ -598,9 +612,9 @@ export async function jobForTask(id: string): Promise {
guidance: loop.guidance,
item,
// Folder jobs work in a checkout that does not hold these files, so the
- // prompt has to name them by their dispatcher path. Everything else runs
- // where the runner wrote them, so the basename is enough.
- files: item.context.map((file) => (cwd ? join(workspace, file.name) : file.name)),
+ // prompt names them in the runner's workspace, which the runner fills
+ // in. Everything else runs where the files were written.
+ files: item.context.map((file) => (cwd ? `${WORKSPACE}/${file.name}` : file.name)),
branch,
}),
settings,
@@ -647,7 +661,9 @@ export async function absorbAgentOutput(id: string, raw: string): Promise | null = null;
function ensureMigrated() {
@@ -32,6 +32,14 @@ const appAccess = createMiddleware({ type: "request" }).server(async ({ next, re
return result;
});
+/**
+ * Server functions are only ever called by the app's own pages, so a request
+ * from another site is refused. Runners use the /api routes, which this skips.
+ */
+const sameOrigin = createCsrfMiddleware({
+ filter: (ctx) => ctx.handlerType === "serverFn",
+});
+
export const startInstance = createStart(() => ({
- requestMiddleware: [appAccess],
+ requestMiddleware: [appAccess, sameOrigin],
}));