Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 60 additions & 5 deletions apps/server/src/jira/JiraIssueBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ import {
postDiscordChannelMessage,
resolveDiscordBotToken,
} from "./jiraDiscordContext.ts";
import { buildJiraTurnPrompt, type JiraIssueInvocation } from "./JiraWebhookPayload.ts";
import {
buildJiraContextOnlyPrompt,
buildJiraTurnPrompt,
type JiraIssueInvocation,
} from "./JiraWebhookPayload.ts";

const NOT_LINKED_RESPONSE =
"not yet linked. No T3 thread lists this issue, and auto-create could not pick a project (set T3CODE_JIRA_PROJECT_MAP for this Jira key, T3CODE_JIRA_DEFAULT_PROJECT_ID, or ensure exactly one T3 project exists).";
Expand All @@ -69,7 +73,7 @@ const CONTEXT_UNLINKED_RESPONSE =
const CONTEXT_AMBIGUOUS_RESPONSE =
"Your Jira account is not in the T3 identity map (context-only), but multiple Discord threads are linked to this issue, so the bot could not pick which one to use.";
const CONTEXT_NOTED_RESPONSE =
"Noted as **context only** on the linked Discord thread (no agent run). Your Jira account is not in the T3 identity map; a trusted operator can act on it.";
"Noted as **context only** on the linked Discord thread (and T3 transcript when available; no agent run). Your Jira account is not in the T3 identity map; a trusted operator can act on it.";
const CONTEXT_FAILED_RESPONSE =
"Could not post context to the linked Discord thread (bot token missing or Discord API error). Ask a trusted operator to check the bot config.";
const CONTEXT_NO_LINKS_PATH_RESPONSE =
Expand Down Expand Up @@ -514,8 +518,8 @@ const make = Effect.gen(function* () {
return;
}

// Untrusted actors: Discord context only (no agent, no T3 transcript write).
// Requires a unique Discord-linked issue in links.json. Never auto-creates.
// Untrusted actors: Discord context note (required) + optional T3 transcript note.
// Never starts an agent turn. Never auto-creates links.
if (trust.mode === "context-only") {
const linksPath = config.discordLinksPath;
if (linksPath === null || linksPath.length === 0) {
Expand Down Expand Up @@ -572,19 +576,70 @@ const make = Effect.gen(function* () {
return;
}

yield* Effect.logInfo("Posted Jira context-only note to Discord (no agent run)", {
// Best-effort T3 transcript mirror when the Discord link carries a live T3 thread.
let t3MessageId: string | null = null;
if (discordLink.t3ThreadId !== null) {
const t3ThreadId = discordLink.t3ThreadId as ThreadId;
const snapshot = yield* projection
.getThreadDetailById(t3ThreadId)
.pipe(Effect.orElseSucceed(() => Option.none()));
if (Option.isSome(snapshot)) {
const commandId = CommandId.make(yield* crypto.randomUUIDv4);
const messageId = MessageId.make(yield* crypto.randomUUIDv4);
const mapPeople = yield* identity.listMapPeople();
const source = jiraSourceRef(input.invocation, mapPeople);
const mirrored = yield* engine
.dispatch({
type: "thread.message.append",
commandId,
threadId: t3ThreadId,
message: {
messageId,
role: "user",
text: buildJiraContextOnlyPrompt(input.invocation),
attachments: [],
},
source,
createdAt: DateTime.formatIso(yield* DateTime.now),
})
.pipe(
Effect.as(true),
Effect.catch((cause) =>
Effect.logWarning("Failed to mirror Jira context note into T3 transcript", {
deliveryId: input.deliveryId,
threadId: t3ThreadId,
cause,
}).pipe(Effect.as(false)),
),
);
if (mirrored) {
t3MessageId = messageId;
yield* workItems
.appendForThread({
threadId: t3ThreadId,
jiraIssueKeys: [input.invocation.issueKey],
source: "jira-webhook",
})
.pipe(Effect.ignore);
}
}
}

yield* Effect.logInfo("Posted Jira context-only note (no agent run)", {
deliveryId: input.deliveryId,
issueKey: input.invocation.issueKey,
discordThreadId: discordLink.discordThreadId,
t3ThreadId: discordLink.t3ThreadId,
discordMessageId: posted.message.id,
t3MessageId,
});
const notedDelivery: StoredJiraDelivery = {
...acknowledged,
threadId:
discordLink.t3ThreadId !== null
? (discordLink.t3ThreadId as ThreadId)
: acknowledged.threadId,
userMessageId: t3MessageId,
};
yield* finishDelivery(notedDelivery, CONTEXT_NOTED_RESPONSE, "completed");
return;
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/jira/JiraWebhookPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,25 @@ export function buildJiraTurnPrompt(invocation: JiraIssueInvocation): string {
return lines.join("\n");
}

/**
* Context-only transcript note for untrusted Jira actors (no agent turn).
*/
export function buildJiraContextOnlyPrompt(invocation: JiraIssueInvocation): string {
const requester = invocation.actorDisplayName ?? invocation.actorAccountId ?? "unknown";
const lines = [
...jiraPromptHeaderLines(invocation),
"- Trust: context-only (Jira actor not in identity map; no agent run)",
"-->",
"",
`**Jira context note** (no agent run) from [${requester}] on [${invocation.issueKey}]${
invocation.commentUrl ? `(${invocation.commentUrl})` : ""
}:`,
"",
invocation.prompt,
].filter((line): line is string => line !== null);
return lines.join("\n");
}

/**
* Stable delivery id: creates dedupe on comment id; updates include updated-at / prompt
* so redeliveries of the same edit collapse but new edits re-run.
Expand Down
66 changes: 66 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1499,6 +1499,72 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
};
}

case "thread.message.append": {
// Context-only transcript note: message-sent without turn-start-requested.
const thread = yield* requireThread({
readModel,
command,
threadId: command.threadId,
});
const messageSentEvent: PlannedOrchestrationEvent = {
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
})),
type: "thread.message-sent",
payload: {
threadId: command.threadId,
messageId: command.message.messageId,
role: command.message.role,
text: command.message.text,
...(command.message.attachments !== undefined
? { attachments: command.message.attachments }
: {}),
turnId: null,
streaming: false,
...(command.source !== undefined ? { source: command.source } : {}),
createdAt: command.createdAt,
updatedAt: command.createdAt,
},
};
const lifecycleResetEvents: Array<PlannedOrchestrationEvent> = [];
if (thread.settledOverride !== null) {
lifecycleResetEvents.push({
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
})),
type: "thread.unsettled",
payload: {
threadId: command.threadId,
reason: "activity",
updatedAt: command.createdAt,
},
});
}
if (thread.snoozedUntil != null) {
lifecycleResetEvents.push({
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
})),
type: "thread.unsnoozed",
payload: {
threadId: command.threadId,
reason: "activity",
updatedAt: command.createdAt,
},
});
}
return [...lifecycleResetEvents, messageSentEvent];
}

case "thread.activity.append": {
const thread = yield* requireThread({
readModel,
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/source-and-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ On `thread.turn.start` / user `thread.message-sent`:

**v1 policy:** unresolved external actor → stamp channel + actor only, `personId` null; does not count as mine for anyone. Log once per turn.

**Jira (when map enabled):** mapped `accountId` → `thread.turn.start` with SourceRef person fields. Unmapped/missing accountId → Discord context note only (no agent), and only if the issue is already linked to exactly one Discord thread in `links.json`.
**Jira (when map enabled):** mapped `accountId` → `thread.turn.start` with SourceRef person fields. Unmapped/missing accountId → Discord context note (required) + optional `thread.message.append` on the linked T3 thread (no agent); requires a unique Discord link in `links.json`.

3. Projector copies `source` onto `OrchestrationMessage`.
4. Shell projector maintains `originSource` and `participantPersonIds`.
Expand Down
5 changes: 4 additions & 1 deletion docs/integrations/jira-issue-conversations.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,10 @@ are logged and never block the turn.
- Allowlist projects when configured (`T3CODE_JIRA_ALLOWED_PROJECTS`).
- **Identity map trust gate** (when `T3_IDENTITY_MAP_PATH` has people):
- **Trusted** — Jira `accountId` appears on a map person (`jira.accountId` / `jiraAccountId`) → full agent turn (same as today, including auto-create when enabled).
- **Untrusted** — map on but actor missing/unmapped → **Discord context only** (no agent, no T3 transcript write). Posts a note into the unique Discord thread linked to the issue in `links.json` (`T3CODE_JIRA_DISCORD_LINKS_PATH` + `DISCORD_BOT_TOKEN`). Requires exactly one active Discord link with that issue key; never auto-creates. Jira reply explains the note was filed.
- **Untrusted** — map on but actor missing/unmapped → **context only** (no agent):
1. **Required:** post a note into the unique Discord thread linked to the issue (`links.json` + `DISCORD_BOT_TOKEN`).
2. **Best-effort:** when that link also has a live T3 thread id, append a transcript note via `thread.message.append` (no turn).
Requires exactly one active Discord link with that issue key; never auto-creates.
- Map **off** / empty → legacy full access for all mentioners (backward compatible).
- Do not put secrets in prompts, delivery logs, or git.
- Prefer the free Atlassian **service account** for REST replies (see
Expand Down
21 changes: 21 additions & 0 deletions packages/contracts/src/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,26 @@ const ThreadActivityAppendCommand = Schema.Struct({
createdAt: IsoDateTime,
});

/**
* Server-internal: append a transcript message without starting a turn.
* Used for untrusted integration context notes so operators (and later
* agent turns) can see platform context without granting host control.
*/
const ThreadMessageAppendCommand = Schema.Struct({
type: Schema.Literal("thread.message.append"),
commandId: CommandId,
threadId: ThreadId,
message: Schema.Struct({
messageId: MessageId,
role: Schema.Literals(["user", "system"]),
text: Schema.String,
attachments: Schema.optional(Schema.Array(ChatAttachment)),
}),
/** Server-authored only. */
source: Schema.optional(SourceRef),
createdAt: IsoDateTime,
});

/**
* Server-internal: dispatch the queued-message head as a turn after a
* natural (non-interrupted) turn completion. Rejected when the queue is
Expand Down Expand Up @@ -999,6 +1019,7 @@ const InternalOrchestrationCommand = Schema.Union([
ThreadProposedPlanUpsertCommand,
ThreadTurnDiffCompleteCommand,
ThreadActivityAppendCommand,
ThreadMessageAppendCommand,
ThreadQueueDrainCommand,
ThreadRevertCompleteCommand,
ThreadMessagesResyncCommand,
Expand Down