Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Ask's card remembers what it did, step by step, on the answer that proposed it (#53): the card kept its progress only in the page that clicked it, so back in Chat the same answer offered Start session again, and a second click on the new-repository card opened a second issue and started a second session on the same empty repository (flagon-io/ask-hello-test got #1 and #2, each with a session racing to push the first version). What a click does is now kept on the @g1t answer as it happens, in a proposal_state step beside the proposal: chat's new g1t_proposal merges it in (the viewer's own direct message with g1t only, known fields only) and tells the room, so every open copy of the answer updates. The card starts from it wherever the answer is read: Created with the repository, Live once the first version is pushed at with the address or Not deployed: with why, and Started with its links. Another click carries on from there rather than over: a session already started is shown, not started again; a repository already made, its deployments and the issue already opened are used; and while a click is still starting it (90 seconds at most), another says it's already starting. A failed step clears that hold so a retry works. Ask's steps also stop marking a read that found nothing as not read: checking that a new repository's name is free answers "not found", which showed as 1 not read on every new-repository answer.

Checked:
chat's tests (84, with withProposalState merging step by step, dropping unknown fields and saying what is kept), its typecheck; the site's typecheck (tsc -b --force), ask.test.ts (4, with kept state folding back), react-router build and the deploy tool's tests (92); cargo test -p g1t-integrations (76, with is_not_found). The end-to-end run before this change: Home's ask in flagon-io proposed Build "Hello world page" in flagon-io/ask-hello-test, Start session created the repository, turned on deployments and started the session, and https://ask-hello-test-flagonio.g1t.page served Hello from g1t about two minutes later.
G1t-Session:
ses_01m4ype025ebxa88d367gx24xv
syntaqxcommitted Parentcd510e3Browse files
11 files+250−240/11 viewed
+43−8
11 import { Ban, Check, ChevronRight, CircleAlert, Copy, ExternalLink, LoaderCircle, Play, Rocket, SquareTerminal } from "lucide-react";
22 import { useEffect, useState } from "react";
33
4−import type { AskSource } from "@g1t/contracts";
4+import type { AskProposalState, AskSource } from "@g1t/contracts";
55
66 import { type Proposal, type Step, foldAnswer, proposalSteps, proposalTitle } from "../lib/ask";
77 import { Badge } from "./ui/badge";
102102 * with it (a chat message's `meta.answer.steps`): steps above the answer's
103103 * text, sources below. Mount each half where it goes with `part`.
104104 */
105−export function AskTrail({ events, working, part, slug }: { events: unknown; working: boolean; part: "steps" | "sources" | "proposals"; slug?: string }) {
106− const { steps, sources, proposals } = foldAnswer(events);
105+export function AskTrail({
106+ events,
107+ working,
108+ part,
109+ slug,
110+ message,
111+}: {
112+ events: unknown;
113+ working: boolean;
114+ part: "steps" | "sources" | "proposals";
115+ slug?: string;
116+ /** The answer they came in, where what each card did is kept. */
117+ message?: { id: string; channel_id: string };
118+}) {
119+ const { steps, sources, proposals, kept } = foldAnswer(events);
107120 if (part === "steps") return <AskSteps steps={steps} working={working} />;
108121 if (part === "sources") return <AskSources sources={sources} />;
109122 if (!slug || proposals.length === 0) return null;
110123 return (
111124 <div className="flex flex-col gap-2">
112125 {proposals.map((proposal) => (
113− <SessionProposal key={proposal.id} proposal={proposal} slug={slug} />
126+ <SessionProposal key={proposal.id} proposal={proposal} slug={slug} message={message} kept={kept[proposal.id]} />
114127 ))}
115128 </div>
116129 );
134147 * one is connected), Copy command gives the Claude Code command to run it
135148 * with the g1t plugin. Either way the session is tied to the issue.
136149 */
137−export function SessionProposal({ proposal, slug }: { proposal: Proposal; slug: string }) {
150+export function SessionProposal({
151+ proposal,
152+ slug,
153+ message,
154+ kept,
155+}: {
156+ proposal: Proposal;
157+ slug: string;
158+ message?: { id: string; channel_id: string };
159+ /** What a click on this card already did, kept on its answer. */
160+ kept?: AskProposalState;
161+}) {
138162 const [available, setAvailable] = useState<boolean | null>(null);
139163 const [busy, setBusy] = useState<"start" | "copy" | null>(null);
140− const [started, setStarted] = useState<Started | null>(null);
164+ const [started, setStarted] = useState<Started | null>(
165+ kept?.started ? { session_url: kept.session_url ?? null, provider_session_url: kept.provider_session_url ?? null, handoff: false, command: null, prompt: "" } : null,
166+ );
141167 const [copied, setCopied] = useState(false);
142168 const [error, setError] = useState<string | null>(null);
143− const [made, setMade] = useState<Pick<Started, "repo_url" | "production_url" | "deploy_error"> | null>(null);
169+ const [made, setMade] = useState<Pick<Started, "repo_url" | "production_url" | "deploy_error"> | null>(
170+ kept?.repo_url ? { repo_url: kept.repo_url, production_url: kept.production_url ?? null, deploy_error: kept.deploy_error ?? null } : null,
171+ );
172+ // What another tab, or a click elsewhere, did shows here as it is kept.
173+ useEffect(() => {
174+ if (kept?.repo_url) setMade({ repo_url: kept.repo_url, production_url: kept.production_url ?? null, deploy_error: kept.deploy_error ?? null });
175+ if (kept?.started) {
176+ setStarted((now) => now ?? { session_url: kept.session_url ?? null, provider_session_url: kept.provider_session_url ?? null, handoff: false, command: null, prompt: "" });
177+ }
178+ }, [kept?.repo_url, kept?.production_url, kept?.deploy_error, kept?.started, kept?.session_url, kept?.provider_session_url]);
144179
145180 useEffect(() => {
146181 let live = true;
160195 const response = await fetch(`/${slug}/-/ask`, {
161196 method: "POST",
162197 headers: { "content-type": "application/json", accept: "application/json" },
163− body: JSON.stringify({ ...proposal, handoff }),
198+ body: JSON.stringify({ ...proposal, handoff, message_id: message?.id, channel_id: message?.channel_id }),
164199 });
165200 const body = (await response.json().catch(() => null)) as (Started & { error?: string }) | null;
166201 // The repository may be made even when the session is not: keep where it is.
+1−1
7373 {message.body}
7474 </MessageResponse>
7575 )}
76− {!live && <AskTrail events={answer?.steps} working={false} part="proposals" slug={context.slug} />}
76+ {!live && <AskTrail events={answer?.steps} working={false} part="proposals" slug={context.slug} message={message} />}
7777 {!live && <AskTrail events={answer?.steps} working={false} part="sources" />}
7878 {!live && message.body && <FolioUnfurls slug={context.slug} body={message.body} />}
7979 {actions && !live && (
+3−1
5050 );
5151 assert.equal(folded.sources.length, 1);
5252 assert.equal(folded.sources[0]!.url, "https://g1t.sh/acme/web/pull/7");
53− assert.deepEqual(foldAnswer("junk"), { steps: [], sources: [], proposals: [] });
53+ assert.deepEqual(foldAnswer("junk"), { steps: [], sources: [], proposals: [], kept: {} });
54+ const kept = foldAnswer([{ type: "proposal", id: "p", repo: "a/b", issue: null, title: "x", body: null, mode: "implement", instruction: null }, { type: "proposal_state", id: "p", started: true, session_url: "/s" }]).kept;
55+ assert.deepEqual(kept, { p: { id: "p", started: true, session_url: "/s" } }, "what a card did comes back with its answer");
5456 });
5557
5658 test("a proposed session is kept once and says what it will do", () => {
+13−4
44 * is told the page and reads the issue or pull request on it. Pure, so it
55 * is tested on its own.
66 */
7−import type { AskEvent, AskPage, AskSource, AskToolStatus } from "@g1t/contracts";
7+import type { AskEvent, AskPage, AskProposalState, AskSource, AskToolStatus } from "@g1t/contracts";
88
99 /** First path segments that are g1t's own pages, never a workspace. */
1010 const RESERVED = new Set([
9292 * An answer's steps and sources from the raw events kept with it (a chat
9393 * message keeps the `tool` and `sources` lines it was streamed with):
9494 * each step once, as it last was, in the order they started; the last
95− * `sources`; each proposal once. Anything else, or anything malformed, is skipped.
95+ * `sources`; each proposal once, and what its card did (`kept`, by its id).
96+ * Anything else, or anything malformed, is skipped.
9697 */
97−export function foldAnswer(events: unknown): { steps: Step[]; sources: AskSource[]; proposals: Proposal[] } {
98+export function foldAnswer(events: unknown): { steps: Step[]; sources: AskSource[]; proposals: Proposal[]; kept: Record<string, AskProposalState> } {
9899 let steps: Step[] = [];
99100 let sources: AskSource[] = [];
100101 const proposals: Proposal[] = [];
102+ const kept: Record<string, AskProposalState> = {};
101103 for (const event of Array.isArray(events) ? events : []) {
102104 if (!event || typeof event !== "object") continue;
105+ // What a proposal's card did, kept on the answer by chat's g1t_proposal.
106+ const state = event as AskProposalState & { type?: string };
107+ if (state.type === "proposal_state" && typeof state.id === "string") {
108+ const { type: _, ...rest } = state;
109+ kept[state.id] = rest;
110+ continue;
111+ }
103112 const line = event as AskEvent;
104113 if (line.type === "tool" && typeof line.id === "string" && typeof line.label === "string") steps = withStep(steps, line);
105114 if (line.type === "sources" && Array.isArray(line.sources)) {
109118 if (!proposals.some((known) => known.id === line.id)) proposals.push(line);
110119 }
111120 }
112− return { steps, sources, proposals };
121+ return { steps, sources, proposals, kept };
113122 }
114123
115124 /** The steps with this one in its place: a new one at the end, a known one updated. */
+64−9
1919 */
2020 import { data } from "react-router";
2121
22−import { AGENT_MODES, type User } from "@g1t/contracts";
22+import { AGENT_MODES, type AskProposalState, type User } from "@g1t/contracts";
2323
2424 import type { Route } from "./+types/ask";
25−import { agentSessions, ai, deployments, projects, repos } from "../../lib/services.server";
25+import { agentSessions, ai, chat, deployments, projects, repos } from "../../lib/services.server";
2626 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
2727
2828 export async function loader({ params, context, request }: Route.LoaderArgs) {
5050 return Response.json({ error: "That repository isn't in this workspace." }, { status: 400 });
5151 }
5252 const text = (key: string) => (typeof input?.[key] === "string" && (input[key] as string).trim()) || undefined;
53− const issue = Number.isInteger(input?.issue) && (input?.issue as number) > 0 ? (input?.issue as number) : undefined;
53+ const handoff = input?.handoff === true;
54+ // What the card did is kept on the answer that proposed it, step by step, so
55+ // the card shows it wherever the answer is read and another click carries on.
56+ const kept = keeper(user, params.owner, input);
57+ const before = await kept.save({});
58+ if (before?.started && !handoff) {
59+ return Response.json({ ...before, handoff: false, command: null, prompt: "" });
60+ }
61+ if (!handoff && before?.starting_at && Date.now() - before.starting_at < STARTING_MS) {
62+ return Response.json({ error: "It's already starting. This card shows it in a moment.", ...before }, { status: 409 });
63+ }
64+ const asked = Number.isInteger(input?.issue) && (input?.issue as number) > 0 ? (input?.issue as number) : undefined;
65+ // An issue a click already opened is worked on, not opened again.
66+ const issue = asked ?? before?.issue;
5467 const title = issue ? undefined : text("title");
5568 if (!issue && !title) return Response.json({ error: "The proposal names no issue." }, { status: 400 });
5669 const mode = AGENT_MODES.find((one) => one.mode === input?.mode)?.mode ?? "implement";
70+ if (!handoff) await kept.save({ starting_at: Date.now() });
5771 let instruction = text("instruction");
5872 let built: Built | null = null;
5973 if (input?.new_repository === true) {
60− const made = await newRepository(user, namespace, name, { title: title ?? name, isPrivate: input.private === true, deploy: input.deploy !== false });
61− if ("error" in made) return Response.json({ error: made.error }, { status: 400 });
74+ const made = before?.repo_url && before.default_branch
75+ ? { repo_url: before.repo_url, default_branch: before.default_branch, production_url: before.production_url ?? null, deploy_error: before.deploy_error ?? null }
76+ : await newRepository(user, namespace, name, { title: (title ?? text("title")) || name, isPrivate: input.private === true, deploy: input.deploy !== false });
77+ if ("error" in made) {
78+ await kept.save({ starting_at: 0 });
79+ return Response.json({ error: made.error }, { status: 400 });
80+ }
6281 built = made;
82+ await kept.save(made);
6383 instruction = [firstPush(made), instruction].filter(Boolean).join("\n\n");
6484 }
6585 const result = await agentSessions
66− .start(user, { namespace, name }, { issue, title, body: issue ? undefined : text("body"), instruction, mode, handoff: input?.handoff === true })
86+ .start(user, { namespace, name }, { issue, title, body: issue ? undefined : text("body"), instruction, mode, handoff })
6787 .catch(() => null);
68− if (!result) return Response.json({ error: "The session could not be started. Try again in a moment.", ...built }, { status: 502 });
69− if (!result.ok) return Response.json({ error: result.error.message, ...built }, { status: 400 });
88+ if (!result?.ok) {
89+ if (!handoff) await kept.save({ starting_at: 0 });
90+ if (!result) return Response.json({ error: "The session could not be started. Try again in a moment.", ...built }, { status: 502 });
91+ return Response.json({ error: result.error.message, ...built }, { status: 400 });
92+ }
7093 const started = result.value;
94+ const session_url = started.sessionUrl ?? (started.session ? `/${namespace}/${name}/work/sessions/${started.session.id}` : null);
95+ await kept.save(
96+ started.handoff
97+ ? { issue: started.issue }
98+ : { issue: started.issue, started: true, session_url, provider_session_url: started.providerSessionUrl ?? null },
99+ );
71100 return Response.json({
72101 issue: started.issue,
73− session_url: started.sessionUrl ?? (started.session ? `/${namespace}/${name}/work/sessions/${started.session.id}` : null),
102+ session_url,
74103 provider_session_url: started.providerSessionUrl,
75104 handoff: started.handoff,
76105 command: started.command,
79108 });
80109 }
81110
111+/** How long a click that began starting a proposal holds off another. */
112+const STARTING_MS = 90_000;
113+
114+/**
115+ * Keeps what a proposal's card did on the @g1t answer it came in
116+ * (`message_id` in `channel_id`, the viewer's direct message with g1t),
117+ * through chat's `g1t_proposal`; `save({})` only reads it. Without a
118+ * message, or when chat can't be reached, nothing is kept and null is said:
119+ * the card still works, it just won't remember.
120+ */
121+function keeper(user: User, workspace: string, input: Record<string, unknown> | null) {
122+ const id = typeof input?.id === "string" ? input.id : "";
123+ const channel = typeof input?.channel_id === "string" ? input.channel_id : "";
124+ const message = typeof input?.message_id === "string" ? input.message_id : "";
125+ return {
126+ async save(change: Omit<AskProposalState, "id">): Promise<AskProposalState | null> {
127+ if (!id || !channel || !message) return null;
128+ const kept = await chat.g1tProposal(workspace.toLowerCase(), channel, user, message, { ...change, id }).catch((error: unknown) => {
129+ console.error("ask: proposal state not kept", id, error);
130+ return null;
131+ });
132+ return kept?.ok ? kept.value : null;
133+ },
134+ };
135+}
136+
82137 /** What the card made before the session: the repository, and where it deploys or why it can't. */
83138 type Built = { repo_url: string; default_branch: string; production_url: string | null; deploy_error: string | null };
84139
+30−0
234234 */
235235 export type ChatAnswerState = "thinking" | "streaming" | "done" | "stopped" | "error";
236236
237+/**
238+ * What a proposal's card (Ask's `proposal` step) has done so far, kept on
239+ * the answer as a `proposal_state` step with the same `id`, so the card
240+ * shows it wherever the answer is read and a second click carries on.
241+ */
242+export type AskProposalState = {
243+ id: string;
244+ /** When a click began starting it (ms); another click waits on it. */
245+ starting_at?: number;
246+ /** A new repository's: where it is, its default branch, where it deploys or why it can't. */
247+ repo_url?: string;
248+ default_branch?: string;
249+ production_url?: string | null;
250+ deploy_error?: string | null;
251+ /** The issue it opened or worked on. */
252+ issue?: number;
253+ /** The session it started on the workspace's Anthropic account. */
254+ started?: boolean;
255+ session_url?: string | null;
256+ provider_session_url?: string | null;
257+};
258+
237259 export type ChatAnswer = {
238260 state: ChatAnswerState;
239261 /** The provider's name and the model, once the provider said. */
660682 /** Stops @g1t's answer still coming; what came stays. */
661683 g1tStop(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
662684 /**
685+ * Keeps what a proposal's card did on the @g1t answer that proposed it
686+ * (merged into its `proposal_state` step), and says what is kept; with
687+ * only `id`, just says. The viewer's own direct message with g1t only.
688+ */
689+ g1tProposal(workspace: string, channelId: string, viewer: User, messageId: string, state: AskProposalState): Promise<Result<AskProposalState>>;
690+ /**
663691 * Clears the viewer's own direct message with @g1t: every message in
664692 * it, g1t's answers and their steps, reactions and the notifications
665693 * from it are deleted for good, and an answer still coming is thrown
763791 g1tReset: (workspace, channelId, viewer) => call("g1t_reset", { workspace, channel_id: channelId, viewer }),
764792 g1tRetry: (workspace, channelId, viewer) => call("g1t_retry", { workspace, channel_id: channelId, viewer }),
765793 g1tStop: (workspace, channelId, viewer) => call("g1t_stop", { workspace, channel_id: channelId, viewer }),
794+ g1tProposal: (workspace, channelId, viewer, messageId, state) =>
795+ call("g1t_proposal", { workspace, channel_id: channelId, viewer, message_id: messageId, state }),
766796 g1tClear: (workspace, channelId, viewer) => call("g1t_clear", { workspace, channel_id: channelId, viewer }),
767797 };
768798 }
+24−0
1818 readsNote,
1919 shownAnswer,
2020 startsAnswer,
21+ withProposalState,
2122 type SaidRow,
2223 } from "./g1t.ts";
2324
251252 assert.equal(shownAnswer({ state: "done" }, at, Date.parse(at) + 3_600_000)!.state, "done");
252253 assert.equal(shownAnswer(undefined, at, 0), null);
253254 });
255+
256+test("what a proposal's card did is kept on its answer, merged step by step", () => {
257+ const answer = { state: "done" as const, steps: [{ type: "proposal", id: "proposal_1_0", repo: "acme/hello" }] };
258+ assert.equal(withProposalState(answer, { id: "proposal_9_9" }), null, "no such proposal");
259+ const made = withProposalState(answer, { id: "proposal_1_0", starting_at: 5, repo_url: "/acme/hello", production_url: "https://hello-acme.g1t.page" })!;
260+ assert.deepEqual(made.state, { id: "proposal_1_0", starting_at: 5, repo_url: "/acme/hello", production_url: "https://hello-acme.g1t.page" });
261+ assert.equal(made.answer.steps!.length, 2);
262+ // A later step merges into the same one; unknown fields are dropped.
263+ const started = withProposalState(made.answer, { id: "proposal_1_0", issue: 1, started: true, session_url: "/s", evil: "x" } as never)!;
264+ assert.equal(started.answer.steps!.length, 2);
265+ assert.deepEqual(started.answer.steps![1], {
266+ type: "proposal_state",
267+ id: "proposal_1_0",
268+ starting_at: 5,
269+ repo_url: "/acme/hello",
270+ production_url: "https://hello-acme.g1t.page",
271+ issue: 1,
272+ started: true,
273+ session_url: "/s",
274+ });
275+ // Only the id: says what is kept, changes nothing.
276+ assert.deepEqual(withProposalState(started.answer, { id: "proposal_1_0" })!.state, started.state);
277+});
+32−1
1111 * src/index.ts writes the messages.
1212 */
1313 // The modules themselves, not the package's index, so the tests load them.
14−import { G1T_KEY, type ChatAnswer, type MemberProfile } from "@g1t/contracts/chat";
14+import { G1T_KEY, type AskProposalState, type ChatAnswer, type MemberProfile } from "@g1t/contracts/chat";
1515 import { ASK_MAX_MESSAGES, ASK_MAX_MESSAGE_CHARS, ASK_MAX_TOTAL_CHARS, type AskMessage, type AskPage } from "@g1t/contracts/ai";
1616
1717 /** How @g1t shows in chat: the g1t mark, never a person. */
4646 }
4747 }
4848
49+/** What a proposal's card has done so far, kept on the answer as a `proposal_state` step. */
50+export type ProposalState = AskProposalState;
51+
52+const PROPOSAL_TEXT = ["production_url", "deploy_error", "session_url", "provider_session_url"] as const;
53+
54+/**
55+ * An answer with what its proposal `id`'s card did merged into it: one
56+ * `proposal_state` step per proposal, made the first time, merged after.
57+ * Only the known fields are kept, texts cut to 500 characters. Null when
58+ * the answer has no such proposal.
59+ */
60+export function withProposalState(answer: ChatAnswer, change: ProposalState): { answer: ChatAnswer; state: ProposalState } | null {
61+ const steps = answer.steps ?? [];
62+ if (!steps.some((step) => step.type === "proposal" && step.id === change.id)) return null;
63+ const at = steps.findIndex((step) => step.type === "proposal_state" && step.id === change.id);
64+ const state: ProposalState = { ...(at === -1 ? {} : (steps[at] as unknown as ProposalState)), id: change.id };
65+ for (const key of PROPOSAL_TEXT) {
66+ const value = change[key];
67+ if (value === null) state[key] = null;
68+ else if (typeof value === "string") state[key] = value.slice(0, 500);
69+ }
70+ if (typeof change.repo_url === "string") state.repo_url = change.repo_url.slice(0, 500);
71+ if (typeof change.default_branch === "string") state.default_branch = change.default_branch.slice(0, 200);
72+ if (Number.isInteger(change.issue) && change.issue! > 0) state.issue = change.issue;
73+ if (typeof change.starting_at === "number") state.starting_at = change.starting_at;
74+ if (change.started === true) state.started = true;
75+ const step = { type: "proposal_state", ...state } as Record<string, unknown>;
76+ const next = at === -1 ? [...steps, step] : steps.map((kept, index) => (index === at ? step : kept));
77+ return { answer: { ...answer, steps: next }, state };
78+}
79+
4980 export function metaColumn(meta: MessageMeta): string | null {
5081 return meta.answer || meta.page || meta.reset ? JSON.stringify(meta) : null;
5182 }
+27−0
8080 import {
8181 G1T_PROFILE,
8282 type MessageMeta,
83+ type ProposalState,
8384 type SaidRow,
8485 conversation,
8586 g1tDmPerson,
9091 parseMeta,
9192 shownAnswer,
9293 startsAnswer,
94+ withProposalState,
9395 } from "./g1t.ts";
9496 import { clearConversation, mayClear } from "./clear.ts";
9597 import { internalAnswer } from "./internal.ts";
17921794 return ok(null);
17931795 }
17941796
1797+ /**
1798+ * Keeps what a proposal's card did (components/ask-steps on the site) on
1799+ * the answer that proposed it, step by step, so the card shows it again
1800+ * wherever the answer is read, and a second click carries on rather than
1801+ * starting over. Only in the viewer's own direct message with g1t. With
1802+ * no change but the id, says what was kept.
1803+ */
1804+ async g1tProposal(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; state: ProposalState }): Promise<Result<ProposalState>> {
1805+ const found = await this.g1tPlace(a.workspace, a.channel_id, a.viewer);
1806+ if (!found.ok) return found;
1807+ const place = found.value;
1808+ const row = await this.messageRow(place.channel.id, String(a.message_id ?? ""));
1809+ if (!row || row.deleted_at || row.author !== G1T_KEY) return fail("not_found", "No such answer from g1t.");
1810+ const meta = parseMeta(row.meta);
1811+ const merged = meta.answer && a.state && typeof a.state.id === "string" ? withProposalState(meta.answer, a.state) : null;
1812+ if (!merged) return fail("not_found", "That answer proposed no such session.");
1813+ const column = metaColumn({ ...meta, answer: merged.answer });
1814+ await this.db.prepare("UPDATE messages SET meta = ? WHERE id = ?").bind(column, row.id).run();
1815+ const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, meta: column }]);
1816+ this.broadcast(place.channel.id, { type: "message.updated", message });
1817+ return ok(merged.state);
1818+ }
1819+
17951820 async g1tStop(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
17961821 const found = await this.g1tPlace(a.workspace, a.channel_id, a.viewer);
17971822 if (!found.ok) return found;
22642289 return Response.json(await service.g1tReset(args));
22652290 case "g1t_retry":
22662291 return Response.json(await service.g1tRetry(args));
2292+ case "g1t_proposal":
2293+ return Response.json(await service.g1tProposal(args));
22672294 case "g1t_stop":
22682295 return Response.json(await service.g1tStop(args));
22692296 case "g1t_clear":
+2−0
319319 };
320320 match ran {
321321 Ok((text, false)) => (text, false, AskToolStatus::Done),
322+ // Nothing there is an answer (a name checked free), not a read that failed.
323+ Ok((text, true)) if ask_tools::is_not_found(&text) => (text, true, AskToolStatus::Done),
322324 Ok((text, true)) => (text, true, AskToolStatus::Failed),
323325 Err(problem) => (problem, true, AskToolStatus::Failed),
324326 }
+11−0
414414 arguments["workspace"].as_str().filter(|slug| !slug.is_empty() && !slug.contains('/')).map(|slug| format!("{site}/{slug}"))
415415 }
416416
417+/// Whether a tool's error says only that what it looked for isn't there:
418+/// a finding, such as a new repository's name being free, not a failed read.
419+pub fn is_not_found(text: &str) -> bool {
420+ let first = text.lines().find(|line| !line.trim().is_empty()).unwrap_or_default().to_ascii_lowercase();
421+ first.contains("not found") || first.starts_with("there is no ") || first.starts_with("no such ")
422+}
423+
417424 /// One line saying what a call found, for the person watching.
418425 pub fn summary(text: &str, failed: bool) -> String {
419426 if failed {
812819 assert_eq!(summary("{\"title\":\"Fix login\",\"number\":7}", false), "Fix login");
813820 assert_eq!(summary("Not allowed: needs pull_requests:write\nmore", true), "Not allowed: needs pull_requests:write");
814821 assert_eq!(summary("plain", false), "Done");
822+ assert!(is_not_found("Repository not found."));
823+ assert!(is_not_found("There is no such project."));
824+ assert!(!is_not_found("Not allowed: needs pull_requests:write"));
825+ assert!(!is_not_found("g1t answered 500."));
815826 }
816827
817828 #[test]

Comments

Sign in to comment.