mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea349ce66b |
No files matched your search
@@ -43,6 +43,7 @@ import { createHash } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { EventBus } from "../event-bus.js";
|
||||
import { isTypedGateBlocker, mcOperationTag, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js";
|
||||
import { askBackRoute, terminalAnswerMeaning } from "../receiver-context.js";
|
||||
import type { PersistedEvent } from "../types.js";
|
||||
import {
|
||||
MissionControlActionLog,
|
||||
@@ -695,7 +696,7 @@ export class MissionControlWriteContract {
|
||||
try {
|
||||
returned = await this.queueRepo.createHumanAnswer({
|
||||
sourceSession: input.actorSession, destinationSession: ask.sourceSession,
|
||||
body: `${reply}\n${answeredBy} in OpenRig.`,
|
||||
body: `${reply}\n${answeredBy} in OpenRig.${unattributed ? `\n${terminalAnswerMeaning(UNNAMED_PRIMARY_USER)} ${askBackRoute(this.queueRepo.reachablePerson(forAsked), UNNAMED_PRIMARY_USER, input.qitemId)}` : ""}`,
|
||||
summary: `${forAsked && unattributed ? `Unattributed (${UNNAMED_PRIMARY_USER})` : input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`,
|
||||
tags: [
|
||||
"human-answer",
|
||||
|
||||
@@ -103,6 +103,7 @@ import { WatchdogJobsRepository } from "./watchdog-jobs-repository.js";
|
||||
import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, retargetQueueWait, isQueueWait } from "./queue-wait-backoff.js";
|
||||
import { parseHumanQuestions, unansweredQuestions, describeTypedReplyPlacement, formatHumanAnswers, humanAnswerText, type HumanQuestion, type HumanAnswers, type TypedHumanReply, type RecordHumanAnswerResult } from "./human-questions.js";
|
||||
import { slackMicros } from "./gateway/slack/state-store.js";
|
||||
import { askBackRoute, RUNNER_CLOSE_WAITS, terminalAnswerMeaning } from "./receiver-context.js";
|
||||
|
||||
/** A direct ask's answer row ID: from the ask, or from the ask and the park episode its answer belongs to. */
|
||||
function humanAnswerId(askId: string, episodeKey?: string): string {
|
||||
@@ -2504,6 +2505,14 @@ export class QueueRepository {
|
||||
return registry.ok && resolveRegisteredHumanAddress(destination, registry.entities) !== null;
|
||||
}
|
||||
|
||||
/** The registered person an address names, so an ask back reaches them; null for anyone else, the unattributed
|
||||
* terminal actor included (it's a person address, but nothing is delivered there). */
|
||||
reachablePerson(address: string | null | undefined): string | null {
|
||||
if (!address || address === UNATTRIBUTED_ACTOR) return null;
|
||||
const registry = this.loadHumanRegistryFn();
|
||||
return registry.ok ? resolveRegisteredHumanAddress(address, registry.entities) : null;
|
||||
}
|
||||
|
||||
/** Who may answer an ask: the asked person, and any other registered person in this workspace, who's recorded as
|
||||
* themselves with the asked person kept (one trusted workspace, equal authority). A shared channel never answers. */
|
||||
answersAsk(item: QueueItem, actorSession: string): boolean {
|
||||
@@ -4245,6 +4254,9 @@ export class QueueRepository {
|
||||
: answer && typeof answer === "object" && "text" in answer ? `wrote "${String(answer.text).trim()}"`
|
||||
: "answered without a choice or words";
|
||||
const person = input.actorSession;
|
||||
// Who to ask back: the person who answered when OpenRig can name them, else the person the request went to.
|
||||
const unattributed = person === UNATTRIBUTED_ACTOR;
|
||||
const askBack = (unattributed ? null : this.reachablePerson(person)) ?? this.reachablePerson(item.destinationSession);
|
||||
// A handoff that fails after persisting its events rolls them back with its savepoint: discardOnThrow takes their
|
||||
// tokens out of the caller's notify envelope too, so the kept answer below still commits.
|
||||
try { this.eventBus.discardOnThrow(() => this.handoffNow({
|
||||
@@ -4260,8 +4272,12 @@ export class QueueRepository {
|
||||
"",
|
||||
`Their whole answer is recorded on the request: rig queue show ${answered.qitemId} --full.`,
|
||||
`Decide with: rig seat decide-permission ${item.qitemId} --option <id> --reason "<why>"`,
|
||||
"Act on a clear answer at once: a clear yes or an allow click is the allow option, a clear no or a reject click the reject option. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming this request, then decide.",
|
||||
`- **Who's who:** ${root.sourceSession} is the agent asking to run the tool; ${item.destinationSession} is the person it asked; ${person} answered.`,
|
||||
...(unattributed ? [terminalAnswerMeaning(UNATTRIBUTED_ACTOR)] : []),
|
||||
"Act on a clear answer at once: a clear yes or an allow click is the allow option, a clear no or a reject click the reject option. Don't ask them to confirm a clear answer.",
|
||||
`${askBackRoute(askBack, UNATTRIBUTED_ACTOR, `request ${item.qitemId}`)} Then decide on what they answer.`,
|
||||
"Until it's decided (by you, or by anyone with that command), the runner keeps waiting, with no timeout that refuses it; if its turn ends first, nothing is sent.",
|
||||
RUNNER_CLOSE_WAITS,
|
||||
].join("\n"),
|
||||
})); } catch (error) {
|
||||
// Kept, and committed with the answer's own write (a throw here would roll it back): the request stays open, and
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// What a receiving agent needs to act on a person's answer, in one place: what an answer typed at this machine means,
|
||||
// how to ask the person back, and what closing a runner's request does. Text only: who is recorded as answering, and
|
||||
// every state and policy, stay as they are.
|
||||
|
||||
/** An answer recorded as `unattributed` (typed at this machine's terminal or TUI): what it means for acting on it. */
|
||||
export function terminalAnswerMeaning(unattributed: string): string {
|
||||
return `${unattributed} is how OpenRig records any answer typed at this machine's terminal or TUI: it can't tell who typed, so it names nobody. `
|
||||
+ "It's the answer of whoever is at this machine, usually the person the request went to. Act on a clear answer with the request's own decision command, and don't ask them to confirm it.";
|
||||
}
|
||||
|
||||
/** How to ask the person back when their answer is unclear or conditional: a new ask to the person the request went to,
|
||||
* which reaches them where they answer. `asked` is null when that person isn't registered (any more): what to do then is
|
||||
* the one open case, so it's this one sentence. */
|
||||
export function askBackRoute(asked: string | null, unattributed: string, request: string): string {
|
||||
const nowhere = `Don't write to ${unattributed}: nothing is delivered there.`;
|
||||
if (asked) {
|
||||
return `If their answer is unclear or conditional, ask them back with an ask to ${asked} that names ${request}: `
|
||||
+ `rig queue create --destination ${asked} --summary "<your question>" --body-file <file>. It reaches them where they answer (the Feed, Steering, and Slack when it's connected). ${nowhere}`;
|
||||
}
|
||||
return `If their answer is unclear or conditional, OpenRig can't ask them back: the person ${request} went to isn't registered. `
|
||||
+ `Say so once, in this conversation, and keep the request and their answer waiting. The ways through are registering a person (rig gateway human add) or the request's own decision command. ${nowhere}`;
|
||||
}
|
||||
|
||||
/** What closing a runner's permission request without a decision does: a kept answer waits (runner-permissions). */
|
||||
export const RUNNER_CLOSE_WAITS = "Closing this item without deciding permits nothing: the person's answer stays kept and the runner keeps waiting until it's decided. "
|
||||
+ "OpenRig's default permit applies only to a request nobody answered.";
|
||||
@@ -56,6 +56,7 @@ import { addHostEntry, updateHostEntry, removeHostEntry } from "../domain/hosts/
|
||||
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry } from "../domain/hosts/hosts-registry-reader.js";
|
||||
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
|
||||
import { UNATTRIBUTED_ACTOR, type QueueItem, type QueueRepository } from "../domain/queue-repository.js";
|
||||
import { askBackRoute } from "../domain/receiver-context.js";
|
||||
import { KERNEL_OPERATOR_SESSION, resolveDefaultReceiver } from "../domain/default-receiver.js";
|
||||
import type { HumanQuestion } from "../domain/human-questions.js";
|
||||
import { PAIR_SOURCE_SESSION } from "../domain/hosts/pairing-ask.js";
|
||||
@@ -268,7 +269,7 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
...(owner ? [
|
||||
`Answer with Approve or Deny, or in your own words. ${owner} reads your answer and acts on it: a clear yes pairs, a clear no denies, and if your answer isn't clear it asks you back.${rec.ownerNote ? ` (${rec.ownerNote}, so ${owner} reads it instead.)` : ""}`,
|
||||
...((running) => running === true ? [] : [`${running === false ? `${owner} isn't running right now, so your answer waits` : `OpenRig couldn't tell whether ${owner} is running, so your answer may wait`} until it starts; nothing is granted or denied meanwhile. To decide sooner on this host: ${directDecision(code)}.`])(ownerRunning(c, owner)),
|
||||
`For ${owner}: when the person's answer to this request comes back, act on it. A clear yes or an Approve click: rig host approve ${code}. A clear no or a Deny click: rig host deny ${code}. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming pairing code ${code}, and act on what they answer.`,
|
||||
`For ${owner}: when the person's answer to this request comes back, act on it. ${rec.person} is the person asked, on this host; ${requester} is the other machine, asking to pair. A clear yes or an Approve click: rig host approve ${code}. A clear no or a Deny click: rig host deny ${code}. Don't ask them to confirm a clear answer. ${askBackRoute(rec.person, UNATTRIBUTED_ACTOR, `pairing code ${code}`)} Then act on what they answer.`,
|
||||
] : [
|
||||
`No agent reads answers here yet: ${noReader(rec)}. Your answer, a click or your words, is kept, and goes to the receiving agent once one is set; nothing is granted or denied meanwhile.`,
|
||||
`To decide now on this host: ${directDecision(code)}.`,
|
||||
|
||||
@@ -259,7 +259,10 @@ describe("host pairing with the kernel operator running: the person's answer goe
|
||||
const item = f.repo.getById(f.ask)!;
|
||||
expect(item).toMatchObject({ sourceSession: OPERATOR, destinationSession: PERSON, tier: "human-gate" });
|
||||
expect(item.body).toContain(`Answer with Approve or Deny, or in your own words. ${OPERATOR} reads your answer and acts on it`);
|
||||
expect(item.body).toContain(`For ${OPERATOR}: when the person's answer to this request comes back, act on it. A clear yes or an Approve click: rig host approve ${f.issued.code}. A clear no or a Deny click: rig host deny ${f.issued.code}.`);
|
||||
expect(item.body).toContain(`For ${OPERATOR}: when the person's answer to this request comes back, act on it. ${PERSON} is the person asked, on this host; requester@far-host is the other machine, asking to pair. A clear yes or an Approve click: rig host approve ${f.issued.code}. A clear no or a Deny click: rig host deny ${f.issued.code}.`);
|
||||
// An unclear answer is asked back through the person asked, never human@host.
|
||||
expect(item.body).toContain(`ask them back with an ask to ${PERSON} that names pairing code ${f.issued.code}: rig queue create --destination ${PERSON}`);
|
||||
expect(item.body).toContain("Don't write to human@host: nothing is delivered there. Then act on what they answer.");
|
||||
});
|
||||
|
||||
it.each(["yes", "no", "yes, but which machine?"])("a typed %j (Steering) reaches the operator as a reply with the person's words, and decides nothing by itself", async (words) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
|
||||
import { humanAnswerText } from "../src/domain/human-questions.js";
|
||||
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
|
||||
import { missionControlRoutes } from "../src/routes/mission-control.js";
|
||||
import { addHumanFragment } from "../src/domain/gateway/human-registry.js";
|
||||
@@ -77,6 +78,12 @@ describe("the primary user acts on anyone's ask, unattributed, on that person's
|
||||
const reply = repo.list({ limit: 50 }).find((item) => item.replyTo === sarahs.qitemId)!;
|
||||
expect(reply).toMatchObject({ sourceSession: "human@host", destinationSession: ASKER, summary: "Unattributed (human@host) answered sarah@external's ask: Ship the draft?" });
|
||||
expect(reply.body).toContain("Answered at the terminal by someone OpenRig can't name (human@host), unattributed, on sarah@external's ask in OpenRig.");
|
||||
// The receiver reads what that means for acting, and the route to ask the person back: never human@host.
|
||||
expect(reply.body).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
|
||||
expect(reply.body).toContain("don't ask them to confirm it");
|
||||
expect(reply.body).toContain(`ask them back with an ask to sarah@external that names ${sarahs.qitemId}: rig queue create --destination sarah@external`);
|
||||
expect(reply.body).toContain("Don't write to human@host: nothing is delivered there.");
|
||||
expect(humanAnswerText(reply.body)).toBe("Ship it");
|
||||
});
|
||||
|
||||
it("records a choice on a question ask the same way", async () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { askBackRoute, RUNNER_CLOSE_WAITS, terminalAnswerMeaning } from "../src/domain/receiver-context.js";
|
||||
|
||||
describe("what a receiving agent reads to act on a person's answer", () => {
|
||||
it("names the person to ask back, and never the unattributed terminal actor", () => {
|
||||
const route = askBackRoute("sarah@external", "human@host", "request q-1");
|
||||
expect(route).toContain("ask them back with an ask to sarah@external that names request q-1: rig queue create --destination sarah@external");
|
||||
expect(route).toContain("Don't write to human@host: nothing is delivered there.");
|
||||
});
|
||||
|
||||
it("with no registered person to ask back: says so once, keeps the request and the answer waiting, and names the ways through", () => {
|
||||
const route = askBackRoute(null, "human@host", "request q-1");
|
||||
expect(route).toContain("OpenRig can't ask them back: the person request q-1 went to isn't registered.");
|
||||
expect(route).toContain("Say so once, in this conversation, and keep the request and their answer waiting.");
|
||||
expect(route).toContain("registering a person (rig gateway human add) or the request's own decision command");
|
||||
expect(route).not.toContain("rig queue create");
|
||||
});
|
||||
|
||||
it("explains an unattributed terminal answer as the answer of whoever is at this machine, to act on without a confirmation", () => {
|
||||
expect(terminalAnswerMeaning("human@host")).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
|
||||
expect(terminalAnswerMeaning("human@host")).toContain("don't ask them to confirm it");
|
||||
});
|
||||
|
||||
it("says a close without a decision permits nothing, and keeps the default permit to a request nobody answered", () => {
|
||||
expect(RUNNER_CLOSE_WAITS).toContain("Closing this item without deciding permits nothing");
|
||||
expect(RUNNER_CLOSE_WAITS).toContain("OpenRig's default permit applies only to a request nobody answered.");
|
||||
});
|
||||
});
|
||||
@@ -642,6 +642,10 @@ describe("an answered request goes to the receiving agent, which decides while t
|
||||
expect(handed).toMatchObject({ destinationSession: DECIDER, state: "pending" });
|
||||
expect(handed.body).toContain(`${PERSON} answered ${SEAT}'s permission request ${askId}: they chose "Allow" (option allow).`);
|
||||
expect(handed.body).toContain(`Decide with: rig seat decide-permission ${askId} --option <id>`);
|
||||
// The receiver reads who to ask back (the person who answered), and that closing without a decision permits nothing.
|
||||
expect(handed.body).toContain(`ask them back with an ask to ${PERSON} that names request ${askId}`);
|
||||
expect(handed.body).toContain("Closing this item without deciding permits nothing: the person's answer stays kept and the runner keeps waiting");
|
||||
expect(handed.body).not.toContain("is how OpenRig records any answer typed at this machine's terminal");
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ status: 200, body: { ok: true, state: "answered", optionId: "allow" } });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "answered", optionId: "allow" } });
|
||||
@@ -652,6 +656,25 @@ describe("an answered request goes to the receiving agent, which decides while t
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("an answer typed at the terminal reaches the agent with what it means, who's who, and the asked person to ask back", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
const asked = f.queueRepo.getById(askId)!.destinationSession;
|
||||
await f.contract.act({ verb: "answer", qitemId: askId, actorSession: "human@host", authority: "primary-user",
|
||||
questionId: "permission", optionId: "allow" } as never);
|
||||
const handed = f.queueRepo.list({ limit: 100 }).find((row) => row.handedOffFrom === askId)!;
|
||||
expect(handed).toMatchObject({ destinationSession: DECIDER, state: "pending" });
|
||||
// Provenance stays truthful: the answerer is human@host, never a registered person.
|
||||
expect(handed.body).toContain(`human@host answered ${SEAT}'s permission request ${askId}: they chose "Allow" (option allow).`);
|
||||
expect(handed.body).toContain(`- **Who's who:** ${SEAT} is the agent asking to run the tool; ${asked} is the person it asked; human@host answered.`);
|
||||
expect(handed.body).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
|
||||
expect(handed.body).toContain(`ask them back with an ask to ${asked} that names request ${askId}`);
|
||||
expect(handed.body).toContain("Don't write to human@host: nothing is delivered there.");
|
||||
expect(handed.body).toContain("Closing this item without deciding permits nothing");
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a person's words, even a plain yes, go to the agent with the question and options, never permitted by default first", async () => {
|
||||
for (const words of ["yes", "only if it's the test file"]) {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
|
||||
Reference in new issue
Block a user