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
63 changes: 59 additions & 4 deletions apps/server/src/jira/JiraIssueBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,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 Down Expand Up @@ -661,8 +665,8 @@ const make = Effect.gen(function* () {
return;
}

// Untrusted actors: optional chat context note only (no agent). Never auto-creates.
// Requires a unique chat-linked issue in links.json when filing context.
// 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 @@ -715,19 +719,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 @@ -433,6 +433,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 @@ -1661,6 +1661,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
13 changes: 12 additions & 1 deletion docs/user/jira-issue-conversations.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,19 @@ are logged and never block the turn.

- Require a shared secret on every delivery (`Authorization: Bearer …` or `X-T3-Webhook-Secret`).
- Cap body size at 1 MiB.
<<<<<<< HEAD:docs/user/jira-issue-conversations.md
- Ignore events that are not `comment_created`.
- Allowlist projects when configured.
- # Allowlist projects when configured.
- Ignore events that are not `comment_created` / `comment_updated`.
- 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 → **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).
> > > > > > > 9bc1befdc (feat(jira): mirror untrusted context notes into T3 transcript):docs/integrations/jira-issue-conversations.md
- Do not put secrets in prompts, delivery logs, or git.
- Prefer the free Atlassian **service account** for REST replies (see
[atlassian-service-accounts](./atlassian-service-accounts.md) when present on the branch).
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 @@ -1022,6 +1022,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 @@ -1076,6 +1096,7 @@ const InternalOrchestrationCommand = Schema.Union([
ThreadProposedPlanUpsertCommand,
ThreadTurnDiffCompleteCommand,
ThreadActivityAppendCommand,
ThreadMessageAppendCommand,
ThreadQueueDrainCommand,
ThreadRevertCompleteCommand,
ThreadTitleRegenerationCompleteCommand,
Expand Down
Loading