diff --git a/apps/console/app/[team]/[project]/settings/sign-ins/[signIn]/again/page.tsx b/apps/console/app/[team]/[project]/settings/sign-ins/[signIn]/again/page.tsx
new file mode 100644
index 0000000..7e0c992
--- /dev/null
+++ b/apps/console/app/[team]/[project]/settings/sign-ins/[signIn]/again/page.tsx
@@ -0,0 +1,18 @@
+import type { Metadata } from "next";
+
+import { SignInAddSkeleton, SignInAgainPage } from "../../../../../../_components/sign-in-add-page";
+import { serverReadQuery } from "../../../../../../../lib/server-client";
+import { ProductPage } from "../../../../../_lib/workspace-page";
+
+export const metadata: Metadata = { title: "Sign in again" };
+export default function Page({ params }: { params: Promise<{ team: string; project: string; signIn: string }> }) {
+ return (
+ }
+ prefetch={(client, { projectId }) => client.prefetchQuery(serverReadQuery("projectSignIns", projectId))}
+ >
+ {({ signIn }) => }
+
+ );
+}
diff --git a/apps/console/app/[team]/[project]/settings/sign-ins/new/page.tsx b/apps/console/app/[team]/[project]/settings/sign-ins/new/page.tsx
index f4a40d6..fac4fed 100644
--- a/apps/console/app/[team]/[project]/settings/sign-ins/new/page.tsx
+++ b/apps/console/app/[team]/[project]/settings/sign-ins/new/page.tsx
@@ -9,7 +9,7 @@ export default function Page({ params }: { params: Promise<{ team: string; proje
return (
}
+ fallback={}
prefetch={(client, { projectId }) => client.prefetchQuery(serverReadQuery("projectSignIns", projectId))}
>
diff --git a/apps/console/app/_components/sign-in-add-page.tsx b/apps/console/app/_components/sign-in-add-page.tsx
index 40861c9..9a14765 100644
--- a/apps/console/app/_components/sign-in-add-page.tsx
+++ b/apps/console/app/_components/sign-in-add-page.tsx
@@ -4,18 +4,20 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, TriangleAlert } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import type { BoundSignIn } from "../../lib/web-client";
import { readQuery } from "../../lib/web-client";
import { PageColumn, PageTitle, ProductGate, QueryError, useProduct } from "./shell";
import { PageSkeleton } from "./page-skeleton";
+import { PASSWORD_METHOD } from "./sign-in-methods";
import { GitHubAuthenticatorAction, GitHubAuthenticatorEditor } from "./github-authenticator";
import { SignInStageBody, useSignInStage } from "./sign-in-stage";
import type { Binding } from "./use-sign-in-binding";
/**
- * Adding a way in from a product's settings, as an address of its own.
+ * Adding a way in from a product's settings, or signing in again to one the
+ * product already uses, as an address of its own.
*
* A route of its own rather than a modal: settings is a narrow page and the
* browser wants the width, the job already has a go in, finish,
@@ -56,20 +58,39 @@ function GitHubAuthenticatorOffer({ projectId, binding }: { projectId: string; b
);
}
-function AddStage({ bound }: { bound: readonly BoundSignIn[] }) {
+/**
+ * Signing in again opens the way in's browser on arrival: the press that led
+ * here was the ask, and the page has nothing else to offer before it.
+ */
+function useOpenOnArrival(pick: (method: BoundSignIn["method"], signInId: string) => void, again: BoundSignIn | null) {
+ const asked = useRef(false);
+ useEffect(() => {
+ if (!again || asked.current) return;
+ asked.current = true;
+ pick(again.method, again.id);
+ }, [again, pick]);
+}
+
+function AddStage({ bound, again, title }: {
+ bound: readonly BoundSignIn[];
+ /** The saved way in being signed in to again; null adds a new one. */
+ again: BoundSignIn | null;
+ title: string;
+}) {
const router = useRouter();
const { project, teamHref } = useProduct();
const settings = teamHref("/settings/sign-ins");
const stage = useSignInStage(project, bound);
const { binding, busy } = stage;
+ useOpenOnArrival(stage.bind.actions.pick, again);
return (
Back to settings}
inlineActions
- title="Add a way in"
+ title={title}
/>
-
+
{/* This route is always bind mode, so the stage carries its own at-rest
placeholder: the question before a pick, and what just happened once
a bind has ticked and handed the browser back. Same reading as the
@@ -93,22 +114,45 @@ function AddStage({ bound }: { bound: readonly BoundSignIn[] }) {
);
}
-function AddContent() {
+/** A way in this product does not use, or one ShipBash signs in with itself, has no browser to give back. */
+function NotSignedInAgain({ title }: { title: string }) {
+ const { teamHref } = useProduct();
+ return (
+
+
+
+
This product does not sign in with that way in.
+ Back to settings
+
+
+ );
+}
+
+function AddContent({ againId }: { againId: string | null }) {
const { project } = useProduct();
const signIns = useQuery(readQuery("projectSignIns", project.id));
+ const title = againId ? "Sign in again" : "Add a way in";
if (signIns.isPending) {
- return ;
+ return ;
}
if (signIns.isLoadingError) return void signIns.refetch()} />;
- return ;
+ if (!againId) return ;
+ const again = signIns.data.signIns.find((signIn) => signIn.id === againId);
+ if (!again || again.method === PASSWORD_METHOD) return ;
+ return ;
}
/** Saving a way in, before the product's sign-ins answer. */
-export function SignInAddSkeleton() {
- return ;
+export function SignInAddSkeleton({ title }: { title: string }) {
+ return ;
}
export function SignInAddPage() {
- return ;
+ return ;
+}
+
+/** Signing in again to a saved way in: the same page, opened on that way in's browser. */
+export function SignInAgainPage({ signInId }: { signInId: string }) {
+ return ;
}
diff --git a/apps/console/app/_components/sign-in-setup-head.ts b/apps/console/app/_components/sign-in-setup-head.ts
index 573e272..f9034df 100644
--- a/apps/console/app/_components/sign-in-setup-head.ts
+++ b/apps/console/app/_components/sign-in-setup-head.ts
@@ -54,8 +54,10 @@ export function railHead(binding: Binding | null, saved: readonly TeamSignIn[],
const name = METHOD_NAMES[binding.method];
if (binding.phase === "verifying") return { title: `Checking the ${name} sign-in`, lede: VERIFY_LEDE };
if (binding.method === PASSWORD_METHOD) return { title: name, lede: PASSWORD_LEDE };
+ const lede = binding.touched ? FINISH_LEDE : WATCHING_LEDE;
+ if (binding.signInId) return { title: `Signing in again · ${name}`, lede };
const article = VOWEL_SOUND_METHODS.has(binding.method) ? "an" : "a";
- return { title: `Adding ${article} ${name} sign-in`, lede: binding.touched ? FINISH_LEDE : WATCHING_LEDE };
+ return { title: `Adding ${article} ${name} sign-in`, lede };
}
if (saved.length === 0) return { title: `How does ShipBash get into ${host}?`, lede: PICK_LEDE };
return { title: savedTitle(saved.length), lede: savedLede(saved) };
diff --git a/apps/console/app/_components/team-sign-ins.tsx b/apps/console/app/_components/team-sign-ins.tsx
index 2374942..f9da94e 100644
--- a/apps/console/app/_components/team-sign-ins.tsx
+++ b/apps/console/app/_components/team-sign-ins.tsx
@@ -13,7 +13,7 @@ import { useWorkspace } from "./shell";
import { teamProjects } from "./workspace-address";
import { projectMark } from "./workspace-marks";
import type { Workspace } from "./workspace-session";
-import { METHOD_NAMES, SignInMark } from "./sign-in-methods";
+import { METHOD_NAMES, PASSWORD_METHOD, SignInMark } from "./sign-in-methods";
import { PausedHint, signInState } from "./sign-in-status";
import { Dropdown } from "./dropdown";
import { GitHubAuthenticatorEditor, GitHubAuthenticatorFact } from "./github-authenticator";
@@ -54,6 +54,22 @@ function takeoverHref(
return target ? teamHref("/settings/sign-ins/new", { project: target.slug }) : null;
}
+/**
+ * Where "Sign in again" goes, or null when it is not offered. A way in that
+ * keeps a session a person made holds it only until the provider ends it,
+ * which no status on this row can see coming, so the browser is always offered
+ * back — on the Project in scope, or one that uses this way in. The password
+ * lane signs in by itself, which is "Check again".
+ */
+function signInAgainHref(
+ signIn: TeamSignIn,
+ home: Project | null,
+ teamHref: Workspace["teamHref"],
+): Route | null {
+ if (signIn.method === PASSWORD_METHOD || !home) return null;
+ return teamHref(`/settings/sign-ins/${encodeURIComponent(signIn.id)}/again`, { project: home.slug });
+}
+
/**
* Where "Check again" would run, or null when it cannot run anywhere. A sign-in
* belongs to the Team, but proving one means opening a product with it, so the
@@ -62,14 +78,10 @@ function takeoverHref(
* nothing left to open, and the check would fail for a reason the reader cannot
* act on.
*/
-function verifyTarget(
- signIn: TeamSignIn,
- projects: readonly Project[],
- scoped: Project | null,
-): Project | null {
+function verifyTarget(signIn: TeamSignIn, home: Project | null): Project | null {
if (!signIn.keyAvailable) return null;
if (signIn.status !== "paused" && signIn.status !== "failed") return null;
- return projects.find((project) => project.id === scoped?.id) ?? projects[0] ?? null;
+ return home;
}
/**
@@ -121,7 +133,7 @@ function RunsMark({ projects }: { projects: readonly Project[] }) {
);
}
-function SignInRow({ signIn, projects, runsOn, admin, onRemove, onVerify, takeover, authenticatorProject, onAuthenticator, onUnbind }: {
+function SignInRow({ signIn, projects, runsOn, admin, onRemove, onVerify, again, takeover, authenticatorProject, onAuthenticator, onUnbind }: {
signIn: TeamSignIn;
projects: readonly Project[];
/** The products whose verification runs sign in with this way in. */
@@ -130,6 +142,8 @@ function SignInRow({ signIn, projects, runsOn, admin, onRemove, onVerify, takeov
onRemove: () => void;
/** Hand the saved session to a leg once more, when a product can receive it. */
onVerify: (() => void) | null;
+ /** The browser back, to sign in to this way in again. */
+ again: Route | null;
/** The way back in that does not need a password, when this row is paused. */
takeover: Route | null;
authenticatorProject: Project | null;
@@ -154,6 +168,7 @@ function SignInRow({ signIn, projects, runsOn, admin, onRemove, onVerify, takeov
{signIn.status === "ready" ? null : {state.text}}
+ {admin && again ? Sign in again : null}
{admin && onVerify ? (
) : null}
@@ -203,22 +218,25 @@ function TeamSignInRows({ signIns, usage, runs, scoped, admin, teamHref, onRemov
{signIns.map((signIn) => {
const used = usage.get(signIn.id) ?? [];
- const target = verifyTarget(signIn, used, scoped);
- const authenticatorProject = used.find((entry) => entry.id === scoped?.id) ?? used[0] ?? null;
+ // A row's actions open a product: the one in scope when it uses this
+ // way in, otherwise the first that does.
+ const home = used.find((entry) => entry.id === scoped?.id) ?? used[0] ?? null;
+ const target = verifyTarget(signIn, home);
return (
onRemove(signIn.id)}
onVerify={target ? () => onVerify(target.id, signIn.id) : null}
- authenticatorProject={authenticatorProject}
+ authenticatorProject={home}
onAuthenticator={() => {
- if (authenticatorProject) onAuthenticator(authenticatorProject.id, signIn.id);
+ if (home) onAuthenticator(home.id, signIn.id);
}}
onUnbind={(projectId) => onUnbind(projectId, signIn.id)}
projects={used}
runsOn={runs.get(signIn.id) ?? []}
signIn={signIn}
+ again={signInAgainHref(signIn, home, teamHref)}
takeover={takeoverHref(signIn, used, scoped, teamHref)}
/>
);
diff --git a/apps/console/app/_components/use-sign-in-binding.ts b/apps/console/app/_components/use-sign-in-binding.ts
index d7c1f07..3cdbc35 100644
--- a/apps/console/app/_components/use-sign-in-binding.ts
+++ b/apps/console/app/_components/use-sign-in-binding.ts
@@ -45,11 +45,15 @@ interface SaveAttempt {
signInId: string | null;
}
-function opened(method: TeamSignInMethod, draft: PasswordDraft | null): Binding {
+/**
+ * A bind just begun. Signing in again to a saved way in starts on its row,
+ * which is where the session the person makes is kept.
+ */
+function opened(method: TeamSignInMethod, draft: PasswordDraft | null, signInId: string | null): Binding {
return {
method,
phase: draft ? "form" : "opening",
- signInId: null,
+ signInId,
failureReason: null,
touched: false,
confirming: false,
@@ -139,8 +143,8 @@ function useBindingWrites(
});
const finish = useMutation({
- mutationFn: async (method: SessionSignInMethod) => {
- const { signIn } = await webClient.completeSignInTakeover(projectId, method);
+ mutationFn: async ({ method, signInId }: { method: SessionSignInMethod; signInId: string | null }) => {
+ const { signIn } = await webClient.completeSignInTakeover(projectId, method, signInId);
return signIn;
},
onSuccess: async (signIn) => {
@@ -254,9 +258,9 @@ function useBindingSelection(options: {
setTakeoverPhase: Dispatch>;
}) {
const { loginUrl, takeover, release, setBinding, dropPicture, setTakeoverPhase } = options;
- const pick = useCallback((method: TeamSignInMethod) => {
+ const pick = useCallback((method: TeamSignInMethod, signInId: string | null = null) => {
const draft = method === PASSWORD_METHOD ? emptyPasswordDraft(loginUrl) : null;
- setBinding(opened(method, draft));
+ setBinding(opened(method, draft, signInId));
dropPicture();
setTakeoverPhase(method === PASSWORD_METHOD ? "agent" : "claiming");
takeover.mutate(method);
@@ -326,7 +330,9 @@ export function useSignInBinding(project: Project) {
const presentation = useSignInPresentation({
binding, phase: takeoverPhase, setPhase: setTakeoverPhase,
- submit: (method, accepted) => finish.mutate(method, { onSuccess: accepted }),
+ // The row the bind is on, when it has one: a saved way in signed in to
+ // again, or the row an earlier try of this bind saved.
+ submit: (method, accepted) => finish.mutate({ method, signInId: binding?.signInId ?? null }, { onSuccess: accepted }),
});
const { drop: dropPicture } = presentation;
const { pick, discard, cancel } = useBindingSelection({
diff --git a/apps/console/app/globals.css b/apps/console/app/globals.css
index 5300c60..0bc3509 100644
--- a/apps/console/app/globals.css
+++ b/apps/console/app/globals.css
@@ -323,8 +323,9 @@ h1 { margin-bottom: 7px; font-size: clamp(25px, 3vw, var(--text-3xl)); line-heig
.rp-poster { position: absolute; inset: 0; z-index: 6; display: grid; grid-template-columns: minmax(0, 1fr); align-content: end; overflow: hidden; pointer-events: none; background: linear-gradient(to top, rgba(10,10,12,.93) 0%, rgba(10,10,12,.9) 22%, rgba(10,10,12,.84) 36%, rgba(10,10,12,.74) 48%, rgba(10,10,12,.6) 60%, rgba(10,10,12,.42) 72%, rgba(10,10,12,.24) 84%, rgba(10,10,12,.1) 93%, rgba(10,10,12,0) 100%); }
.rp-poster:not([hidden]) + .qrp-caption, .qrp-caption:has(+ .rp-poster) { display: none; }
/* With a cover on it, the stage is not empty: saying so under the scrim reads
- as two answers to one question. */
-.case-evidence-stage:has(.rp-poster) .case-evidence-empty { display: none; }
+ as two answers to one question. Only the words go — the empty frame is the
+ one box the absolutely placed cover has to sit on. */
+.case-evidence-stage:has(.rp-poster) .case-evidence-empty strong { visibility: hidden; }
.rp-poster-ink { display: grid; gap: 14px; padding: clamp(26px, 5vw, 52px) clamp(16px, 2.6vw, 28px) clamp(16px, 2.6vw, 28px); }
.rp-poster button, .rp-poster a, .rp-poster-desc { pointer-events: auto; }
/* The run's own words are the page's ONLY copy of them, so they are never
diff --git a/apps/console/lib/production-sign-in-takeover.ts b/apps/console/lib/production-sign-in-takeover.ts
index a9d28b8..411811a 100644
--- a/apps/console/lib/production-sign-in-takeover.ts
+++ b/apps/console/lib/production-sign-in-takeover.ts
@@ -23,6 +23,7 @@ import {
createSignInRecheck,
type SignInVerificationContext,
type SignInVerificationStore,
+ takeoverRow,
} from "@shipbash/control-plane/service/sign-in-verification";
import type { SignInTakeover, SignInTakeoverAccess } from "@shipbash/control-plane/store/sign-in-takeovers";
import type { TargetAuthStateAccess } from "@shipbash/control-plane/store/target-auth-state";
@@ -174,17 +175,18 @@ async function release(resolved: Resolved, projectId: string): Promise {
/**
* "I'm done": the way in saved for the Team as ready and bound to this
- * Project, and the session the person made sealed on the Project's profile
+ * Project, on a new row or on the saved one the person signed in to again, and
+ * the session the person made sealed on the Project's profile
* when runs sign in with this way in — the first one the Project saves, or the
* one they already use, whose runs parked on a sign-in then resume. The
* browser has done its job whichever way the save went, so the Sandbox goes.
*/
async function complete(
resolved: Resolved,
- input: Scope & { method: Parameters[0]["method"] },
+ input: Parameters[0],
): Promise {
const { dependencies, factory } = resolved;
- const { actor, teamId, projectId, method } = input;
+ const { actor, teamId, projectId } = input;
const productionUrl = (await dependencies.store.getProject(actor, teamId, projectId))?.productionUrl;
if (!productionUrl) {
throw new HttpError(409, "PRODUCTION_URL_REQUIRED", "This Project has no Production URL, so there is nowhere to keep the sign-in for.");
@@ -203,12 +205,12 @@ async function complete(
} finally {
await endTakeover(resolved, existing);
}
- const created = await dependencies.store.createTeamSignIn(actor, teamId, { method });
+ const target = await takeoverRow(dependencies.store, input);
// A ready way in carries the session it was ticked on (team_sign_ins_ready_check).
// The Sandbox's key died with it, so the row keeps the state itself — sealed
// at rest by the Team keyring like everything on it — named by its own bytes,
// with the GitHub account its cookies name.
- const signIn = await dependencies.store.updateTeamSignIn(actor, teamId, created.id, {
+ const signIn = await dependencies.store.updateTeamSignIn(actor, teamId, target.id, {
status: "ready",
failureReason: null,
consecutiveFailures: 0,
@@ -217,7 +219,7 @@ async function complete(
version: authStateVersion(session),
githubAccountLogin,
},
- }) ?? created;
+ }) ?? target;
await dependencies.store.bindProjectSignIn(actor, teamId, projectId, signIn.id);
await dependencies.store.recordTeamSignInEvent(actor, teamId, { signInId: signIn.id, projectId, actor: "user", action: "saved" });
if (await dependencies.store.claimProjectRunSignIn(actor, teamId, projectId, signIn.id)) {
diff --git a/apps/console/lib/web-client-sign-ins.ts b/apps/console/lib/web-client-sign-ins.ts
index 1421806..7559bcc 100644
--- a/apps/console/lib/web-client-sign-ins.ts
+++ b/apps/console/lib/web-client-sign-ins.ts
@@ -56,12 +56,17 @@ export const signInsClient = {
/**
* "I'm done". It answers with a sign-in that is `verifying`, never one that
* is ready: the tick comes from the leg this call starts, which the page
- * follows with readSignInVerification.
+ * follows with readSignInVerification. With `signInId` the session lands on
+ * that saved way in, the one the person signed in to again.
*/
completeSignInTakeover: (
projectId: string,
method: Exclude,
- ) => callApi(completeSignInTakeoverContract, { params: { projectId }, body: { method } }),
+ signInId: string | null,
+ ) => callApi(completeSignInTakeoverContract, {
+ params: { projectId },
+ body: signInId ? { method, signInId } : { method },
+ }),
/**
* Check a saved sign-in again, on the row it is already stored on. For the
* password lane this is `Save and verify`: ShipBash types the credential
diff --git a/packages/contracts/src/api/sign-in-verification.ts b/packages/contracts/src/api/sign-in-verification.ts
index 2d4f7dc..3bb0099 100644
--- a/packages/contracts/src/api/sign-in-verification.ts
+++ b/packages/contracts/src/api/sign-in-verification.ts
@@ -60,7 +60,14 @@ export const completeSignInTakeoverContract = defineApiContract({
path: "/api/projects/:projectId/sign-ins/takeover/complete",
params: projectParamsSchema,
query: null,
- body: z.object({ method: sessionSignInMethodSchema }),
+ body: z.object({
+ method: sessionSignInMethodSchema,
+ /**
+ * The saved way in the person signed in to again, which keeps the session
+ * on its own row; a new way in has none, and gets a row of its own.
+ */
+ signInId: idSchema.optional(),
+ }),
response: z.object({ signIn: projectSignInSchema }),
// Accepted, not done: the leg that decides is still running.
successStatuses: [202],
diff --git a/packages/control-plane/src/api/gateways.ts b/packages/control-plane/src/api/gateways.ts
index 762e8c2..400eab8 100644
--- a/packages/control-plane/src/api/gateways.ts
+++ b/packages/control-plane/src/api/gateways.ts
@@ -51,6 +51,8 @@ export interface SignInVerificationGateway {
teamId: string;
projectId: string;
method: TeamSignInMethod;
+ /** The bound way in signed in to again; null saves a new one. */
+ signInId: string | null;
}): Promise;
/**
* Checks a sign-in this Project has bound, again: the auto-login leg for a
diff --git a/packages/control-plane/src/api/routes-sign-in-verification.ts b/packages/control-plane/src/api/routes-sign-in-verification.ts
index 57ea558..4798437 100644
--- a/packages/control-plane/src/api/routes-sign-in-verification.ts
+++ b/packages/control-plane/src/api/routes-sign-in-verification.ts
@@ -39,8 +39,8 @@ async function handleComplete(context: RouteContext, rawProjectId: string): Prom
const gateway = requireGateway(options);
const { projectId } = parseParams(completeSignInTakeoverContract.params, { projectId: rawProjectId });
const teamId = await adminScope(context, projectId);
- const { method } = await parseBody(request, completeSignInTakeoverContract.body);
- const signIn = await gateway.complete({ actor, teamId, projectId, method });
+ const { method, signInId } = await parseBody(request, completeSignInTakeoverContract.body);
+ const signIn = await gateway.complete({ actor, teamId, projectId, method, signInId: signInId ?? null });
sendContractJson(response, 202, completeSignInTakeoverContract, { signIn: teamSignInView(signIn) });
}
diff --git a/packages/control-plane/src/service/sign-in-verification.ts b/packages/control-plane/src/service/sign-in-verification.ts
index 0b3e973..2bd7819 100644
--- a/packages/control-plane/src/service/sign-in-verification.ts
+++ b/packages/control-plane/src/service/sign-in-verification.ts
@@ -240,6 +240,50 @@ async function legMaterial(
return { legActor: "verification_leg", state: Buffer.from(session, "base64") };
}
+/**
+ * A sign-in this Project has bound and is not checking right now.
+ *
+ * Read through the Project's own bindings: a sign-in this Project never bound
+ * is not one it may spend a browser on. And one row, one leg: a second leg
+ * started while the first is still going takes the exchange directory out from
+ * under it, and the leg that was already running ends `unknown` on a session
+ * it never tried.
+ */
+async function boundSignIn(
+ store: SignInVerificationStore,
+ input: { actor: TenantActor; teamId: string; projectId: string; signInId: string },
+): Promise {
+ const bound = (await store.listProjectSignIns(input.actor, input.teamId, input.projectId))
+ .find((candidate) => candidate.id === input.signInId);
+ if (!bound) throw new HttpError(404, "NOT_FOUND", "This Project has no such sign-in.");
+ if (bound.status === "verifying") {
+ throw new HttpError(
+ 409,
+ "SIGN_IN_CHECK_IN_PROGRESS",
+ "This sign-in is already being checked; wait for that check to finish.",
+ );
+ }
+ return bound;
+}
+
+/**
+ * The row a finished takeover saves to: the bound way in the person signed in
+ * to again, or a new one. Signing in again keeps the row, and with it the
+ * Projects on it and the GitHub authenticator saved for its account.
+ */
+export async function takeoverRow(
+ store: SignInVerificationStore,
+ input: Parameters[0],
+): Promise {
+ const { actor, teamId, projectId, method, signInId } = input;
+ if (!signInId) return store.createTeamSignIn(actor, teamId, { method });
+ const bound = await boundSignIn(store, { actor, teamId, projectId, signInId });
+ if (bound.method !== method) {
+ throw new HttpError(409, "SIGN_IN_METHOD_MISMATCH", "This sign-in is a different way in.");
+ }
+ return bound;
+}
+
/**
* "Check this one again", for a sign-in the Project already holds: the
* auto-login leg for a saved password, the verification leg for a session
@@ -256,22 +300,7 @@ export function createSignInRecheck(context: SignInRecheckContext): SignInVerifi
"This Project has no Production URL, so there is nothing to sign in to.",
);
}
- // Read through the Project's own bindings: a sign-in this Project never
- // bound is not one it may spend a browser on.
- const bound = (await store.listProjectSignIns(actor, teamId, projectId))
- .find((candidate) => candidate.id === signInId);
- if (!bound) throw new HttpError(404, "NOT_FOUND", "This Project has no such sign-in.");
- // One row, one leg. A second check started while the first is still
- // going takes the exchange directory out from under it, and the check
- // that was already running ends `unknown` on a session it never tried.
- // `complete` is unaffected: it makes a fresh row every time.
- if (bound.status === "verifying") {
- throw new HttpError(
- 409,
- "SIGN_IN_CHECK_IN_PROGRESS",
- "This sign-in is already being checked; wait for that check to finish.",
- );
- }
+ const bound = await boundSignIn(store, { actor, teamId, projectId, signInId });
const material = await legMaterial(store, { actor, teamId, signIn: bound });
// A row that stopped trying starts again here, whichever lane it is on,
// and the trail says so separately: "somebody asked for another check"
@@ -313,7 +342,8 @@ export function createSignInVerificationGateway(
return {
streamUrl: (signInId) => verify.streamUrl(signInId),
chooseForRuns: createRunSignInChoice(context),
- complete: async ({ actor, teamId, projectId, method }) => {
+ complete: async (input) => {
+ const { actor, teamId, projectId, method } = input;
const project = await store.getProject(actor, teamId, projectId);
const productionUrl = project?.productionUrl;
if (!productionUrl) {
@@ -324,15 +354,18 @@ export function createSignInVerificationGateway(
);
}
const captured = await takeover.capture(projectId);
- const created = await store.createTeamSignIn(actor, teamId, { method });
- const signIn = await store.updateTeamSignIn(actor, teamId, created.id, {
+ const target = await takeoverRow(store, input);
+ // A session the person just made starts the row's count over.
+ const signIn = await store.updateTeamSignIn(actor, teamId, target.id, {
status: "verifying",
+ failureReason: null,
+ consecutiveFailures: 0,
session: {
state: captured.state.toString("base64"),
version: captured.version,
githubAccountLogin: captured.githubAccountLogin,
},
- }) ?? created;
+ }) ?? target;
await store.bindProjectSignIn(actor, teamId, projectId, signIn.id);
for (const event of [
{ actor: "user" as const, action: "saved" as const },
diff --git a/packages/control-plane/src/slack-alert-delivery.ts b/packages/control-plane/src/slack-alert-delivery.ts
index 3b3586c..21ac753 100644
--- a/packages/control-plane/src/slack-alert-delivery.ts
+++ b/packages/control-plane/src/slack-alert-delivery.ts
@@ -17,6 +17,7 @@ import { isRecord } from "./is-record.ts";
import type { SlackMessagePayload } from "./slack-alert-messages.ts";
import type { SlackAlertRecording } from "./slack-alert-recording.ts";
import { SlackCallError, SlackTransportError, slackCall } from "./slack-channels.ts";
+import { withDeleteRow } from "./slack-delete-row.ts";
/** How long handing Slack one replay's bytes may take. */
const UPLOAD_TIMEOUT_MS = 45_000;
@@ -37,7 +38,8 @@ function stringAt(value: unknown, key: string): string | null {
}
/**
- * Posts one message and answers with its `ts`, or null in the unlikely case
+ * Posts one message, with the Delete row at its foot (slack-delete-row.ts),
+ * and answers with its `ts`, or null in the unlikely case
* Slack said `ok` without one. Replies never unfurl: their only URL is the
* button's, and nothing in them should grow a preview. `parse: none` keeps a
* customer string in `text` that reads as a URL from being linked — and so
@@ -54,7 +56,7 @@ export async function postSlackMessage(token: string, input: {
channel: input.channelId,
text: payload.text,
parse: "none",
- blocks: payload.blocks,
+ blocks: withDeleteRow(payload.blocks),
unfurl_links: payload.unfurl_links ?? false,
unfurl_media: payload.unfurl_media ?? false,
...(input.threadTs === null ? {} : { thread_ts: input.threadTs }),
@@ -123,7 +125,7 @@ async function untilShownInThread(token: string, input: { channelId: string; thr
/**
* Shares a staged file into a thread as the reply itself, the reply's blocks
- * drawn above it, and returns once the thread shows it. Slack documents no
+ * and the Delete row drawn above it, and returns once the thread shows it. Slack documents no
* `text` here and builds the notification line itself, and the blocks hold
* no text URL, so nothing needs unfurl flags. Slack shares the file after
* files.completeUploadExternal answers, so a message posted at once would
@@ -139,7 +141,7 @@ export async function shareSlackFile(token: string, input: {
files: [{ id: input.file.fileId, title: input.file.title }],
channel_id: input.channelId,
thread_ts: input.threadTs,
- blocks: input.payload.blocks,
+ blocks: withDeleteRow(input.payload.blocks),
}, token);
await untilShownInThread(token, input);
}
diff --git a/packages/control-plane/src/slack-alert-messages.ts b/packages/control-plane/src/slack-alert-messages.ts
index 5705a72..8fd4ee7 100644
--- a/packages/control-plane/src/slack-alert-messages.ts
+++ b/packages/control-plane/src/slack-alert-messages.ts
@@ -16,7 +16,8 @@
* ② failed carries no video and no button. Its run URL is the linked suite
* name, and it is posted with `unfurl_links`, so Slack's own preview draws the
* run's card under it. No reply puts a URL in its text or rich text, because
- * a text URL is what Slack unfurls; a URL button is not.
+ * a text URL is what Slack unfurls; a URL button is not. Delivery puts the
+ * Delete row at the foot of every one of them (slack-delete-row.ts).
*/
import { evidenceCommand } from "@shipbash/contracts/runtime/evidence-command.mjs";
diff --git a/packages/control-plane/src/slack-channels.ts b/packages/control-plane/src/slack-channels.ts
index 570465e..85a98df 100644
--- a/packages/control-plane/src/slack-channels.ts
+++ b/packages/control-plane/src/slack-channels.ts
@@ -15,6 +15,7 @@ import type { SlackDeliveryError } from "@shipbash/contracts/database-contract-t
import { isRecord } from "./is-record.ts";
import { connectionMessage } from "./slack-alert-messages.ts";
+import { withDeleteRow } from "./slack-delete-row.ts";
/** A channel this workspace offers, as the pane lists it. */
export interface SlackChannel {
@@ -216,7 +217,7 @@ async function joinSlackChannel(token: string, channelId: string): Promise
await slackCall("conversations.join", { channel: channelId }, token);
}
-/** The connection message: which project now alerts here, and who set it up. */
+/** The connection message: which project now alerts here, and who set it up, with the Delete row at its foot. */
async function postSlackTestMessage(token: string, input: {
channelId: string;
projectName: string;
@@ -227,7 +228,7 @@ async function postSlackTestMessage(token: string, input: {
channel: input.channelId,
text: message.text,
parse: "none",
- blocks: message.blocks,
+ blocks: withDeleteRow(message.blocks),
}, token);
}
diff --git a/packages/control-plane/src/slack-chat/adapter.ts b/packages/control-plane/src/slack-chat/adapter.ts
index 3fb9eff..9d7c3f7 100644
--- a/packages/control-plane/src/slack-chat/adapter.ts
+++ b/packages/control-plane/src/slack-chat/adapter.ts
@@ -12,7 +12,9 @@ import type { AdapterPostableMessage, Message } from "chat";
import type { SlackImageRef } from "@shipbash/contracts/steer-images";
import { isRecord } from "../is-record.ts";
+import { sections } from "../slack-block-kit.ts";
import { SLACK_UNCERTAIN_CODES } from "../slack-channels.ts";
+import { withDeleteRow } from "../slack-delete-row.ts";
import { slackMarkdown, slackMarkdownStream } from "./slack-markdown.ts";
/** Slack's streamer of one message, as Slack's Web API client opens it for Chat SDK. */
@@ -24,6 +26,47 @@ function streamRenderer(): (ar
return (args) => (args.markdown_text === undefined ? args : { ...args, markdown_text: render(args.markdown_text) });
}
+/** What a message is made of, as chat.postMessage and chat.update take it. */
+interface MessageContent {
+ text?: string;
+ markdown_text?: string;
+ blocks?: { type: string }[];
+}
+
+/**
+ * A message posted or drawn again with the Delete row at its foot
+ * (slack-delete-row.ts): under its blocks, or else under its words made
+ * blocks — markdown as Slack's markdown block, which is what Slack makes of
+ * `markdown_text`, and plain text as sections. Slack takes no blocks beside
+ * `markdown_text`, so that field is dropped from the call and its words
+ * become the message's notification text.
+ */
+function withFoot(args: Args): Args {
+ const { blocks, markdown_text: markdown, text } = args;
+ if (blocks && blocks.length > 0) return { ...args, blocks: withDeleteRow(blocks) };
+ if (markdown) return { ...args, markdown_text: undefined, text: markdown, blocks: withDeleteRow([{ type: "markdown", text: markdown }]) };
+ if (!text) return args;
+ return { ...args, blocks: withDeleteRow(sections(text).map((words) => ({ type: "section", text: { type: "mrkdwn", text: words } }))) };
+}
+
+/** One of Slack's Web API clients the adapter calls through. */
+type SlackWebClient = SlackAdapter["webClient"];
+
+/**
+ * Sets a client to put the Delete row at the foot of every message it posts,
+ * draws again or stops streaming. A stream's words are its own: the row goes
+ * under them as the stop's blocks, which Slack appends.
+ */
+function footMessages(client: SlackWebClient): void {
+ const { chat } = client;
+ const postMessage = chat.postMessage.bind(chat);
+ const update = chat.update.bind(chat);
+ const stopStream = chat.stopStream.bind(chat);
+ chat.postMessage = (args) => postMessage(withFoot(args));
+ chat.update = (args) => update(withFoot(args));
+ chat.stopStream = (args) => stopStream({ ...args, blocks: withDeleteRow(args.blocks ?? []) });
+}
+
/** Chat SDK's converter, whose markdown Slack renders as CommonMark would. */
class ShipBashFormatConverter extends SlackFormatConverter {
override toSlackPayload(message: AdapterPostableMessage): ReturnType {
@@ -51,11 +94,15 @@ class ShipBashFormatConverter extends SlackFormatConverter {
* downloaded through the SDK's own guarded fetch (`downloadImage`), whose
* Attachment drops the file id that files.info needs. And a thread's start
* is read in the one call Slack allows, never paged or waited out
- * (`threadStart`).
+ * (`threadStart`). Every message it posts, draws again or stops streaming
+ * carries the Delete row at its foot; an ephemeral note does not, since it
+ * leaves on its own.
*/
export class ShipBashSlackAdapter extends SlackAdapter {
/** The streamer of the message each thread's reply last streamed into: one turn speaks in a thread at a time. */
private readonly streamers = new Map();
+ /** The Web API clients set to put the Delete row under what they post. */
+ private readonly footedClients = new WeakSet();
protected override readonly formatConverter = new ShipBashFormatConverter();
@@ -65,7 +112,7 @@ export class ShipBashSlackAdapter extends SlackAdapter {
// has no option for one, so the streamer holds 256 characters before it calls Slack: a short reply
// would show only when it ends. A one-character buffer sends each line as Chat SDK commits it — one
// chat.appendStream per line at most, one call at a time, within Slack's Tier 4 limit for it.
- const client = this._client;
+ const client = this.footed(this._client);
const chatStream = client.chatStream.bind(client);
client.chatStream = (params) => {
const streamer = chatStream({ ...params, buffer_size: 1 });
@@ -83,6 +130,22 @@ export class ShipBashSlackAdapter extends SlackAdapter {
return super.botUserId ?? "";
}
+ /**
+ * The client for the token the call runs as. Chat SDK makes one per bot
+ * token beside the one it calls through itself, and ShipBash posts and
+ * draws through these too, so each is footed once (`footMessages`).
+ */
+ override get webClient(): SlackWebClient {
+ return this.footed(super.webClient);
+ }
+
+ private footed(client: SlackWebClient): SlackWebClient {
+ if (this.footedClients.has(client)) return client;
+ footMessages(client);
+ this.footedClients.add(client);
+ return client;
+ }
+
/**
* The workspace the adapter resolved the token for, from the delivery's
* envelope. A message's own `team` names its author's workspace, which in a
diff --git a/packages/control-plane/src/slack-chat/bot.ts b/packages/control-plane/src/slack-chat/bot.ts
index e2a1b41..2eb669d 100644
--- a/packages/control-plane/src/slack-chat/bot.ts
+++ b/packages/control-plane/src/slack-chat/bot.ts
@@ -20,10 +20,12 @@ import pg from "pg";
import type { AgentServices } from "@shipbash/contracts/runtime/agent-model.mjs";
import type { SlackAppConfiguration } from "../slack-app.ts";
+import { DELETE_MESSAGE_ACTION } from "../slack-delete-row.ts";
import { ShipBashSlackAdapter } from "./adapter.ts";
import { type ConversationActionContext, stopFromSlack, stopWorkFromSlack } from "./conversation-actions.ts";
import type { SlackConversationPort } from "./conversation.ts";
import { type InboundContext, answerMessages } from "./inbound.ts";
+import { type MessageDeletionContext, deleteMessageFromSlack, forgetDeletedMessage } from "./message-deletion.ts";
import { PROJECT_CHOICE_ACTION, chooseProject } from "./project-choice.ts";
import { forgetLine, keepLines } from "./thread-lines.ts";
import { type ThreadListStore, createThreadLists } from "./thread-lists.ts";
@@ -31,7 +33,7 @@ import { STOP_WORK_ACTION } from "./todo-list.ts";
export type SlackChatBot = Chat<{ slack: ShipBashSlackAdapter }>;
-export type SlackChatStore = InboundContext["store"] & ConversationActionContext["store"] & ThreadListStore;
+export type SlackChatStore = InboundContext["store"] & ConversationActionContext["store"] & MessageDeletionContext["store"] & ThreadListStore;
export interface SlackChat {
bot: SlackChatBot;
@@ -110,9 +112,14 @@ export function createSlackChat(input: {
// are what a conversation that starts in it opens on (thread-lines.ts).
bot.onNewMessage(/[\s\S]*/u, (_thread, message, queued) => keepLines(context, [...(queued?.skipped ?? []), message], "line"));
bot.onMessageUpdated((_thread, message) => keepLines(context, [message], "edit"));
- bot.onMessageDeleted((event) => forgetLine(context, event.threadId, event.messageId).catch((error: unknown) => {
- console.error(`[slack-chat] line ${event.messageId} in ${event.threadId} could not be forgotten:`, error);
- }));
+ // A deleted message of ShipBash's own also lets go of what hung from it, whoever deleted it (message-deletion.ts).
+ const deleting: MessageDeletionContext = { slack, store };
+ bot.onMessageDeleted(async (event) => {
+ await forgetLine(context, event.threadId, event.messageId).catch((error: unknown) => {
+ console.error(`[slack-chat] line ${event.messageId} in ${event.threadId} could not be forgotten:`, error);
+ });
+ await forgetDeletedMessage(deleting, event.threadId, event.messageId);
+ });
bot.onAction(PROJECT_CHOICE_ACTION, (event) => chooseProject(context, event));
// The owner's Stop — Slack's on the thread, or the one beside a piece of
// work — which redraws the lists of what it stopped through the posting
@@ -121,6 +128,8 @@ export function createSlackChat(input: {
const stopping: ConversationActionContext = { slack, store, conversations, lists: createThreadLists({ store, publicOrigin }) };
bot.onAgentSessionStopped((event) => stopFromSlack(stopping, event));
bot.onAction(STOP_WORK_ACTION, (event) => stopWorkFromSlack(stopping, event));
+ // The Delete at the foot of every message ShipBash posts, for anyone who sees it.
+ bot.onAction(DELETE_MESSAGE_ACTION, (event) => deleteMessageFromSlack(deleting, event));
return {
bot,
async close() {
diff --git a/packages/control-plane/src/slack-chat/message-deletion.ts b/packages/control-plane/src/slack-chat/message-deletion.ts
new file mode 100644
index 0000000..f06c296
--- /dev/null
+++ b/packages/control-plane/src/slack-chat/message-deletion.ts
@@ -0,0 +1,79 @@
+/**
+ * A message of ShipBash's leaving Slack, and what ShipBash kept about it.
+ *
+ * Delete sits at the foot of every message ShipBash posts (slack-delete-row.ts).
+ * Anyone who can see the message may remove it, and Slack has already asked
+ * them to confirm, so the click is not checked against a ShipBash account:
+ * the message goes, deleted as the workspace's bot, and the recording it
+ * shared goes with it. A message someone else already deleted counts as gone;
+ * any other refusal is logged, and the clicker told in a message only they see.
+ *
+ * However a message goes — this Delete, or a workspace admin deleting it in
+ * Slack, which arrives as Slack's deletion event — the store lets go of what
+ * hung from it (`forgetDeletedMessage`): an alert thread it opened closes, so
+ * the subject's next result opens a thread of its own, and a to-do list it
+ * carried is no longer drawn. The Delete does that itself too, since Slack's
+ * event can be lost; saying it twice changes nothing.
+ */
+import type { ActionEvent, AdapterPostableMessage } from "chat";
+
+import { isRecord } from "../is-record.ts";
+import type { InternalSlackStore } from "../store/internal-lanes-slack.ts";
+
+export interface MessageDeletionContext {
+ slack: {
+ deliverySlackTeamId(): string | null;
+ decodeThreadId(threadId: string): { channel: string; threadTs: string };
+ deleteMessage(threadId: string, messageId: string): Promise;
+ postEphemeral(threadId: string, userId: string, message: AdapterPostableMessage): Promise;
+ webClient: { files: { delete(args: { file: string }): Promise } };
+ };
+ store: Pick;
+}
+
+/** Slack's answer for a message that is already gone. */
+function alreadyGone(error: unknown): boolean {
+ return isRecord(error) && isRecord(error.data) && error.data.error === "message_not_found";
+}
+
+/** The files a message shared, as the click's payload carries the message. */
+function sharedFiles(raw: unknown): string[] {
+ const message = isRecord(raw) ? raw.message : null;
+ const files = isRecord(message) && Array.isArray(message.files) ? message.files : [];
+ return files.flatMap((file) => (isRecord(file) && typeof file.id === "string" ? [file.id] : []));
+}
+
+/** The store lets go of what hung from a message of ShipBash's that is gone. Never throws. */
+export async function forgetDeletedMessage(context: Pick, threadId: string, ts: string): Promise {
+ const slackTeamId = context.slack.deliverySlackTeamId();
+ if (!slackTeamId) return;
+ const { channel } = context.slack.decodeThreadId(threadId);
+ await context.store.forgetSlackMessageInternal({ slackTeamId, channelId: channel, ts }).catch((error: unknown) => {
+ console.error(`[slack-chat] what ShipBash kept about deleted message ${ts} in ${channel} could not be let go:`, error);
+ });
+}
+
+/** The message the Delete was pressed under, gone with the recording it shared; and what hung from it, let go. */
+export async function deleteMessageFromSlack(context: MessageDeletionContext, event: ActionEvent): Promise {
+ const { threadId, messageId } = event;
+ const clicker = event.user.userId;
+ const { channel } = context.slack.decodeThreadId(threadId);
+ try {
+ await context.slack.deleteMessage(threadId, messageId);
+ console.warn(`[slack-chat] ${clicker} deleted ShipBash's message ${messageId} in ${channel}.`);
+ } catch (error) {
+ if (!alreadyGone(error)) {
+ console.error(`[slack-chat] ${clicker} could not delete message ${messageId} in ${channel}:`, error);
+ await context.slack.postEphemeral(threadId, clicker, "ShipBash couldn't delete that message.").catch((noted: unknown) => {
+ console.error(`[slack-chat] the note to ${clicker} about message ${messageId} did not land:`, noted);
+ });
+ return;
+ }
+ }
+ for (const file of sharedFiles(event.raw)) {
+ await context.slack.webClient.files.delete({ file }).catch((error: unknown) => {
+ console.error(`[slack-chat] recording ${file} of deleted message ${messageId} could not be deleted:`, error);
+ });
+ }
+ await forgetDeletedMessage(context, threadId, messageId);
+}
diff --git a/packages/control-plane/src/slack-delete-row.ts b/packages/control-plane/src/slack-delete-row.ts
new file mode 100644
index 0000000..1b0e58a
--- /dev/null
+++ b/packages/control-plane/src/slack-delete-row.ts
@@ -0,0 +1,67 @@
+/**
+ * The row at the foot of every message ShipBash posts in Slack: Delete, for
+ * anyone who can see the message, once Slack has asked them to confirm.
+ *
+ * It is Slack's `context_actions` block, the one place a message carries
+ * actions of its own under its content, and its trash icon is the only icon
+ * Slack offers there. Slack does nothing with the press but tell the app
+ * (`DELETE_MESSAGE_ACTION`, slack-chat/message-deletion.ts); a message posted
+ * outside the channel — an ephemeral note only its reader sees — carries no
+ * row, since it leaves on its own.
+ */
+
+/** The action the row's button sends, whichever message it sits under. */
+export const DELETE_MESSAGE_ACTION = "shipbash:delete-message";
+
+interface PlainText {
+ type: "plain_text";
+ text: string;
+}
+
+export interface DeleteRow {
+ type: "context_actions";
+ elements: [{
+ type: "icon_button";
+ icon: "trash";
+ text: PlainText;
+ action_id: string;
+ accessibility_label: string;
+ confirm: { title: PlainText; text: PlainText; confirm: PlainText; deny: PlainText; style: "danger" };
+ }];
+}
+
+function plain(text: string): PlainText {
+ return { type: "plain_text", text };
+}
+
+/** The row itself; it names no block id, so Slack gives each message's row a fresh one. */
+function deleteRow(): DeleteRow {
+ return {
+ type: "context_actions",
+ elements: [{
+ type: "icon_button",
+ icon: "trash",
+ text: plain("Delete"),
+ action_id: DELETE_MESSAGE_ACTION,
+ accessibility_label: "Delete this message from ShipBash",
+ confirm: {
+ title: plain("Delete this message?"),
+ text: plain("It is removed for everyone in this conversation."),
+ confirm: plain("Delete"),
+ deny: plain("Cancel"),
+ style: "danger",
+ },
+ }],
+ };
+}
+
+/**
+ * A message's blocks with the row at their foot. A row they already carry —
+ * the one Slack sends back with a message ShipBash draws again — is dropped
+ * wherever it stood, so the row stays last under whatever the new drawing
+ * adds, and goes out without the block id Slack gave it: Slack wants a new
+ * one on every iteration of a message.
+ */
+export function withDeleteRow(blocks: readonly Block[]): (Block | DeleteRow)[] {
+ return [...blocks.filter((block) => block.type !== "context_actions"), deleteRow()];
+}
diff --git a/packages/control-plane/src/store/internal-lanes-slack.ts b/packages/control-plane/src/store/internal-lanes-slack.ts
index f7fdd71..d1c5d96 100644
--- a/packages/control-plane/src/store/internal-lanes-slack.ts
+++ b/packages/control-plane/src/store/internal-lanes-slack.ts
@@ -51,6 +51,7 @@ import {
type ThreadWorkPlace,
type UnannouncedWork,
announceThreadWork,
+ forgetThreadReply,
selectThreadList,
selectThreadWorkPlace,
selectUnannouncedThreadWork,
@@ -63,6 +64,7 @@ import {
selectSlackThreadAtWork,
} from "./slack-thread-work.ts";
import { type PostSubject, type StopInput, type StoppedWork, selectStopped, stopThreadWork } from "./slack-thread-stops.ts";
+import { closeSlackAlertThreadsUnder } from "./slack-alert-threads.ts";
import { type SuiteScheduleTiming, selectSuiteScheduleTiming } from "./suite-schedules.ts";
import { type WalkReplay, type WalkReport, selectWalkReplay, selectWalkReport } from "./walks.ts";
import {
@@ -191,6 +193,19 @@ export class InternalSlackStore extends InternalSlackAlertsStore {
return job ? { suiteRunId: job.suite_run_id, activityId: job.activity_id } : null;
}
+ /**
+ * What the store kept about a message of ShipBash's that is gone from Slack,
+ * at its ts in its channel: an alert thread it opened closes, and a to-do
+ * list it carried is let go. Saying it again changes nothing.
+ */
+ async forgetSlackMessageInternal(input: { slackTeamId: string; channelId: string; ts: string }): Promise {
+ const { slackTeamId, channelId, ts } = input;
+ await this.sql.begin(async (tx) => {
+ await closeSlackAlertThreadsUnder(tx, { slackTeamId, channelId, threadTs: ts });
+ await forgetThreadReply(tx, { slackTeamId, channelId, replyTs: ts });
+ });
+ }
+
/** The to-do list under the reply, as its work stands; null for a reply that carries none. */
async readThreadListInternal(input: { teamId: string; threadId: string; replyTs: string }): Promise {
return selectThreadList(this.sql, input);
diff --git a/packages/control-plane/src/store/slack-alert-threads.ts b/packages/control-plane/src/store/slack-alert-threads.ts
index 2c70a43..f49e23e 100644
--- a/packages/control-plane/src/store/slack-alert-threads.ts
+++ b/packages/control-plane/src/store/slack-alert-threads.ts
@@ -136,6 +136,30 @@ export async function closeSlackAlertThreads(
`;
}
+/**
+ * Someone deleted the message a thread hangs from (slack-chat/
+ * message-deletion.ts). Whatever it held open stops collecting replies there,
+ * so a subject still red opens a thread of its own on its next result rather
+ * than answering under a message that is gone. Bound to the teams that
+ * connected the workspace the deletion came from.
+ */
+export async function closeSlackAlertThreadsUnder(
+ sql: Queryable,
+ input: { slackTeamId: string; channelId: string; threadTs: string },
+): Promise {
+ await sql`
+ update public.slack_alert_threads thread
+ set closed_at = now()
+ from public.slack_workspaces workspace
+ where workspace.slack_team_id = ${nonEmpty(input.slackTeamId, "slackTeamId")}
+ and workspace.revoked_at is null
+ and thread.team_id = workspace.team_id
+ and thread.channel_id = ${nonEmpty(input.channelId, "channelId")}
+ and thread.thread_ts = ${nonEmpty(input.threadTs, "threadTs")}
+ and thread.closed_at is null
+ `;
+}
+
/**
* The project stopped alerting into this channel. Its open threads there
* describe a conversation nothing will be posted to again, so they close; its
diff --git a/packages/control-plane/src/store/slack-thread-lists.ts b/packages/control-plane/src/store/slack-thread-lists.ts
index e824ffd..ae60fd5 100644
--- a/packages/control-plane/src/store/slack-thread-lists.ts
+++ b/packages/control-plane/src/store/slack-thread-lists.ts
@@ -158,6 +158,30 @@ export async function selectThreadList(
return { blocks: Array.isArray(reply.blocks) ? reply.blocks : [], items };
}
+/**
+ * Someone deleted a reply that carried a to-do list (slack-chat/
+ * message-deletion.ts). Its kept blocks go, so nothing draws under a message
+ * that is gone, and its work rides in no list: what is still in flight is
+ * announced again by the thread's next reply, with its Stop. Found by its ts
+ * in its channel, where a ts names one message.
+ */
+export async function forgetThreadReply(
+ sql: postgres.TransactionSql,
+ input: { slackTeamId: string; channelId: string; replyTs: string },
+): Promise {
+ const replies = await sql>`
+ select reply.slack_thread_id from public.slack_thread_replies reply
+ join public.slack_threads thread on thread.id = reply.slack_thread_id
+ where thread.slack_team_id = ${nonEmpty(input.slackTeamId, "slackTeamId")}
+ and thread.channel_id = ${nonEmpty(input.channelId, "channelId")}
+ and reply.reply_ts = ${nonEmpty(input.replyTs, "replyTs")}
+ `;
+ for (const { slack_thread_id: threadId } of replies) {
+ await sql`update public.slack_thread_work set reply_ts = null where slack_thread_id = ${threadId}::uuid and reply_ts = ${input.replyTs}`;
+ await sql`delete from public.slack_thread_replies where slack_thread_id = ${threadId}::uuid and reply_ts = ${input.replyTs}`;
+ }
+}
+
/** The thread's work in flight that no reply has carried yet, oldest first. */
export async function selectUnannouncedThreadWork(
sql: Queryable,