mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
364a46c2fd | ||
|
|
ab083aee7e | ||
|
|
303ed582a0 | ||
|
|
9a57abbc9a |
No files matched your search
@@ -224,7 +224,8 @@ export class InboundRouter {
|
||||
this.botLimiter = new BotLimiter(deps.now);
|
||||
}
|
||||
|
||||
private summaryOf(ev: SlackEvent, transfer?: InboundFileResult | null, correlationQitemId?: string): { summary: string; body: string } {
|
||||
/** The row's summary names the person who actually wrote (their registered address); its tags stay as they were. */
|
||||
private summaryOf(ev: SlackEvent, sender: string, transfer?: InboundFileResult | null, correlationQitemId?: string): { summary: string; body: string } {
|
||||
const text = String(ev.text ?? "");
|
||||
const meta = `slack channel=${ev.channel} user=${ev.user} ts=${ev.ts}`;
|
||||
// OPR.0.5.6.2 — attachments ride the row BODY by LOCAL path (Slack owns
|
||||
@@ -253,7 +254,7 @@ export class InboundRouter {
|
||||
const firstFileName = transfer?.stored[0]?.name ?? transfer?.failed[0]?.name;
|
||||
const headline = text.trim() ? text : firstFileName ? `[file] ${firstFileName}` : text;
|
||||
return {
|
||||
summary: `${ev.recoveredAfterGap ? "[Recovered after gap] " : ""}Founder via Slack: ${headline.slice(0, 90)}`,
|
||||
summary: `${ev.recoveredAfterGap ? "[Recovered after gap] " : ""}${sender} via Slack: ${headline.slice(0, 90)}`,
|
||||
body: `${sections.filter((s) => s.length > 0).join("\n\n")}\n\n---\nSource: ${meta}${correlationQitemId ? `\nIn reply to: ${correlationQitemId}` : ""}\nRouted by openrig slack-inbound. Default destination per config; re-route via queue as needed.`,
|
||||
};
|
||||
}
|
||||
@@ -326,7 +327,7 @@ export class InboundRouter {
|
||||
if (!isCurrent()) return { landed: false, reason: "inactive" };
|
||||
// S10 — deterministic route (thread map) when wired; static destination otherwise.
|
||||
const route = this.deps.resolveRoute?.(ev) ?? { destination: this.deps.destination };
|
||||
const { summary, body } = this.summaryOf(ev, transfer, route.correlationQitemId);
|
||||
const { summary, body } = this.summaryOf(ev, who.source, transfer, route.correlationQitemId);
|
||||
// A person's top-level message (not a reply in a thread) becomes the root its answer lands under.
|
||||
const mintsRoot = Boolean(this.deps.mintThread && ev.channel && (!ev.thread_ts || ev.thread_ts === ev.ts));
|
||||
const correlated = route.correlationQitemId ? this.deps.queue.getQitem?.(route.correlationQitemId) : null;
|
||||
@@ -540,7 +541,7 @@ export class InboundRouter {
|
||||
tags: [...route.tags ?? ["founder-slack", "inbound"], "human-answer"],
|
||||
inReplyTo: qitemId,
|
||||
humanAnswer: true,
|
||||
summary: `Founder via Slack: answered ${lines.length === 1 ? "1 question" : `${lines.length} questions`}`,
|
||||
summary: `${who.source} via Slack: answered ${lines.length === 1 ? "1 question" : `${lines.length} questions`}`,
|
||||
body: `${lines.join("\n")}\n\n---\nAnswers (question id → option id): ${JSON.stringify(recorded.answers)}\nIn reply to: ${qitemId} (its humanAnswers field holds the same)\nRouted by openrig slack-inbound (button click).`,
|
||||
});
|
||||
resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: lines.join("; ") });
|
||||
@@ -611,7 +612,7 @@ export class InboundRouter {
|
||||
priority: "routine",
|
||||
tags: [...route.tags ?? ["founder-slack", "inbound"], "human-reaction"],
|
||||
inReplyTo: ask,
|
||||
summary: `Founder via Slack: reacted :${ev.reaction}: to ${ask}`,
|
||||
summary: `${who.source} via Slack: reacted :${ev.reaction}: to ${ask}`,
|
||||
body: [
|
||||
`${who.source} reacted :${ev.reaction}: to your ask ${ask} in Slack.`,
|
||||
"",
|
||||
|
||||
@@ -97,6 +97,9 @@ export function makeHumanReplyResolver(
|
||||
const source = queueRepo.getById(input.qitemId);
|
||||
if (source?.humanIntent === "update") return "not-applicable";
|
||||
if (!contract) return "not-applicable";
|
||||
// A different person's reply to a question parked on someone else already reached the agent as their own message
|
||||
// (the inbound row, linked to the question). It doesn't resume the question, which stays parked on its person.
|
||||
if (source?.state === "blocked" && queueRepo.askedSomeoneElse(input.actorSession, source.blockedOn)) return "not-applicable";
|
||||
const uncertainDecision = `${input.decision}\n\n---\nOpenRig timing context (Slack reply ${JSON.stringify([input.actorSession, input.messageTs ?? null])}): This reply resumed the question, but its ordering against the posted question could not be confirmed. Resuming is not approval; read the person's words in context.`;
|
||||
// An acknowledgement retry must not resolve again. Match this reply's durable
|
||||
// context, not an older episode's generic resolved marker.
|
||||
|
||||
@@ -1960,6 +1960,16 @@ export class QueueRepository {
|
||||
return registry.ok && resolveRegisteredHumanAddress(destination, registry.entities) !== null;
|
||||
}
|
||||
|
||||
/** The asked person, when a different registered person is acting on what was asked of them; else null. An agent, the
|
||||
* asked person under another spelling, and an ask to no registered person aren't someone else. */
|
||||
askedSomeoneElse(actorSession: string, askedSession: string | null | undefined): string | null {
|
||||
const registry = this.loadHumanRegistryFn();
|
||||
if (!registry.ok) return null;
|
||||
const asked = resolveRegisteredHumanAddress(askedSession, registry.entities);
|
||||
const actor = resolveRegisteredHumanAddress(actorSession, registry.entities);
|
||||
return asked && actor && actor !== asked ? asked : null;
|
||||
}
|
||||
|
||||
private validateReplyTo(replyTo: string, humanIntent: QueueCreateInput["humanIntent"]): void {
|
||||
if (humanIntent !== "update") {
|
||||
throw new QueueRepositoryError("reply_to_requires_update", "replyTo is accepted only with humanIntent update; a decision keeps its own thread so its reply stays unambiguous.");
|
||||
|
||||
@@ -29,7 +29,7 @@ it("keeps complete short and long human briefs durable while bounding summaries
|
||||
expect(result.disposition).toBe("accepted");
|
||||
const row = repo.getById(result.qitemId!)!;
|
||||
expect(row.body).toBe(`${text}\n\n---\nSource: slack channel=C-FIXTURE user=U-FIXTURE ts=${event.ts}\nRouted by openrig slack-inbound. Default destination per config; re-route via queue as needed.`);
|
||||
expect(row.summary).toBe(`Founder via Slack: ${text.slice(0, 90)}`);
|
||||
expect(row.summary).toBe(`human-fixture@external via Slack: ${text.slice(0, 90)}`);
|
||||
expect(seen.load().has(`${event.channel}:${event.ts}`)).toBe(true);
|
||||
expect((await router.route(event)).disposition).toBe("ignored");
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ function harness(opts: { admitted?: boolean; failCreates?: number; newerRoot?: s
|
||||
seen: new SeenStore("/s.jsonl", fs, clock),
|
||||
deadLetter,
|
||||
destination: "operator-agent@kernel",
|
||||
resolveSender: () => opts.admitted === false ? { admitted: false, teaching: "not registered" } : { admitted: true, source: "founder@humans" },
|
||||
resolveSender: () => opts.admitted === false ? { admitted: false, teaching: "not registered" } : { admitted: true, source: "ana@humans" },
|
||||
resolveRoute: makeThreadRouteResolver({ map: threadMap, unroutedDestination: "orch-lead@team", log: (m: string) => logs.push(m) }),
|
||||
resolveHumanReply: async (input: { qitemId: string }) => { resolved.push(input.qitemId); return "resolved"; },
|
||||
log: (m: string) => logs.push(m),
|
||||
@@ -79,10 +79,10 @@ describe("#899 reactions on an ask", () => {
|
||||
expect(await deliver(h, reaction())).toEqual({ status: "accepted", reason: "reaction" });
|
||||
expect(h.rows).toHaveLength(1);
|
||||
const row = h.rows[0]!;
|
||||
expect(row).toMatchObject({ source: "founder@humans", destination: "asker@rig" });
|
||||
expect(row).toMatchObject({ source: "ana@humans", destination: "asker@rig" });
|
||||
expect(row.tags).toContain("human-reaction");
|
||||
expect(row.summary).toBe("Founder via Slack: reacted :white_check_mark: to qitem-ask-1");
|
||||
expect(row.body).toContain("founder@humans reacted :white_check_mark: to your ask qitem-ask-1");
|
||||
expect(row.summary).toBe("ana@humans via Slack: reacted :white_check_mark: to qitem-ask-1");
|
||||
expect(row.body).toContain("ana@humans reacted :white_check_mark: to your ask qitem-ask-1");
|
||||
expect(row.body).toContain("doesn't answer or close the ask");
|
||||
// The seat decides what the reaction means; nothing resolves the ask for it.
|
||||
expect(h.resolved).toEqual([]);
|
||||
@@ -94,29 +94,29 @@ describe("#899 reactions on an ask", () => {
|
||||
expect(await deliver(h, reaction())).toEqual({ status: "ignored", reason: "dup" });
|
||||
await deliver(h, reaction({ reaction: "eyes", event_ts: "402.0" }));
|
||||
expect(h.rows.map((row) => row.summary)).toEqual([
|
||||
"Founder via Slack: reacted :white_check_mark: to qitem-ask-1",
|
||||
"Founder via Slack: reacted :eyes: to qitem-ask-1",
|
||||
"ana@humans via Slack: reacted :white_check_mark: to qitem-ask-1",
|
||||
"ana@humans via Slack: reacted :eyes: to qitem-ask-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("on an older root of the same ask still reach its seat", async () => {
|
||||
const h = harness({ newerRoot: "450.0" });
|
||||
expect(await deliver(h, reaction())).toEqual({ status: "accepted", reason: "reaction" });
|
||||
expect(h.rows[0]).toMatchObject({ destination: "asker@rig", summary: "Founder via Slack: reacted :white_check_mark: to qitem-ask-1" });
|
||||
expect(h.rows[0]).toMatchObject({ destination: "asker@rig", summary: "ana@humans via Slack: reacted :white_check_mark: to qitem-ask-1" });
|
||||
});
|
||||
|
||||
it("on a reply part OpenRig posted for a long ask reach the seat that asked, as on the ask itself", async () => {
|
||||
const h = harness({ newerRoot: "400.0" });
|
||||
expect(await deliver(h, reaction({ item: { type: "message", channel: "C1", ts: "400.5" } }))).toEqual({ status: "accepted", reason: "reaction" });
|
||||
expect(h.rows).toHaveLength(1);
|
||||
expect(h.rows[0]).toMatchObject({ destination: "asker@rig", summary: "Founder via Slack: reacted :white_check_mark: to qitem-ask-1" });
|
||||
expect(h.rows[0]).toMatchObject({ destination: "asker@rig", summary: "ana@humans via Slack: reacted :white_check_mark: to qitem-ask-1" });
|
||||
});
|
||||
|
||||
it("on an ask posted into another ask's thread reach that ask's own seat, not the thread owner's", async () => {
|
||||
const h = harness();
|
||||
expect(await deliver(h, reaction({ item: { type: "message", channel: "C1", ts: "400.7" } }))).toEqual({ status: "accepted", reason: "reaction" });
|
||||
expect(h.rows).toHaveLength(1);
|
||||
expect(h.rows[0]).toMatchObject({ destination: "other@rig", summary: "Founder via Slack: reacted :white_check_mark: to qitem-ask-2" });
|
||||
expect(h.rows[0]).toMatchObject({ destination: "other@rig", summary: "ana@humans via Slack: reacted :white_check_mark: to qitem-ask-2" });
|
||||
});
|
||||
|
||||
it("are ignored on any message that isn't an ask, with no row for anyone", async () => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
|
||||
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
|
||||
import { makeHumanReplyResolver } from "../src/domain/gateway/slack/slack-subsystem.js";
|
||||
import type { HumanFragment, LoadResult } from "../src/domain/gateway/human-registry.js";
|
||||
|
||||
// A reply that comes through the gateway keeps its sender's identity: a different registered person's Slack reply to a
|
||||
// question parked on someone else reaches the agent as their own message and doesn't resume the question. An action
|
||||
// at the terminal is the primary user's and keeps its existing authority.
|
||||
|
||||
const person = (entityId: string): HumanFragment => ({
|
||||
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
|
||||
connectorBindings: [{ kind: "slack", connectorRef: "main", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }],
|
||||
prefs: { deliveryClass: "B", availability: "available" },
|
||||
});
|
||||
const ANA = "human-ana@external";
|
||||
const MIKE = "human-mike@external";
|
||||
|
||||
describe("a Slack reply from someone other than the person a question is parked on", () => {
|
||||
let db: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
let contract: MissionControlWriteContract;
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const registry: LoadResult = { ok: true, entities: [person("human-ana"), person("human-mike")] };
|
||||
const bus = new EventBus(db);
|
||||
repo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => registry });
|
||||
contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
/** A question parked on Ana and posted to her in Slack: the human-required episode with its posted receipt, as a real
|
||||
* post records it, so a reply after the post is one the resolver would act on. */
|
||||
const parkOnAna = async () => {
|
||||
const row = await repo.create({ sourceSession: "orch-lead@r", destinationSession: "dev@r", body: "work", nudge: false });
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "dev@r", state: "blocked", blockedOn: "human-ana@host", summary: "Parked",
|
||||
evidenceRef: "/proof/parked.md", transitionNote: "parked on Ana" });
|
||||
const episode = repo.listTransitions(row.qitemId).filter((t) => t.ownerNotificationKind === "human-required").at(-1)!;
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "openrig-slack",
|
||||
transitionNote: `slack-owner-notification-posted notification_key=${row.qitemId}:${episode.transitionId} kind=human-required thread_ts=1.1 message_ts=1.5` });
|
||||
return row;
|
||||
};
|
||||
const slackReply = (qitemId: string, actorSession: string) =>
|
||||
makeHumanReplyResolver(repo, contract)({ qitemId, actorSession, decision: "go ahead", messageTs: "2.2" });
|
||||
|
||||
it("doesn't resume the question: it stays parked on its person and nothing is written to it", async () => {
|
||||
const parked = await parkOnAna();
|
||||
const before = repo.listTransitions(parked.qitemId).length;
|
||||
expect(await slackReply(parked.qitemId, MIKE)).toBe("not-applicable");
|
||||
expect(repo.getById(parked.qitemId)).toMatchObject({ state: "blocked", blockedOn: "human-ana@host" });
|
||||
expect(repo.listTransitions(parked.qitemId)).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("the person it's parked on still resumes it from Slack", async () => {
|
||||
const parked = await parkOnAna();
|
||||
expect(await slackReply(parked.qitemId, ANA)).toBe("resolved");
|
||||
expect(repo.getById(parked.qitemId)?.state).toBe("in-progress");
|
||||
expect(repo.listTransitions(parked.qitemId).filter((t) => t.ownerNotificationKind === "human-decision-resolved").map((t) => t.actorSession))
|
||||
.toEqual([ANA]);
|
||||
});
|
||||
|
||||
it("an action at the terminal keeps its existing authority over the same question", async () => {
|
||||
const parked = await parkOnAna();
|
||||
const result = await contract.act({ verb: "resolve", qitemId: parked.qitemId, actorSession: MIKE, decision: "go ahead", notify: false });
|
||||
expect(result).not.toHaveProperty("correspondence");
|
||||
expect(repo.getById(parked.qitemId)?.state).toBe("in-progress");
|
||||
expect(repo.listTransitions(parked.qitemId).filter((t) => t.ownerNotificationKind === "human-decision-resolved").map((t) => t.actorSession))
|
||||
.toEqual([MIKE]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user