@mastra/factory 0.25.0-alpha.5 → 0.25.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/boards/work-transition-policy.d.ts.map +1 -1
- package/dist/boards/work-transition-policy.js +3 -3
- package/dist/boards/work-transition-policy.js.map +1 -1
- package/dist/default-thinking-level.d.ts +3 -0
- package/dist/default-thinking-level.d.ts.map +1 -0
- package/dist/default-thinking-level.js +5 -0
- package/dist/default-thinking-level.js.map +1 -0
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +19 -5
- package/dist/factory.js.map +1 -1
- package/dist/integrations/gitlab/integration.d.ts +4 -0
- package/dist/integrations/gitlab/integration.d.ts.map +1 -1
- package/dist/integrations/gitlab/integration.js +10 -1
- package/dist/integrations/gitlab/integration.js.map +1 -1
- package/dist/integrations/gitlab/routes.d.ts +6 -0
- package/dist/integrations/gitlab/routes.d.ts.map +1 -1
- package/dist/integrations/gitlab/routes.js +153 -1
- package/dist/integrations/gitlab/routes.js.map +1 -1
- package/dist/integrations/gitlab/rules.d.ts.map +1 -1
- package/dist/integrations/gitlab/rules.js +4 -1
- package/dist/integrations/gitlab/rules.js.map +1 -1
- package/dist/integrations/jira/api.d.ts +1 -1
- package/dist/integrations/jira/api.d.ts.map +1 -1
- package/dist/integrations/jira/api.js +1 -1
- package/dist/integrations/jira/api.js.map +1 -1
- package/dist/integrations/jira/integration.d.ts +3 -1
- package/dist/integrations/jira/integration.d.ts.map +1 -1
- package/dist/integrations/jira/integration.js +17 -2
- package/dist/integrations/jira/integration.js.map +1 -1
- package/dist/integrations/pull-request-head.d.ts +1 -0
- package/dist/integrations/pull-request-head.d.ts.map +1 -1
- package/dist/integrations/pull-request-head.js +2 -1
- package/dist/integrations/pull-request-head.js.map +1 -1
- package/dist/integrations/slack/channel-reply-processor.d.ts +1 -0
- package/dist/integrations/slack/channel-reply-processor.d.ts.map +1 -1
- package/dist/integrations/slack/channel-reply-processor.js +11 -3
- package/dist/integrations/slack/channel-reply-processor.js.map +1 -1
- package/dist/integrations/slack/channel-session-link-processor.d.ts +1 -0
- package/dist/integrations/slack/channel-session-link-processor.d.ts.map +1 -1
- package/dist/integrations/slack/channel-session-link-processor.js +1 -1
- package/dist/integrations/slack/channel-session-link-processor.js.map +1 -1
- package/dist/integrations/slack/reply-tool.d.ts.map +1 -1
- package/dist/integrations/slack/reply-tool.js +3 -11
- package/dist/integrations/slack/reply-tool.js.map +1 -1
- package/dist/integrations/slack/run-error-card.d.ts +3 -0
- package/dist/integrations/slack/run-error-card.d.ts.map +1 -0
- package/dist/integrations/slack/run-error-card.js +44 -0
- package/dist/integrations/slack/run-error-card.js.map +1 -0
- package/dist/integrations/slack/slack.d.ts.map +1 -1
- package/dist/integrations/slack/slack.js +5 -1
- package/dist/integrations/slack/slack.js.map +1 -1
- package/dist/links.d.ts +24 -0
- package/dist/links.d.ts.map +1 -0
- package/dist/links.js +40 -0
- package/dist/links.js.map +1 -0
- package/dist/routes/config.d.ts +3 -0
- package/dist/routes/config.d.ts.map +1 -1
- package/dist/routes/config.js +22 -1
- package/dist/routes/config.js.map +1 -1
- package/dist/routes/intake.js +5 -2
- package/dist/routes/intake.js.map +1 -1
- package/dist/routes/surface.js +1 -1
- package/dist/routes/surface.js.map +1 -1
- package/dist/rules/dispatcher.d.ts.map +1 -1
- package/dist/rules/dispatcher.js +8 -1
- package/dist/rules/dispatcher.js.map +1 -1
- package/dist/run-error.d.ts +13 -0
- package/dist/run-error.d.ts.map +1 -0
- package/dist/run-error.js +81 -0
- package/dist/run-error.js.map +1 -0
- package/dist/session/thinking-default.d.ts +1 -1
- package/dist/session/thinking-default.d.ts.map +1 -1
- package/dist/session/thinking-default.js +10 -11
- package/dist/session/thinking-default.js.map +1 -1
- package/dist/storage/domains/intake/base.d.ts +5 -0
- package/dist/storage/domains/intake/base.d.ts.map +1 -1
- package/dist/storage/domains/intake/base.js +9 -0
- package/dist/storage/domains/intake/base.js.map +1 -1
- package/dist/storage/domains/projects/base.d.ts +0 -1
- package/dist/storage/domains/projects/base.d.ts.map +1 -1
- package/dist/storage/domains/projects/base.js +4 -4
- package/dist/storage/domains/projects/base.js.map +1 -1
- package/dist/tools/read-tools.d.ts.map +1 -1
- package/dist/tools/read-tools.js +18 -2
- package/dist/tools/read-tools.js.map +1 -1
- package/dist/tools/run-watch.d.ts +0 -2
- package/dist/tools/run-watch.d.ts.map +1 -1
- package/dist/tools/run-watch.js +3 -42
- package/dist/tools/run-watch.js.map +1 -1
- package/dist/tools/scope.js +1 -1
- package/dist/tools/scope.js.map +1 -1
- package/dist/tools/session-tools.d.ts.map +1 -1
- package/dist/tools/session-tools.js +21 -5
- package/dist/tools/session-tools.js.map +1 -1
- package/dist/tools/work-item-tools.d.ts.map +1 -1
- package/dist/tools/work-item-tools.js +7 -1
- package/dist/tools/work-item-tools.js.map +1 -1
- package/factory-skills/factory-gitlab-review/SKILL.md +1 -1
- package/factory-skills/factory-plan/SKILL.md +1 -1
- package/factory-skills/factory-rereview/SKILL.md +2 -1
- package/factory-skills/factory-review/SKILL.md +2 -1
- package/factory-skills/factory-self-review/SKILL.md +1 -1
- package/factory-skills/factory-triage/SKILL.md +46 -23
- package/package.json +12 -12
|
@@ -26,8 +26,10 @@ import { JiraApiClient } from './api.js';
|
|
|
26
26
|
import type { JiraEventRules, JiraRuleOverrides } from './default-rules.js';
|
|
27
27
|
/** Deployment-global Jira Cloud credentials. All credential fields are required. */
|
|
28
28
|
export interface JiraIntegrationConfig {
|
|
29
|
-
/** Site base URL, e.g. `https://acme.atlassian.net
|
|
29
|
+
/** Site base URL, e.g. `https://acme.atlassian.net`, used for browser links. */
|
|
30
30
|
baseUrl: string;
|
|
31
|
+
/** API base URL for scoped tokens. Defaults to `baseUrl`. */
|
|
32
|
+
apiBaseUrl?: string;
|
|
31
33
|
/** Atlassian account email the API token belongs to. */
|
|
32
34
|
email: string;
|
|
33
35
|
/** API token from id.atlassian.com → Security → API tokens. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integration.d.ts","sourceRoot":"","sources":["../../../src/integrations/jira/integration.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,kCAAkC,CAAC;AAC9E,OAAO,KAAK,EAIV,MAAM,EACN,WAAW,EASZ,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,wCAAwC,CAAC;AACrF,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAK3F,OAAO,EAAE,aAAa,EAAgB,MAAM,UAAU,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAO5E,oFAAoF;AACpF,MAAM,WAAW,qBAAqB;IACpC,
|
|
1
|
+
{"version":3,"file":"integration.d.ts","sourceRoot":"","sources":["../../../src/integrations/jira/integration.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,kCAAkC,CAAC;AAC9E,OAAO,KAAK,EAIV,MAAM,EACN,WAAW,EASZ,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,wCAAwC,CAAC;AACrF,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAK3F,OAAO,EAAE,aAAa,EAAgB,MAAM,UAAU,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAO5E,oFAAoF;AACpF,MAAM,WAAW,qBAAqB;IACpC,gFAAgF;IAChF,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,KAAK,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,iBAAiB,CAAC;CAC3B;AAQD;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,qBAA2E,CAAC;AA6ChH,qBAAa,eAAgB,YAAW,kBAAkB;;IACxD,wDAAwD;IACxD,QAAQ,CAAC,EAAE,UAAU;IAIrB,6DAA6D;IAC7D,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAC;IAS5B,IAAI,KAAK,IAAI,cAAc,CAE1B;gBAEW,MAAM,EAAE,qBAAqB;IAQzC;;;;OAIG;IACH,UAAU,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE;QAAE,QAAQ,EAAE,sBAAsB,CAAC;QAAC,IAAI,EAAE,SAAS,CAAA;KAAE,GAAG,IAAI;IAK3F,+EAA+E;IAC/E,IAAI,QAAQ,IAAI,sBAAsB,CAKrC;IAED;;;OAGG;IACH,IAAI,WAAW,IAAI,OAAO,CAEzB;IAED;;;;OAIG;IACG,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAuB9D,oDAAoD;IACpD,WAAW,IAAI,IAAI;IAInB,0FAA0F;IAC1F,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,QAAQ,CAAC,MAAM,EAAE,MAAM,CAQrB;IA6EF;;;;OAIG;IACG,gBAAgB,CACpB,MAAM,CAAC,EAAE,MAAM,EACf,UAAU,CAAC,EAAE,MAAM,EAAE,EACrB,MAAM,CAAC,EAAE,MAAM,EAAE,GAChB,OAAO,CAAC;QAAE,MAAM,EAAE,KAAK,CAAC,WAAW,GAAG;YAAE,QAAQ,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IAyH5F;;;;OAIG;IACH,MAAM,CAAC,GAAG,EAAE,kBAAkB,GAAG,QAAQ,EAAE;IAS3C;;;;OAIG;IACH,OAAO,CAAC,GAAG,EAAE,kBAAkB,GAAG,YAAY,EAAE;IAchD;;;;OAIG;IACG,UAAU,CAAC,IAAI,EAAE;QAAE,cAAc,EAAE,cAAc,CAAA;KAAE,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAIrF,sEAAsE;IACtE,WAAW,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CAOvC"}
|
|
@@ -25,6 +25,16 @@ const DEPLOYMENT_CONNECTION = {
|
|
|
25
25
|
/** Jira issue keys look like `ENG-42`. */
|
|
26
26
|
const ISSUE_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\d+$/;
|
|
27
27
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
function normalizeSiteBaseUrl(baseUrl) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = new URL(baseUrl);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(`JiraIntegration baseUrl is not an absolute URL: "${baseUrl}".`);
|
|
34
|
+
}
|
|
35
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error(`JiraIntegration baseUrl must be an http(s) URL: "${baseUrl}".`);
|
|
36
|
+
return baseUrl.replace(/\/+$/, "");
|
|
37
|
+
}
|
|
28
38
|
/**
|
|
29
39
|
* Capability state types (`capabilities/intake.ts`) → Jira status-category
|
|
30
40
|
* keys. `canceled` has no Jira category — it is resolved separately against
|
|
@@ -48,6 +58,7 @@ var JiraIntegration = class {
|
|
|
48
58
|
/** Stable integration identifier (see `../base.ts`). */
|
|
49
59
|
id = "jira";
|
|
50
60
|
#config;
|
|
61
|
+
#baseUrl;
|
|
51
62
|
/** Typed REST client bound to the deployment credentials. */
|
|
52
63
|
api;
|
|
53
64
|
/** Bound once by the factory via `initialize()` before any surface is used. */
|
|
@@ -60,7 +71,11 @@ var JiraIntegration = class {
|
|
|
60
71
|
}
|
|
61
72
|
constructor(config) {
|
|
62
73
|
this.#rules = resolveJiraRules(config.rules);
|
|
63
|
-
this
|
|
74
|
+
this.#baseUrl = normalizeSiteBaseUrl(config.baseUrl);
|
|
75
|
+
this.api = new JiraApiClient({
|
|
76
|
+
...config,
|
|
77
|
+
baseUrl: config.apiBaseUrl ?? config.baseUrl
|
|
78
|
+
});
|
|
64
79
|
this.#config = config;
|
|
65
80
|
}
|
|
66
81
|
/**
|
|
@@ -112,7 +127,7 @@ var JiraIntegration = class {
|
|
|
112
127
|
}
|
|
113
128
|
/** Normalized site base URL (no trailing slash) — `/browse/<key>` links hang off this. */
|
|
114
129
|
get baseUrl() {
|
|
115
|
-
return this
|
|
130
|
+
return this.#baseUrl;
|
|
116
131
|
}
|
|
117
132
|
intake = {
|
|
118
133
|
resolveIntakeDispatch: (input) => this.#resolveIntakeDispatch(input),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integration.js","names":[],"sources":["../../../src/integrations/jira/integration.ts"],"sourcesContent":["/**\n * `JiraIntegration` — the self-contained Jira Cloud integration.\n *\n * Implements the system-wide `FactoryIntegration` contract\n * (`../base.ts`): the deploy entry reads the `JIRA_*` env vars ONCE,\n * constructs an instance with explicit credentials, and passes it to\n * `MastraFactory`. Everything Jira-flavored the system does — project/issue\n * reads for Intake and the agent's issue tools — flows through this\n * instance. No other module reads `JIRA_*` env vars.\n *\n * Unlike Linear, credentials are **deployment-global** (Basic auth with\n * `email:apiToken` from the environment): there is no OAuth flow, no state\n * signer, and no per-org connection storage. The `IntegrationConnection`\n * arguments required by the `Intake` contract are accepted but not consulted\n * — the instance's own credentials authenticate every call.\n */\n\nimport type { RequestContext } from '@mastra/core/request-context';\nimport type { ApiRoute } from '@mastra/core/server';\nimport type { MastraWorker } from '@mastra/core/worker';\n\nimport type { IntegrationConnection } from '../../capabilities/connection.js';\nimport type {\n CreateIntakeCommentInput,\n CreatedIntakeComment,\n GetIntakeIssueInput,\n Intake,\n IntakeIssue,\n IntakeIssueDetail,\n IntakeItemPage,\n IntakeSource,\n ListIntakeIssuesInput,\n ListIntakeItemsInput,\n ResolveIntakeDispatchInput,\n ResolvedIntakeDispatch,\n UpdateIntakeIssueInput,\n} from '../../capabilities/intake.js';\nimport type { RouteAuth } from '../../routes/route.js';\nimport type { FactoryProjectsStorage } from '../../storage/domains/projects/base.js';\nimport type { FactoryIntegration, IntegrationContext, IntegrationTools } from '../base.js';\nimport { IssueReconcileWorker } from '../issue-reconcile-worker.js';\nimport { adfToText } from './adf.js';\nimport { buildJiraAgentTools } from './agent-tools.js';\nimport type { JiraComment, JiraIssue, JiraTransition } from './api.js';\nimport { JiraApiClient, JiraApiError } from './api.js';\nimport type { JiraEventRules, JiraRuleOverrides } from './default-rules.js';\nimport { resolveJiraRules } from './default-rules.js';\nimport { attachJiraIssueReconciler } from './issue-reconciler.js';\nimport { jiraReconciliationEnabled, jiraReconciliationInterval } from './reconciliation-config.js';\nimport { buildJiraRoutes } from './routes.js';\nimport { attachJiraRules } from './rules.js';\n\n/** Deployment-global Jira Cloud credentials. All credential fields are required. */\nexport interface JiraIntegrationConfig {\n /** Site base URL, e.g. `https://acme.atlassian.net`. */\n baseUrl: string;\n /** Atlassian account email the API token belongs to. */\n email: string;\n /** API token from id.atlassian.com → Security → API tokens. */\n apiToken: string;\n /** Per-event replacements; omitted events retain defaults, null disables. */\n rules?: JiraRuleOverrides;\n}\n\n/** Hard stop for comment pagination so a misbehaving `total` can't loop forever. */\nconst ISSUE_COMMENTS_MAX_PAGES = 20;\n\n/** Hard stop for project pagination so a misbehaving `isLast` can't loop forever. */\nconst PROJECT_SEARCH_MAX_PAGES = 50;\n\n/**\n * The `Intake` contract requires a connection argument, but Jira credentials\n * live on the integration instance — this inert placeholder is accepted and\n * ignored everywhere, and keeps the real API token out of resolved dispatches\n * and agent-tool calls.\n */\nexport const DEPLOYMENT_CONNECTION: IntegrationConnection = { type: 'oauth', accessToken: 'deployment-global' };\n\n/** Jira issue keys look like `ENG-42`. */\nconst ISSUE_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\\d+$/;\n\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/**\n * Capability state types (`capabilities/intake.ts`) → Jira status-category\n * keys. `canceled` has no Jira category — it is resolved separately against\n * `done`-category transitions whose status name contains \"cancel\".\n */\nconst STATE_TYPE_TO_CATEGORY: Record<'unstarted' | 'started' | 'completed', string> = {\n unstarted: 'new',\n started: 'indeterminate',\n completed: 'done',\n};\n\n/** Jira status-category keys → capability state types. */\nfunction stateTypeFromCategory(key: string | undefined): string | null {\n switch (key) {\n case 'new':\n return 'unstarted';\n case 'indeterminate':\n return 'started';\n case 'done':\n return 'completed';\n default:\n return null;\n }\n}\n\nexport class JiraIntegration implements FactoryIntegration {\n /** Stable integration identifier (see `../base.ts`). */\n readonly id = 'jira';\n\n readonly #config: JiraIntegrationConfig;\n /** Typed REST client bound to the deployment credentials. */\n readonly api: JiraApiClient;\n\n /** Bound once by the factory via `initialize()` before any surface is used. */\n #projects: FactoryProjectsStorage | undefined;\n #auth: RouteAuth | undefined;\n readonly #orgIdByResourceId = new Map<string, string | null>();\n\n readonly #rules: JiraEventRules;\n\n get rules(): JiraEventRules {\n return this.#rules;\n }\n\n constructor(config: JiraIntegrationConfig) {\n this.#rules = resolveJiraRules(config.rules);\n // JiraApiClient validates the credential group and normalizes the URL.\n this.api = new JiraApiClient(config);\n this.#config = config;\n }\n\n /**\n * Bind the projects domain and the host auth seam. Jira has no per-org\n * connection rows, so the generic storage handle is unused — credentials\n * are deployment-global constructor config.\n */\n initialize({ projects, auth }: { projects: FactoryProjectsStorage; auth: RouteAuth }): void {\n this.#projects = projects;\n this.#auth = auth;\n }\n\n /** Factory projects domain — maps a session's resourceId to its owning org. */\n get projects(): FactoryProjectsStorage {\n if (!this.#projects) {\n throw new Error('JiraIntegration is not initialized — the factory binds storage during prepare().');\n }\n return this.#projects;\n }\n\n /**\n * Whether the host runs with web auth enabled. Intake selections are\n * org-owned, so every Jira surface is inert without a tenant auth seam.\n */\n get authEnabled(): boolean {\n return this.#auth?.enabled() ?? false;\n }\n\n /**\n * Map a session's resourceId (the factory project id) to its owning org.\n * Same caching semantics as the Linear integration: definitive misses are\n * cached, transient database failures are not.\n */\n async resolveOrgId(resourceId: string): Promise<string | null> {\n const cached = this.#orgIdByResourceId.get(resourceId);\n if (cached !== undefined) return cached;\n // Non-UUID resource ids (local/dev resources) would make the uuid column\n // comparison throw — they're definitively \"not a project\", so cache that.\n if (!UUID_PATTERN.test(resourceId)) {\n this.#orgIdByResourceId.set(resourceId, null);\n return null;\n }\n let orgId: string | null;\n try {\n await this.projects.ensureReady();\n const project = await this.projects.getById({ id: resourceId });\n orgId = project?.orgId ?? null;\n } catch {\n // Transient database failure: skip the tools for this request but don't\n // cache the miss, so the next request retries the lookup.\n return null;\n }\n this.#orgIdByResourceId.set(resourceId, orgId);\n return orgId;\n }\n\n /** Test hook: clear the org cache between specs. */\n clearCaches(): void {\n this.#orgIdByResourceId.clear();\n }\n\n /** Normalized site base URL (no trailing slash) — `/browse/<key>` links hang off this. */\n get baseUrl(): string {\n return this.api.baseUrl;\n }\n\n readonly intake: Intake = {\n resolveIntakeDispatch: input => this.#resolveIntakeDispatch(input),\n listSources: () => this.#listSources(),\n listItems: input => this.#listItems(input),\n listIssues: input => this.#listIssues(input),\n getIssue: input => this.#getIssue(input),\n createComment: input => this.#createComment(input),\n updateIssue: input => this.#updateIssue(input),\n };\n\n /**\n * Background-dispatch context. Credentials are deployment-global, so the\n * returned connection is a formality the Jira intake methods ignore; the\n * issue key is stored directly as the work item's `externalId` (mirroring\n * Linear, which stores the issue UUID).\n */\n async #resolveIntakeDispatch({ externalSource }: ResolveIntakeDispatchInput): Promise<ResolvedIntakeDispatch | null> {\n if (externalSource.type !== 'issue') return null;\n return {\n // Inert placeholder, never the real token: the Jira intake methods read\n // credentials from the client, and the resolved dispatch travels through\n // generic reconciler/dispatcher code that must stay secret-free.\n connection: DEPLOYMENT_CONNECTION,\n issueId: externalSource.externalId,\n };\n }\n\n /** All the site's projects (Settings intake-source picker), following `isLast` paging. */\n async #listSources(): Promise<IntakeSource[]> {\n const sources: IntakeSource[] = [];\n let startAt = 0;\n for (let pageCount = 0; pageCount < PROJECT_SEARCH_MAX_PAGES; pageCount++) {\n const page = await this.api.listProjects({ startAt });\n for (const project of page.values) {\n sources.push({\n id: project.id,\n name: project.name,\n type: 'project',\n metadata: { key: project.key },\n });\n }\n if (page.isLast || page.values.length === 0) break;\n startAt += page.values.length;\n }\n return sources;\n }\n\n async #listItems({ sourceIds, cursor }: ListIntakeItemsInput): Promise<IntakeItemPage> {\n if (sourceIds.length === 0) return { items: [], nextCursor: null };\n const page = await this.listActiveIssues(cursor, sourceIds);\n return {\n items: page.issues.map(issue => ({\n source: { type: 'issue', externalId: issue.identifier, url: issue.url },\n sourceId: issue.sourceId,\n title: `${issue.identifier}: ${issue.title}`,\n status: issue.state ?? undefined,\n labels: issue.labels,\n assignee: issue.assignee,\n createdAt: issue.createdAt,\n updatedAt: issue.updatedAt,\n metadata: {\n identifier: issue.identifier,\n issueRef: issue.identifier,\n state: issue.state,\n stateType: issue.stateType,\n priority: issue.priority,\n project: issue.source,\n assignee: issue.assignee,\n assignees: issue.assignees ?? [],\n creator: issue.author,\n author: issue.author,\n labels: issue.labels,\n createdAt: issue.createdAt,\n updatedAt: issue.updatedAt,\n },\n })),\n nextCursor: page.nextCursor,\n };\n }\n\n async #listIssues(input: ListIntakeIssuesInput): Promise<{ issues: IntakeIssue[]; nextCursor: string | null }> {\n const page = await this.listActiveIssues(input.cursor, input.sourceIds, input.labels);\n return { issues: page.issues, nextCursor: page.nextCursor };\n }\n\n /**\n * One page of active issues (status category not Done), most recently\n * updated first, optionally filtered to projects and labels. The\n * capability cursor is Jira's opaque `nextPageToken` passed through.\n */\n async listActiveIssues(\n cursor?: string,\n projectIds?: string[],\n labels?: string[],\n ): Promise<{ issues: Array<IntakeIssue & { sourceId: string }>; nextCursor: string | null }> {\n const page = await this.api.searchIssues({\n jql: buildIntakeJql(projectIds, labels),\n ...(cursor ? { nextPageToken: cursor } : {}),\n });\n return {\n issues: page.issues.map(issue => ({\n ...this.#toIntakeIssue(issue),\n sourceId: issue.fields.project?.id ?? '',\n })),\n nextCursor: page.nextPageToken ?? null,\n };\n }\n\n async #getIssue({ issueId }: GetIntakeIssueInput): Promise<IntakeIssueDetail | null> {\n let issue: JiraIssue;\n try {\n issue = await this.api.getIssue(issueId);\n } catch (err) {\n if (err instanceof JiraApiError && err.status === 404) return null;\n throw err;\n }\n const { comments, total } = await this.#listAllComments(issue.key);\n return {\n ...this.#toIntakeIssue(issue),\n commentCount: total,\n description: issue.fields.description ? adfToText(issue.fields.description) || null : null,\n comments: comments.map(comment => ({\n author: comment.author?.displayName ?? null,\n body: adfToText(comment.body),\n createdAt: comment.created,\n })),\n };\n }\n\n /** Every comment on the issue, oldest first, bounded by {@link ISSUE_COMMENTS_MAX_PAGES}. */\n async #listAllComments(keyOrId: string): Promise<{ comments: JiraComment[]; total: number }> {\n const comments: JiraComment[] = [];\n let total = 0;\n for (let page = 0; page < ISSUE_COMMENTS_MAX_PAGES; page++) {\n const result = await this.api.listComments(keyOrId, { startAt: comments.length });\n comments.push(...result.comments);\n total = result.total;\n if (result.comments.length === 0 || comments.length >= total) break;\n }\n return { comments, total };\n }\n\n async #createComment({ issueId, body }: CreateIntakeCommentInput): Promise<CreatedIntakeComment | null> {\n // The browse URL needs the issue key; resolve it when the caller passed a\n // numeric id instead.\n let key = issueId;\n if (!ISSUE_KEY_PATTERN.test(issueId)) {\n try {\n key = (await this.api.getIssue(issueId)).key;\n } catch (err) {\n if (err instanceof JiraApiError && err.status === 404) return null;\n throw err;\n }\n }\n let comment;\n try {\n comment = await this.api.createComment(key, body);\n } catch (err) {\n if (err instanceof JiraApiError && err.status === 404) return null;\n throw err;\n }\n return {\n id: comment.id,\n url: `${this.baseUrl}/browse/${key}?focusedCommentId=${comment.id}`,\n };\n }\n\n /**\n * Move an issue to a target state by applying a workflow transition. Jira\n * only exposes transitions that are currently legal, so a policy miss (no\n * transition reaches the target) returns `null` per the `Intake` contract —\n * infrastructure errors (network, auth) still throw.\n */\n async #updateIssue(input: UpdateIntakeIssueInput): Promise<IntakeIssue | null> {\n let issue: JiraIssue;\n try {\n issue = await this.api.getIssue(input.issueId);\n } catch (err) {\n if (err instanceof JiraApiError && err.status === 404) return null;\n throw err;\n }\n if (currentStatusMatches(issue, input.state)) {\n return this.#toIntakeIssue(issue);\n }\n const transitions = await this.api.listTransitions(issue.key);\n const transition = resolveTransition(transitions, input.state);\n if (!transition) return null;\n await this.api.applyTransition(issue.key, transition.id);\n const fresh = await this.api.getIssue(issue.key);\n return this.#toIntakeIssue(fresh);\n }\n\n #toIntakeIssue(issue: JiraIssue): IntakeIssue {\n const { fields } = issue;\n return {\n id: issue.id,\n identifier: issue.key,\n title: fields.summary ?? issue.key,\n url: `${this.baseUrl}/browse/${issue.key}`,\n author: fields.reporter?.displayName ?? null,\n state: fields.status?.name ?? null,\n stateType: stateTypeFromCategory(fields.status?.statusCategory?.key),\n priority: fields.priority?.name ?? null,\n assignee: fields.assignee?.displayName ?? null,\n source: fields.project?.key ?? null,\n sourceId: fields.project?.id ?? null,\n labels: fields.labels ?? [],\n commentCount: null,\n createdAt: fields.created ?? '',\n updatedAt: fields.updated ?? '',\n };\n }\n\n // ── FactoryIntegration surface ───────────────────────────────────────────\n\n /**\n * The integration's HTTP surface: `/web/jira/*` Mastra `apiRoutes` (status,\n * projects + issues for Intake). No OAuth routes — credentials are\n * deployment-global. Handlers operate on this instance.\n */\n routes(ctx: IntegrationContext): ApiRoute[] {\n return buildJiraRoutes({\n jira: this,\n auth: ctx.auth,\n intake: ctx.storage.intake,\n ingestFactoryIssues: attachJiraRules(this, ctx),\n });\n }\n\n /**\n * Background reconciliation, matching the Platform integration: keeps filed\n * Jira cards' metadata fresh and replays close events through the rules\n * ingress. Registered only when reconciliation is enabled.\n */\n workers(ctx: IntegrationContext): MastraWorker[] {\n if (!jiraReconciliationEnabled()) return [];\n const reconcile = attachJiraIssueReconciler(this, ctx);\n if (!reconcile) return [];\n const intervalMs = jiraReconciliationInterval();\n return [\n new IssueReconcileWorker({\n integrationId: this.id,\n reconcile,\n ...(intervalMs ? { intervalMs } : {}),\n }),\n ];\n }\n\n /**\n * Org-scoped agent tools: the read-only issue-detail tool for sessions whose\n * resource is a factory project (Jira credentials are deployment-global,\n * so no per-org connection gates apply). v1 exposes no mutating Jira tools.\n */\n async agentTools(args: { requestContext: RequestContext }): Promise<IntegrationTools> {\n return buildJiraAgentTools({ requestContext: args.requestContext, jira: this });\n }\n\n /** Non-secret config snapshot for system diagnostics/startup logs. */\n diagnostics(): Record<string, unknown> {\n return {\n configured: true,\n site: new URL(this.baseUrl).host,\n email: maskEmail(this.#config.email),\n };\n }\n}\n\n/** `ops@acme.test` → `o***@acme.test` — enough to identify, never the value. */\nfunction maskEmail(email: string): string {\n const at = email.indexOf('@');\n if (at <= 0) return '***';\n return `${email[0]}***${email.slice(at)}`;\n}\n\n/**\n * Build the intake JQL. Source ids and labels come from stored org config /\n * request input, so both are sanitized before interpolation: numeric project\n * ids pass through bare, project keys are restricted to a safe charset, and\n * label quotes/backslashes are stripped.\n */\nfunction buildIntakeJql(projectIds?: string[], labels?: string[]): string {\n const clauses: string[] = [];\n const safeProjects = (projectIds ?? [])\n .map(id => id.trim())\n .filter(id => /^[A-Za-z0-9_]+$/.test(id))\n .map(id => (/^\\d+$/.test(id) ? id : `\"${id}\"`));\n if (safeProjects.length > 0) {\n clauses.push(`project IN (${safeProjects.join(', ')})`);\n }\n clauses.push('statusCategory != Done');\n const safeLabels = [...new Set((labels ?? []).map(label => label.trim().replace(/[\"\\\\]/g, '')).filter(Boolean))];\n if (safeLabels.length > 0) {\n clauses.push(`labels IN (${safeLabels.map(label => `\"${label}\"`).join(', ')})`);\n }\n return `${clauses.join(' AND ')} ORDER BY updated DESC`;\n}\n\n/** Whether the issue is already in the requested target state. */\nfunction currentStatusMatches(issue: JiraIssue, target: UpdateIntakeIssueInput['state']): boolean {\n const status = issue.fields.status;\n if (!status) return false;\n if (target.kind === 'byName') {\n return status.name.toLowerCase() === target.name.toLowerCase();\n }\n if (target.stateType === 'canceled') {\n return status.statusCategory?.key === 'done' && /cancel/i.test(status.name);\n }\n return status.statusCategory?.key === STATE_TYPE_TO_CATEGORY[target.stateType];\n}\n\n/** Resolve the target state against the issue's currently-legal transitions. */\nfunction resolveTransition(\n transitions: JiraTransition[],\n target: UpdateIntakeIssueInput['state'],\n): JiraTransition | null {\n if (target.kind === 'byName') {\n const wanted = target.name.toLowerCase();\n return transitions.find(transition => transition.to?.name.toLowerCase() === wanted) ?? null;\n }\n if (target.stateType === 'canceled') {\n return (\n transitions.find(\n transition => transition.to?.statusCategory?.key === 'done' && /cancel/i.test(transition.to.name),\n ) ?? null\n );\n }\n const category = STATE_TYPE_TO_CATEGORY[target.stateType];\n return transitions.find(transition => transition.to?.statusCategory?.key === category) ?? null;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"integration.js","names":[],"sources":["../../../src/integrations/jira/integration.ts"],"sourcesContent":["/**\n * `JiraIntegration` — the self-contained Jira Cloud integration.\n *\n * Implements the system-wide `FactoryIntegration` contract\n * (`../base.ts`): the deploy entry reads the `JIRA_*` env vars ONCE,\n * constructs an instance with explicit credentials, and passes it to\n * `MastraFactory`. Everything Jira-flavored the system does — project/issue\n * reads for Intake and the agent's issue tools — flows through this\n * instance. No other module reads `JIRA_*` env vars.\n *\n * Unlike Linear, credentials are **deployment-global** (Basic auth with\n * `email:apiToken` from the environment): there is no OAuth flow, no state\n * signer, and no per-org connection storage. The `IntegrationConnection`\n * arguments required by the `Intake` contract are accepted but not consulted\n * — the instance's own credentials authenticate every call.\n */\n\nimport type { RequestContext } from '@mastra/core/request-context';\nimport type { ApiRoute } from '@mastra/core/server';\nimport type { MastraWorker } from '@mastra/core/worker';\n\nimport type { IntegrationConnection } from '../../capabilities/connection.js';\nimport type {\n CreateIntakeCommentInput,\n CreatedIntakeComment,\n GetIntakeIssueInput,\n Intake,\n IntakeIssue,\n IntakeIssueDetail,\n IntakeItemPage,\n IntakeSource,\n ListIntakeIssuesInput,\n ListIntakeItemsInput,\n ResolveIntakeDispatchInput,\n ResolvedIntakeDispatch,\n UpdateIntakeIssueInput,\n} from '../../capabilities/intake.js';\nimport type { RouteAuth } from '../../routes/route.js';\nimport type { FactoryProjectsStorage } from '../../storage/domains/projects/base.js';\nimport type { FactoryIntegration, IntegrationContext, IntegrationTools } from '../base.js';\nimport { IssueReconcileWorker } from '../issue-reconcile-worker.js';\nimport { adfToText } from './adf.js';\nimport { buildJiraAgentTools } from './agent-tools.js';\nimport type { JiraComment, JiraIssue, JiraTransition } from './api.js';\nimport { JiraApiClient, JiraApiError } from './api.js';\nimport type { JiraEventRules, JiraRuleOverrides } from './default-rules.js';\nimport { resolveJiraRules } from './default-rules.js';\nimport { attachJiraIssueReconciler } from './issue-reconciler.js';\nimport { jiraReconciliationEnabled, jiraReconciliationInterval } from './reconciliation-config.js';\nimport { buildJiraRoutes } from './routes.js';\nimport { attachJiraRules } from './rules.js';\n\n/** Deployment-global Jira Cloud credentials. All credential fields are required. */\nexport interface JiraIntegrationConfig {\n /** Site base URL, e.g. `https://acme.atlassian.net`, used for browser links. */\n baseUrl: string;\n /** API base URL for scoped tokens. Defaults to `baseUrl`. */\n apiBaseUrl?: string;\n /** Atlassian account email the API token belongs to. */\n email: string;\n /** API token from id.atlassian.com → Security → API tokens. */\n apiToken: string;\n /** Per-event replacements; omitted events retain defaults, null disables. */\n rules?: JiraRuleOverrides;\n}\n\n/** Hard stop for comment pagination so a misbehaving `total` can't loop forever. */\nconst ISSUE_COMMENTS_MAX_PAGES = 20;\n\n/** Hard stop for project pagination so a misbehaving `isLast` can't loop forever. */\nconst PROJECT_SEARCH_MAX_PAGES = 50;\n\n/**\n * The `Intake` contract requires a connection argument, but Jira credentials\n * live on the integration instance — this inert placeholder is accepted and\n * ignored everywhere, and keeps the real API token out of resolved dispatches\n * and agent-tool calls.\n */\nexport const DEPLOYMENT_CONNECTION: IntegrationConnection = { type: 'oauth', accessToken: 'deployment-global' };\n\n/** Jira issue keys look like `ENG-42`. */\nconst ISSUE_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\\d+$/;\n\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nfunction normalizeSiteBaseUrl(baseUrl: string): string {\n let parsed: URL;\n try {\n parsed = new URL(baseUrl);\n } catch {\n throw new Error(`JiraIntegration baseUrl is not an absolute URL: \"${baseUrl}\".`);\n }\n if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {\n throw new Error(`JiraIntegration baseUrl must be an http(s) URL: \"${baseUrl}\".`);\n }\n return baseUrl.replace(/\\/+$/, '');\n}\n\n/**\n * Capability state types (`capabilities/intake.ts`) → Jira status-category\n * keys. `canceled` has no Jira category — it is resolved separately against\n * `done`-category transitions whose status name contains \"cancel\".\n */\nconst STATE_TYPE_TO_CATEGORY: Record<'unstarted' | 'started' | 'completed', string> = {\n unstarted: 'new',\n started: 'indeterminate',\n completed: 'done',\n};\n\n/** Jira status-category keys → capability state types. */\nfunction stateTypeFromCategory(key: string | undefined): string | null {\n switch (key) {\n case 'new':\n return 'unstarted';\n case 'indeterminate':\n return 'started';\n case 'done':\n return 'completed';\n default:\n return null;\n }\n}\n\nexport class JiraIntegration implements FactoryIntegration {\n /** Stable integration identifier (see `../base.ts`). */\n readonly id = 'jira';\n\n readonly #config: JiraIntegrationConfig;\n readonly #baseUrl: string;\n /** Typed REST client bound to the deployment credentials. */\n readonly api: JiraApiClient;\n\n /** Bound once by the factory via `initialize()` before any surface is used. */\n #projects: FactoryProjectsStorage | undefined;\n #auth: RouteAuth | undefined;\n readonly #orgIdByResourceId = new Map<string, string | null>();\n\n readonly #rules: JiraEventRules;\n\n get rules(): JiraEventRules {\n return this.#rules;\n }\n\n constructor(config: JiraIntegrationConfig) {\n this.#rules = resolveJiraRules(config.rules);\n this.#baseUrl = normalizeSiteBaseUrl(config.baseUrl);\n // JiraApiClient validates the credential group and normalizes the API URL.\n this.api = new JiraApiClient({ ...config, baseUrl: config.apiBaseUrl ?? config.baseUrl });\n this.#config = config;\n }\n\n /**\n * Bind the projects domain and the host auth seam. Jira has no per-org\n * connection rows, so the generic storage handle is unused — credentials\n * are deployment-global constructor config.\n */\n initialize({ projects, auth }: { projects: FactoryProjectsStorage; auth: RouteAuth }): void {\n this.#projects = projects;\n this.#auth = auth;\n }\n\n /** Factory projects domain — maps a session's resourceId to its owning org. */\n get projects(): FactoryProjectsStorage {\n if (!this.#projects) {\n throw new Error('JiraIntegration is not initialized — the factory binds storage during prepare().');\n }\n return this.#projects;\n }\n\n /**\n * Whether the host runs with web auth enabled. Intake selections are\n * org-owned, so every Jira surface is inert without a tenant auth seam.\n */\n get authEnabled(): boolean {\n return this.#auth?.enabled() ?? false;\n }\n\n /**\n * Map a session's resourceId (the factory project id) to its owning org.\n * Same caching semantics as the Linear integration: definitive misses are\n * cached, transient database failures are not.\n */\n async resolveOrgId(resourceId: string): Promise<string | null> {\n const cached = this.#orgIdByResourceId.get(resourceId);\n if (cached !== undefined) return cached;\n // Non-UUID resource ids (local/dev resources) would make the uuid column\n // comparison throw — they're definitively \"not a project\", so cache that.\n if (!UUID_PATTERN.test(resourceId)) {\n this.#orgIdByResourceId.set(resourceId, null);\n return null;\n }\n let orgId: string | null;\n try {\n await this.projects.ensureReady();\n const project = await this.projects.getById({ id: resourceId });\n orgId = project?.orgId ?? null;\n } catch {\n // Transient database failure: skip the tools for this request but don't\n // cache the miss, so the next request retries the lookup.\n return null;\n }\n this.#orgIdByResourceId.set(resourceId, orgId);\n return orgId;\n }\n\n /** Test hook: clear the org cache between specs. */\n clearCaches(): void {\n this.#orgIdByResourceId.clear();\n }\n\n /** Normalized site base URL (no trailing slash) — `/browse/<key>` links hang off this. */\n get baseUrl(): string {\n return this.#baseUrl;\n }\n\n readonly intake: Intake = {\n resolveIntakeDispatch: input => this.#resolveIntakeDispatch(input),\n listSources: () => this.#listSources(),\n listItems: input => this.#listItems(input),\n listIssues: input => this.#listIssues(input),\n getIssue: input => this.#getIssue(input),\n createComment: input => this.#createComment(input),\n updateIssue: input => this.#updateIssue(input),\n };\n\n /**\n * Background-dispatch context. Credentials are deployment-global, so the\n * returned connection is a formality the Jira intake methods ignore; the\n * issue key is stored directly as the work item's `externalId` (mirroring\n * Linear, which stores the issue UUID).\n */\n async #resolveIntakeDispatch({ externalSource }: ResolveIntakeDispatchInput): Promise<ResolvedIntakeDispatch | null> {\n if (externalSource.type !== 'issue') return null;\n return {\n // Inert placeholder, never the real token: the Jira intake methods read\n // credentials from the client, and the resolved dispatch travels through\n // generic reconciler/dispatcher code that must stay secret-free.\n connection: DEPLOYMENT_CONNECTION,\n issueId: externalSource.externalId,\n };\n }\n\n /** All the site's projects (Settings intake-source picker), following `isLast` paging. */\n async #listSources(): Promise<IntakeSource[]> {\n const sources: IntakeSource[] = [];\n let startAt = 0;\n for (let pageCount = 0; pageCount < PROJECT_SEARCH_MAX_PAGES; pageCount++) {\n const page = await this.api.listProjects({ startAt });\n for (const project of page.values) {\n sources.push({\n id: project.id,\n name: project.name,\n type: 'project',\n metadata: { key: project.key },\n });\n }\n if (page.isLast || page.values.length === 0) break;\n startAt += page.values.length;\n }\n return sources;\n }\n\n async #listItems({ sourceIds, cursor }: ListIntakeItemsInput): Promise<IntakeItemPage> {\n if (sourceIds.length === 0) return { items: [], nextCursor: null };\n const page = await this.listActiveIssues(cursor, sourceIds);\n return {\n items: page.issues.map(issue => ({\n source: { type: 'issue', externalId: issue.identifier, url: issue.url },\n sourceId: issue.sourceId,\n title: `${issue.identifier}: ${issue.title}`,\n status: issue.state ?? undefined,\n labels: issue.labels,\n assignee: issue.assignee,\n createdAt: issue.createdAt,\n updatedAt: issue.updatedAt,\n metadata: {\n identifier: issue.identifier,\n issueRef: issue.identifier,\n state: issue.state,\n stateType: issue.stateType,\n priority: issue.priority,\n project: issue.source,\n assignee: issue.assignee,\n assignees: issue.assignees ?? [],\n creator: issue.author,\n author: issue.author,\n labels: issue.labels,\n createdAt: issue.createdAt,\n updatedAt: issue.updatedAt,\n },\n })),\n nextCursor: page.nextCursor,\n };\n }\n\n async #listIssues(input: ListIntakeIssuesInput): Promise<{ issues: IntakeIssue[]; nextCursor: string | null }> {\n const page = await this.listActiveIssues(input.cursor, input.sourceIds, input.labels);\n return { issues: page.issues, nextCursor: page.nextCursor };\n }\n\n /**\n * One page of active issues (status category not Done), most recently\n * updated first, optionally filtered to projects and labels. The\n * capability cursor is Jira's opaque `nextPageToken` passed through.\n */\n async listActiveIssues(\n cursor?: string,\n projectIds?: string[],\n labels?: string[],\n ): Promise<{ issues: Array<IntakeIssue & { sourceId: string }>; nextCursor: string | null }> {\n const page = await this.api.searchIssues({\n jql: buildIntakeJql(projectIds, labels),\n ...(cursor ? { nextPageToken: cursor } : {}),\n });\n return {\n issues: page.issues.map(issue => ({\n ...this.#toIntakeIssue(issue),\n sourceId: issue.fields.project?.id ?? '',\n })),\n nextCursor: page.nextPageToken ?? null,\n };\n }\n\n async #getIssue({ issueId }: GetIntakeIssueInput): Promise<IntakeIssueDetail | null> {\n let issue: JiraIssue;\n try {\n issue = await this.api.getIssue(issueId);\n } catch (err) {\n if (err instanceof JiraApiError && err.status === 404) return null;\n throw err;\n }\n const { comments, total } = await this.#listAllComments(issue.key);\n return {\n ...this.#toIntakeIssue(issue),\n commentCount: total,\n description: issue.fields.description ? adfToText(issue.fields.description) || null : null,\n comments: comments.map(comment => ({\n author: comment.author?.displayName ?? null,\n body: adfToText(comment.body),\n createdAt: comment.created,\n })),\n };\n }\n\n /** Every comment on the issue, oldest first, bounded by {@link ISSUE_COMMENTS_MAX_PAGES}. */\n async #listAllComments(keyOrId: string): Promise<{ comments: JiraComment[]; total: number }> {\n const comments: JiraComment[] = [];\n let total = 0;\n for (let page = 0; page < ISSUE_COMMENTS_MAX_PAGES; page++) {\n const result = await this.api.listComments(keyOrId, { startAt: comments.length });\n comments.push(...result.comments);\n total = result.total;\n if (result.comments.length === 0 || comments.length >= total) break;\n }\n return { comments, total };\n }\n\n async #createComment({ issueId, body }: CreateIntakeCommentInput): Promise<CreatedIntakeComment | null> {\n // The browse URL needs the issue key; resolve it when the caller passed a\n // numeric id instead.\n let key = issueId;\n if (!ISSUE_KEY_PATTERN.test(issueId)) {\n try {\n key = (await this.api.getIssue(issueId)).key;\n } catch (err) {\n if (err instanceof JiraApiError && err.status === 404) return null;\n throw err;\n }\n }\n let comment;\n try {\n comment = await this.api.createComment(key, body);\n } catch (err) {\n if (err instanceof JiraApiError && err.status === 404) return null;\n throw err;\n }\n return {\n id: comment.id,\n url: `${this.baseUrl}/browse/${key}?focusedCommentId=${comment.id}`,\n };\n }\n\n /**\n * Move an issue to a target state by applying a workflow transition. Jira\n * only exposes transitions that are currently legal, so a policy miss (no\n * transition reaches the target) returns `null` per the `Intake` contract —\n * infrastructure errors (network, auth) still throw.\n */\n async #updateIssue(input: UpdateIntakeIssueInput): Promise<IntakeIssue | null> {\n let issue: JiraIssue;\n try {\n issue = await this.api.getIssue(input.issueId);\n } catch (err) {\n if (err instanceof JiraApiError && err.status === 404) return null;\n throw err;\n }\n if (currentStatusMatches(issue, input.state)) {\n return this.#toIntakeIssue(issue);\n }\n const transitions = await this.api.listTransitions(issue.key);\n const transition = resolveTransition(transitions, input.state);\n if (!transition) return null;\n await this.api.applyTransition(issue.key, transition.id);\n const fresh = await this.api.getIssue(issue.key);\n return this.#toIntakeIssue(fresh);\n }\n\n #toIntakeIssue(issue: JiraIssue): IntakeIssue {\n const { fields } = issue;\n return {\n id: issue.id,\n identifier: issue.key,\n title: fields.summary ?? issue.key,\n url: `${this.baseUrl}/browse/${issue.key}`,\n author: fields.reporter?.displayName ?? null,\n state: fields.status?.name ?? null,\n stateType: stateTypeFromCategory(fields.status?.statusCategory?.key),\n priority: fields.priority?.name ?? null,\n assignee: fields.assignee?.displayName ?? null,\n source: fields.project?.key ?? null,\n sourceId: fields.project?.id ?? null,\n labels: fields.labels ?? [],\n commentCount: null,\n createdAt: fields.created ?? '',\n updatedAt: fields.updated ?? '',\n };\n }\n\n // ── FactoryIntegration surface ───────────────────────────────────────────\n\n /**\n * The integration's HTTP surface: `/web/jira/*` Mastra `apiRoutes` (status,\n * projects + issues for Intake). No OAuth routes — credentials are\n * deployment-global. Handlers operate on this instance.\n */\n routes(ctx: IntegrationContext): ApiRoute[] {\n return buildJiraRoutes({\n jira: this,\n auth: ctx.auth,\n intake: ctx.storage.intake,\n ingestFactoryIssues: attachJiraRules(this, ctx),\n });\n }\n\n /**\n * Background reconciliation, matching the Platform integration: keeps filed\n * Jira cards' metadata fresh and replays close events through the rules\n * ingress. Registered only when reconciliation is enabled.\n */\n workers(ctx: IntegrationContext): MastraWorker[] {\n if (!jiraReconciliationEnabled()) return [];\n const reconcile = attachJiraIssueReconciler(this, ctx);\n if (!reconcile) return [];\n const intervalMs = jiraReconciliationInterval();\n return [\n new IssueReconcileWorker({\n integrationId: this.id,\n reconcile,\n ...(intervalMs ? { intervalMs } : {}),\n }),\n ];\n }\n\n /**\n * Org-scoped agent tools: the read-only issue-detail tool for sessions whose\n * resource is a factory project (Jira credentials are deployment-global,\n * so no per-org connection gates apply). v1 exposes no mutating Jira tools.\n */\n async agentTools(args: { requestContext: RequestContext }): Promise<IntegrationTools> {\n return buildJiraAgentTools({ requestContext: args.requestContext, jira: this });\n }\n\n /** Non-secret config snapshot for system diagnostics/startup logs. */\n diagnostics(): Record<string, unknown> {\n return {\n configured: true,\n site: new URL(this.baseUrl).host,\n email: maskEmail(this.#config.email),\n };\n }\n}\n\n/** `ops@acme.test` → `o***@acme.test` — enough to identify, never the value. */\nfunction maskEmail(email: string): string {\n const at = email.indexOf('@');\n if (at <= 0) return '***';\n return `${email[0]}***${email.slice(at)}`;\n}\n\n/**\n * Build the intake JQL. Source ids and labels come from stored org config /\n * request input, so both are sanitized before interpolation: numeric project\n * ids pass through bare, project keys are restricted to a safe charset, and\n * label quotes/backslashes are stripped.\n */\nfunction buildIntakeJql(projectIds?: string[], labels?: string[]): string {\n const clauses: string[] = [];\n const safeProjects = (projectIds ?? [])\n .map(id => id.trim())\n .filter(id => /^[A-Za-z0-9_]+$/.test(id))\n .map(id => (/^\\d+$/.test(id) ? id : `\"${id}\"`));\n if (safeProjects.length > 0) {\n clauses.push(`project IN (${safeProjects.join(', ')})`);\n }\n clauses.push('statusCategory != Done');\n const safeLabels = [...new Set((labels ?? []).map(label => label.trim().replace(/[\"\\\\]/g, '')).filter(Boolean))];\n if (safeLabels.length > 0) {\n clauses.push(`labels IN (${safeLabels.map(label => `\"${label}\"`).join(', ')})`);\n }\n return `${clauses.join(' AND ')} ORDER BY updated DESC`;\n}\n\n/** Whether the issue is already in the requested target state. */\nfunction currentStatusMatches(issue: JiraIssue, target: UpdateIntakeIssueInput['state']): boolean {\n const status = issue.fields.status;\n if (!status) return false;\n if (target.kind === 'byName') {\n return status.name.toLowerCase() === target.name.toLowerCase();\n }\n if (target.stateType === 'canceled') {\n return status.statusCategory?.key === 'done' && /cancel/i.test(status.name);\n }\n return status.statusCategory?.key === STATE_TYPE_TO_CATEGORY[target.stateType];\n}\n\n/** Resolve the target state against the issue's currently-legal transitions. */\nfunction resolveTransition(\n transitions: JiraTransition[],\n target: UpdateIntakeIssueInput['state'],\n): JiraTransition | null {\n if (target.kind === 'byName') {\n const wanted = target.name.toLowerCase();\n return transitions.find(transition => transition.to?.name.toLowerCase() === wanted) ?? null;\n }\n if (target.stateType === 'canceled') {\n return (\n transitions.find(\n transition => transition.to?.statusCategory?.key === 'done' && /cancel/i.test(transition.to.name),\n ) ?? null\n );\n }\n const category = STATE_TYPE_TO_CATEGORY[target.stateType];\n return transitions.find(transition => transition.to?.statusCategory?.key === category) ?? null;\n}\n"],"mappings":";;;;;;;;;;;AAmEA,MAAM,2BAA2B;;AAGjC,MAAM,2BAA2B;;;;;;;AAQjC,MAAa,wBAA+C;CAAE,MAAM;CAAS,aAAa;AAAoB;;AAG9G,MAAM,oBAAoB;AAE1B,MAAM,eAAe;AAErB,SAAS,qBAAqB,SAAyB;CACrD,IAAI;CACJ,IAAI;EACF,SAAS,IAAI,IAAI,OAAO;CAC1B,QAAQ;EACN,MAAM,IAAI,MAAM,oDAAoD,QAAQ,GAAG;CACjF;CACA,IAAI,OAAO,aAAa,YAAY,OAAO,aAAa,SACtD,MAAM,IAAI,MAAM,oDAAoD,QAAQ,GAAG;CAEjF,OAAO,QAAQ,QAAQ,QAAQ,EAAE;AACnC;;;;;;AAOA,MAAM,yBAAgF;CACpF,WAAW;CACX,SAAS;CACT,WAAW;AACb;;AAGA,SAAS,sBAAsB,KAAwC;CACrE,QAAQ,KAAR;EACE,KAAK,OACH,OAAO;EACT,KAAK,iBACH,OAAO;EACT,KAAK,QACH,OAAO;EACT,SACE,OAAO;CACX;AACF;AAEA,IAAa,kBAAb,MAA2D;;CAEzD,KAAc;CAEd;CACA;;CAEA;;CAGA;CACA;CACA,qCAA8B,IAAI,IAA2B;CAE7D;CAEA,IAAI,QAAwB;EAC1B,OAAO,KAAK;CACd;CAEA,YAAY,QAA+B;EACzC,KAAK,SAAS,iBAAiB,OAAO,KAAK;EAC3C,KAAK,WAAW,qBAAqB,OAAO,OAAO;EAEnD,KAAK,MAAM,IAAI,cAAc;GAAE,GAAG;GAAQ,SAAS,OAAO,cAAc,OAAO;EAAQ,CAAC;EACxF,KAAK,UAAU;CACjB;;;;;;CAOA,WAAW,EAAE,UAAU,QAAqE;EAC1F,KAAK,YAAY;EACjB,KAAK,QAAQ;CACf;;CAGA,IAAI,WAAmC;EACrC,IAAI,CAAC,KAAK,WACR,MAAM,IAAI,MAAM,kFAAkF;EAEpG,OAAO,KAAK;CACd;;;;;CAMA,IAAI,cAAuB;EACzB,OAAO,KAAK,OAAO,QAAQ,KAAK;CAClC;;;;;;CAOA,MAAM,aAAa,YAA4C;EAC7D,MAAM,SAAS,KAAK,mBAAmB,IAAI,UAAU;EACrD,IAAI,WAAW,KAAA,GAAW,OAAO;EAGjC,IAAI,CAAC,aAAa,KAAK,UAAU,GAAG;GAClC,KAAK,mBAAmB,IAAI,YAAY,IAAI;GAC5C,OAAO;EACT;EACA,IAAI;EACJ,IAAI;GACF,MAAM,KAAK,SAAS,YAAY;GAEhC,SAAQ,MADc,KAAK,SAAS,QAAQ,EAAE,IAAI,WAAW,CAAC,EAAA,EAC7C,SAAS;EAC5B,QAAQ;GAGN,OAAO;EACT;EACA,KAAK,mBAAmB,IAAI,YAAY,KAAK;EAC7C,OAAO;CACT;;CAGA,cAAoB;EAClB,KAAK,mBAAmB,MAAM;CAChC;;CAGA,IAAI,UAAkB;EACpB,OAAO,KAAK;CACd;CAEA,SAA0B;EACxB,wBAAuB,UAAS,KAAK,uBAAuB,KAAK;EACjE,mBAAmB,KAAK,aAAa;EACrC,YAAW,UAAS,KAAK,WAAW,KAAK;EACzC,aAAY,UAAS,KAAK,YAAY,KAAK;EAC3C,WAAU,UAAS,KAAK,UAAU,KAAK;EACvC,gBAAe,UAAS,KAAK,eAAe,KAAK;EACjD,cAAa,UAAS,KAAK,aAAa,KAAK;CAC/C;;;;;;;CAQA,MAAM,uBAAuB,EAAE,kBAAsF;EACnH,IAAI,eAAe,SAAS,SAAS,OAAO;EAC5C,OAAO;GAIL,YAAY;GACZ,SAAS,eAAe;EAC1B;CACF;;CAGA,MAAM,eAAwC;EAC5C,MAAM,UAA0B,CAAC;EACjC,IAAI,UAAU;EACd,KAAK,IAAI,YAAY,GAAG,YAAY,0BAA0B,aAAa;GACzE,MAAM,OAAO,MAAM,KAAK,IAAI,aAAa,EAAE,QAAQ,CAAC;GACpD,KAAK,MAAM,WAAW,KAAK,QACzB,QAAQ,KAAK;IACX,IAAI,QAAQ;IACZ,MAAM,QAAQ;IACd,MAAM;IACN,UAAU,EAAE,KAAK,QAAQ,IAAI;GAC/B,CAAC;GAEH,IAAI,KAAK,UAAU,KAAK,OAAO,WAAW,GAAG;GAC7C,WAAW,KAAK,OAAO;EACzB;EACA,OAAO;CACT;CAEA,MAAM,WAAW,EAAE,WAAW,UAAyD;EACrF,IAAI,UAAU,WAAW,GAAG,OAAO;GAAE,OAAO,CAAC;GAAG,YAAY;EAAK;EACjE,MAAM,OAAO,MAAM,KAAK,iBAAiB,QAAQ,SAAS;EAC1D,OAAO;GACL,OAAO,KAAK,OAAO,KAAI,WAAU;IAC/B,QAAQ;KAAE,MAAM;KAAS,YAAY,MAAM;KAAY,KAAK,MAAM;IAAI;IACtE,UAAU,MAAM;IAChB,OAAO,GAAG,MAAM,WAAW,IAAI,MAAM;IACrC,QAAQ,MAAM,SAAS,KAAA;IACvB,QAAQ,MAAM;IACd,UAAU,MAAM;IAChB,WAAW,MAAM;IACjB,WAAW,MAAM;IACjB,UAAU;KACR,YAAY,MAAM;KAClB,UAAU,MAAM;KAChB,OAAO,MAAM;KACb,WAAW,MAAM;KACjB,UAAU,MAAM;KAChB,SAAS,MAAM;KACf,UAAU,MAAM;KAChB,WAAW,MAAM,aAAa,CAAC;KAC/B,SAAS,MAAM;KACf,QAAQ,MAAM;KACd,QAAQ,MAAM;KACd,WAAW,MAAM;KACjB,WAAW,MAAM;IACnB;GACF,EAAE;GACF,YAAY,KAAK;EACnB;CACF;CAEA,MAAM,YAAY,OAA6F;EAC7G,MAAM,OAAO,MAAM,KAAK,iBAAiB,MAAM,QAAQ,MAAM,WAAW,MAAM,MAAM;EACpF,OAAO;GAAE,QAAQ,KAAK;GAAQ,YAAY,KAAK;EAAW;CAC5D;;;;;;CAOA,MAAM,iBACJ,QACA,YACA,QAC2F;EAC3F,MAAM,OAAO,MAAM,KAAK,IAAI,aAAa;GACvC,KAAK,eAAe,YAAY,MAAM;GACtC,GAAI,SAAS,EAAE,eAAe,OAAO,IAAI,CAAC;EAC5C,CAAC;EACD,OAAO;GACL,QAAQ,KAAK,OAAO,KAAI,WAAU;IAChC,GAAG,KAAK,eAAe,KAAK;IAC5B,UAAU,MAAM,OAAO,SAAS,MAAM;GACxC,EAAE;GACF,YAAY,KAAK,iBAAiB;EACpC;CACF;CAEA,MAAM,UAAU,EAAE,WAAmE;EACnF,IAAI;EACJ,IAAI;GACF,QAAQ,MAAM,KAAK,IAAI,SAAS,OAAO;EACzC,SAAS,KAAK;GACZ,IAAI,eAAe,gBAAgB,IAAI,WAAW,KAAK,OAAO;GAC9D,MAAM;EACR;EACA,MAAM,EAAE,UAAU,UAAU,MAAM,KAAK,iBAAiB,MAAM,GAAG;EACjE,OAAO;GACL,GAAG,KAAK,eAAe,KAAK;GAC5B,cAAc;GACd,aAAa,MAAM,OAAO,cAAc,UAAU,MAAM,OAAO,WAAW,KAAK,OAAO;GACtF,UAAU,SAAS,KAAI,aAAY;IACjC,QAAQ,QAAQ,QAAQ,eAAe;IACvC,MAAM,UAAU,QAAQ,IAAI;IAC5B,WAAW,QAAQ;GACrB,EAAE;EACJ;CACF;;CAGA,MAAM,iBAAiB,SAAsE;EAC3F,MAAM,WAA0B,CAAC;EACjC,IAAI,QAAQ;EACZ,KAAK,IAAI,OAAO,GAAG,OAAO,0BAA0B,QAAQ;GAC1D,MAAM,SAAS,MAAM,KAAK,IAAI,aAAa,SAAS,EAAE,SAAS,SAAS,OAAO,CAAC;GAChF,SAAS,KAAK,GAAG,OAAO,QAAQ;GAChC,QAAQ,OAAO;GACf,IAAI,OAAO,SAAS,WAAW,KAAK,SAAS,UAAU,OAAO;EAChE;EACA,OAAO;GAAE;GAAU;EAAM;CAC3B;CAEA,MAAM,eAAe,EAAE,SAAS,QAAwE;EAGtG,IAAI,MAAM;EACV,IAAI,CAAC,kBAAkB,KAAK,OAAO,GACjC,IAAI;GACF,OAAO,MAAM,KAAK,IAAI,SAAS,OAAO,EAAA,CAAG;EAC3C,SAAS,KAAK;GACZ,IAAI,eAAe,gBAAgB,IAAI,WAAW,KAAK,OAAO;GAC9D,MAAM;EACR;EAEF,IAAI;EACJ,IAAI;GACF,UAAU,MAAM,KAAK,IAAI,cAAc,KAAK,IAAI;EAClD,SAAS,KAAK;GACZ,IAAI,eAAe,gBAAgB,IAAI,WAAW,KAAK,OAAO;GAC9D,MAAM;EACR;EACA,OAAO;GACL,IAAI,QAAQ;GACZ,KAAK,GAAG,KAAK,QAAQ,UAAU,IAAI,oBAAoB,QAAQ;EACjE;CACF;;;;;;;CAQA,MAAM,aAAa,OAA4D;EAC7E,IAAI;EACJ,IAAI;GACF,QAAQ,MAAM,KAAK,IAAI,SAAS,MAAM,OAAO;EAC/C,SAAS,KAAK;GACZ,IAAI,eAAe,gBAAgB,IAAI,WAAW,KAAK,OAAO;GAC9D,MAAM;EACR;EACA,IAAI,qBAAqB,OAAO,MAAM,KAAK,GACzC,OAAO,KAAK,eAAe,KAAK;EAGlC,MAAM,aAAa,kBAAkB,MADX,KAAK,IAAI,gBAAgB,MAAM,GAAG,GACV,MAAM,KAAK;EAC7D,IAAI,CAAC,YAAY,OAAO;EACxB,MAAM,KAAK,IAAI,gBAAgB,MAAM,KAAK,WAAW,EAAE;EACvD,MAAM,QAAQ,MAAM,KAAK,IAAI,SAAS,MAAM,GAAG;EAC/C,OAAO,KAAK,eAAe,KAAK;CAClC;CAEA,eAAe,OAA+B;EAC5C,MAAM,EAAE,WAAW;EACnB,OAAO;GACL,IAAI,MAAM;GACV,YAAY,MAAM;GAClB,OAAO,OAAO,WAAW,MAAM;GAC/B,KAAK,GAAG,KAAK,QAAQ,UAAU,MAAM;GACrC,QAAQ,OAAO,UAAU,eAAe;GACxC,OAAO,OAAO,QAAQ,QAAQ;GAC9B,WAAW,sBAAsB,OAAO,QAAQ,gBAAgB,GAAG;GACnE,UAAU,OAAO,UAAU,QAAQ;GACnC,UAAU,OAAO,UAAU,eAAe;GAC1C,QAAQ,OAAO,SAAS,OAAO;GAC/B,UAAU,OAAO,SAAS,MAAM;GAChC,QAAQ,OAAO,UAAU,CAAC;GAC1B,cAAc;GACd,WAAW,OAAO,WAAW;GAC7B,WAAW,OAAO,WAAW;EAC/B;CACF;;;;;;CASA,OAAO,KAAqC;EAC1C,OAAO,gBAAgB;GACrB,MAAM;GACN,MAAM,IAAI;GACV,QAAQ,IAAI,QAAQ;GACpB,qBAAqB,gBAAgB,MAAM,GAAG;EAChD,CAAC;CACH;;;;;;CAOA,QAAQ,KAAyC;EAC/C,IAAI,CAAC,0BAA0B,GAAG,OAAO,CAAC;EAC1C,MAAM,YAAY,0BAA0B,MAAM,GAAG;EACrD,IAAI,CAAC,WAAW,OAAO,CAAC;EACxB,MAAM,aAAa,2BAA2B;EAC9C,OAAO,CACL,IAAI,qBAAqB;GACvB,eAAe,KAAK;GACpB;GACA,GAAI,aAAa,EAAE,WAAW,IAAI,CAAC;EACrC,CAAC,CACH;CACF;;;;;;CAOA,MAAM,WAAW,MAAqE;EACpF,OAAO,oBAAoB;GAAE,gBAAgB,KAAK;GAAgB,MAAM;EAAK,CAAC;CAChF;;CAGA,cAAuC;EACrC,OAAO;GACL,YAAY;GACZ,MAAM,IAAI,IAAI,KAAK,OAAO,CAAC,CAAC;GAC5B,OAAO,UAAU,KAAK,QAAQ,KAAK;EACrC;CACF;AACF;;AAGA,SAAS,UAAU,OAAuB;CACxC,MAAM,KAAK,MAAM,QAAQ,GAAG;CAC5B,IAAI,MAAM,GAAG,OAAO;CACpB,OAAO,GAAG,MAAM,GAAG,KAAK,MAAM,MAAM,EAAE;AACxC;;;;;;;AAQA,SAAS,eAAe,YAAuB,QAA2B;CACxE,MAAM,UAAoB,CAAC;CAC3B,MAAM,gBAAgB,cAAc,CAAC,EAAA,CAClC,KAAI,OAAM,GAAG,KAAK,CAAC,CAAC,CACpB,QAAO,OAAM,kBAAkB,KAAK,EAAE,CAAC,CAAC,CACxC,KAAI,OAAO,QAAQ,KAAK,EAAE,IAAI,KAAK,IAAI,GAAG,EAAG;CAChD,IAAI,aAAa,SAAS,GACxB,QAAQ,KAAK,eAAe,aAAa,KAAK,IAAI,EAAE,EAAE;CAExD,QAAQ,KAAK,wBAAwB;CACrC,MAAM,aAAa,CAAC,GAAG,IAAI,KAAK,UAAU,CAAC,EAAA,CAAG,KAAI,UAAS,MAAM,KAAK,CAAC,CAAC,QAAQ,UAAU,EAAE,CAAC,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC;CAC/G,IAAI,WAAW,SAAS,GACtB,QAAQ,KAAK,cAAc,WAAW,KAAI,UAAS,IAAI,MAAM,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,EAAE;CAEhF,OAAO,GAAG,QAAQ,KAAK,OAAO,EAAE;AAClC;;AAGA,SAAS,qBAAqB,OAAkB,QAAkD;CAChG,MAAM,SAAS,MAAM,OAAO;CAC5B,IAAI,CAAC,QAAQ,OAAO;CACpB,IAAI,OAAO,SAAS,UAClB,OAAO,OAAO,KAAK,YAAY,MAAM,OAAO,KAAK,YAAY;CAE/D,IAAI,OAAO,cAAc,YACvB,OAAO,OAAO,gBAAgB,QAAQ,UAAU,UAAU,KAAK,OAAO,IAAI;CAE5E,OAAO,OAAO,gBAAgB,QAAQ,uBAAuB,OAAO;AACtE;;AAGA,SAAS,kBACP,aACA,QACuB;CACvB,IAAI,OAAO,SAAS,UAAU;EAC5B,MAAM,SAAS,OAAO,KAAK,YAAY;EACvC,OAAO,YAAY,MAAK,eAAc,WAAW,IAAI,KAAK,YAAY,MAAM,MAAM,KAAK;CACzF;CACA,IAAI,OAAO,cAAc,YACvB,OACE,YAAY,MACV,eAAc,WAAW,IAAI,gBAAgB,QAAQ,UAAU,UAAU,KAAK,WAAW,GAAG,IAAI,CAClG,KAAK;CAGT,MAAM,WAAW,uBAAuB,OAAO;CAC/C,OAAO,YAAY,MAAK,eAAc,WAAW,IAAI,gBAAgB,QAAQ,QAAQ,KAAK;AAC5F"}
|
|
@@ -12,6 +12,7 @@ export declare function recordPullRequestHead(storage: WorkItemsStorage, input:
|
|
|
12
12
|
id: string;
|
|
13
13
|
headSha: string;
|
|
14
14
|
headAt: number | undefined;
|
|
15
|
+
deliveryId?: string;
|
|
15
16
|
/** Only write while the card has no open pull request or has this one out. */
|
|
16
17
|
ownedPullRequestNumber?: number;
|
|
17
18
|
}): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pull-request-head.d.ts","sourceRoot":"","sources":["../../src/integrations/pull-request-head.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uCAAuC,CAAC;AAE9E;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE;IACL,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,8EAA8E;IAC9E,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACjC,GACA,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"pull-request-head.d.ts","sourceRoot":"","sources":["../../src/integrations/pull-request-head.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uCAAuC,CAAC;AAE9E;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE;IACL,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8EAA8E;IAC9E,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACjC,GACA,OAAO,CAAC,IAAI,CAAC,CAmCf"}
|
|
@@ -30,7 +30,8 @@ async function recordPullRequestHead(storage, input) {
|
|
|
30
30
|
expectedRevision: card.revision,
|
|
31
31
|
patch: { metadata: {
|
|
32
32
|
pullRequestHeadSha: input.headSha,
|
|
33
|
-
pullRequestHeadAt: input.headAt
|
|
33
|
+
pullRequestHeadAt: input.headAt,
|
|
34
|
+
pullRequestHeadDeliveryId: card.metadata?.pullRequestHeadSha === input.headSha ? card.metadata?.pullRequestHeadDeliveryId ?? null : input.deliveryId ?? null
|
|
34
35
|
} }
|
|
35
36
|
});
|
|
36
37
|
return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pull-request-head.js","names":[],"sources":["../../src/integrations/pull-request-head.ts"],"sourcesContent":["import { WorkItemUpdateConflictError } from '../storage/domains/work-items/base.js';\nimport type { WorkItemsStorage } from '../storage/domains/work-items/base.js';\n\n/**\n * Records the head a pull request or merge request was observed at, so a\n * review verdict given on an earlier head can be shown as outdated. Webhooks\n * arrive out of order and get redelivered, so a write only lands when its\n * event is newer than the one the card last recorded; an event without a\n * timestamp cannot be ordered and is dropped. The check and the write run\n * against the same revision, so a concurrent delivery cannot slip between them.\n */\nexport async function recordPullRequestHead(\n storage: WorkItemsStorage,\n input: {\n orgId: string;\n id: string;\n headSha: string;\n headAt: number | undefined;\n /** Only write while the card has no open pull request or has this one out. */\n ownedPullRequestNumber?: number;\n },\n): Promise<void> {\n if (input.headAt === undefined) return;\n for (let attempt = 0; attempt < 3; attempt++) {\n const card = await storage.get({ orgId: input.orgId, id: input.id });\n if (!card) return;\n if (input.ownedPullRequestNumber !== undefined) {\n const recorded = card.metadata?.openPullRequestNumber;\n if (typeof recorded === 'number' && recorded !== input.ownedPullRequestNumber) return;\n }\n const recordedAt = card.metadata?.pullRequestHeadAt;\n if (typeof recordedAt === 'number' && recordedAt >= input.headAt) return;\n try {\n // The time advances even when the head is unchanged: a head that returns\n // to an earlier commit must still beat a late event for the one between.\n await storage.update({\n orgId: card.orgId,\n id: card.id,\n userId: 'factory-rule-dispatcher',\n expectedRevision: card.revision,\n patch: {
|
|
1
|
+
{"version":3,"file":"pull-request-head.js","names":[],"sources":["../../src/integrations/pull-request-head.ts"],"sourcesContent":["import { WorkItemUpdateConflictError } from '../storage/domains/work-items/base.js';\nimport type { WorkItemsStorage } from '../storage/domains/work-items/base.js';\n\n/**\n * Records the head a pull request or merge request was observed at, so a\n * review verdict given on an earlier head can be shown as outdated. Webhooks\n * arrive out of order and get redelivered, so a write only lands when its\n * event is newer than the one the card last recorded; an event without a\n * timestamp cannot be ordered and is dropped. The check and the write run\n * against the same revision, so a concurrent delivery cannot slip between them.\n */\nexport async function recordPullRequestHead(\n storage: WorkItemsStorage,\n input: {\n orgId: string;\n id: string;\n headSha: string;\n headAt: number | undefined;\n deliveryId?: string;\n /** Only write while the card has no open pull request or has this one out. */\n ownedPullRequestNumber?: number;\n },\n): Promise<void> {\n if (input.headAt === undefined) return;\n for (let attempt = 0; attempt < 3; attempt++) {\n const card = await storage.get({ orgId: input.orgId, id: input.id });\n if (!card) return;\n if (input.ownedPullRequestNumber !== undefined) {\n const recorded = card.metadata?.openPullRequestNumber;\n if (typeof recorded === 'number' && recorded !== input.ownedPullRequestNumber) return;\n }\n const recordedAt = card.metadata?.pullRequestHeadAt;\n if (typeof recordedAt === 'number' && recordedAt >= input.headAt) return;\n try {\n // The time advances even when the head is unchanged: a head that returns\n // to an earlier commit must still beat a late event for the one between.\n await storage.update({\n orgId: card.orgId,\n id: card.id,\n userId: 'factory-rule-dispatcher',\n expectedRevision: card.revision,\n patch: {\n metadata: {\n pullRequestHeadSha: input.headSha,\n pullRequestHeadAt: input.headAt,\n pullRequestHeadDeliveryId:\n card.metadata?.pullRequestHeadSha === input.headSha\n ? (card.metadata?.pullRequestHeadDeliveryId ?? null)\n : (input.deliveryId ?? null),\n },\n },\n });\n return;\n } catch (error) {\n if (!(error instanceof WorkItemUpdateConflictError)) throw error;\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAWA,eAAsB,sBACpB,SACA,OASe;CACf,IAAI,MAAM,WAAW,KAAA,GAAW;CAChC,KAAK,IAAI,UAAU,GAAG,UAAU,GAAG,WAAW;EAC5C,MAAM,OAAO,MAAM,QAAQ,IAAI;GAAE,OAAO,MAAM;GAAO,IAAI,MAAM;EAAG,CAAC;EACnE,IAAI,CAAC,MAAM;EACX,IAAI,MAAM,2BAA2B,KAAA,GAAW;GAC9C,MAAM,WAAW,KAAK,UAAU;GAChC,IAAI,OAAO,aAAa,YAAY,aAAa,MAAM,wBAAwB;EACjF;EACA,MAAM,aAAa,KAAK,UAAU;EAClC,IAAI,OAAO,eAAe,YAAY,cAAc,MAAM,QAAQ;EAClE,IAAI;GAGF,MAAM,QAAQ,OAAO;IACnB,OAAO,KAAK;IACZ,IAAI,KAAK;IACT,QAAQ;IACR,kBAAkB,KAAK;IACvB,OAAO,EACL,UAAU;KACR,oBAAoB,MAAM;KAC1B,mBAAmB,MAAM;KACzB,2BACE,KAAK,UAAU,uBAAuB,MAAM,UACvC,KAAK,UAAU,6BAA6B,OAC5C,MAAM,cAAc;IAC7B,EACF;GACF,CAAC;GACD;EACF,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiB,8BAA8B,MAAM;EAC7D;CACF;AACF"}
|
|
@@ -34,4 +34,5 @@ export interface SessionLinkTarget {
|
|
|
34
34
|
* query through.
|
|
35
35
|
*/
|
|
36
36
|
export declare function sessionLink({ threadId, sessionId, resourceId, factoryProjectId, }: SessionLinkTarget): string | undefined;
|
|
37
|
+
export declare function modelSettingsLink(factoryProjectId: string): string | undefined;
|
|
37
38
|
//# sourceMappingURL=channel-reply-processor.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel-reply-processor.d.ts","sourceRoot":"","sources":["../../../src/integrations/slack/channel-reply-processor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"channel-reply-processor.d.ts","sourceRoot":"","sources":["../../../src/integrations/slack/channel-reply-processor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAQ/E,eAAO,MAAM,kBAAkB,wBAAmB,CAAC;AAanD;;;;;;;;;;;;GAYG;AACH,qBAAa,4BAA6B,YAAW,SAAS;IAC5D,QAAQ,CAAC,EAAE,2BAAM;IAEjB,gBAAgB,CAAC,EAAE,WAAW,EAAE,cAAc,EAAE,KAAK,EAAE,EAAE,oBAAoB;;;CAgC9E;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,SAAS,EACT,UAAU,EACV,gBAAgB,GACjB,EAAE,iBAAiB,GAAG,MAAM,GAAG,SAAS,CAQxC;AAED,wBAAgB,iBAAiB,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE9E"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { factorySettingsUrl, factoryUiOrigin } from "../../links.js";
|
|
1
2
|
// src/integrations/slack/channel-reply-processor.ts
|
|
2
3
|
/** Mirrors `REPLY_TOOL_NAME` in `reply-tool.ts`, which imports this module. */
|
|
3
4
|
const REPLY_TOOL = "reply_in_thread";
|
|
@@ -8,7 +9,11 @@ const SESSION_LINK_LABEL = "Open session ↗";
|
|
|
8
9
|
* `FACT-526` or `52db47cd` is a dead end for the reader. Every channel run
|
|
9
10
|
* gets this, whether it speaks in reply text or through the reply tool.
|
|
10
11
|
*/
|
|
11
|
-
const LINK_REFERENCES = [
|
|
12
|
+
const LINK_REFERENCES = [
|
|
13
|
+
"Write every pull request, issue, commit, Linear ticket or other reference you mention in the thread as a markdown link to its page, with the ID as plain link text: [#28467](https://github.com/<owner>/<repo>/pull/28467), [52db47cd](https://github.com/<owner>/<repo>/commit/52db47cd), [FACT-526](<the ticket URL>). Never leave one as a bare ID or wrap it in code formatting.",
|
|
14
|
+
"Build GitHub and GitLab links from the repository the reference belongs to, from the repositories listed in your environment or a tool result; with more than one repository, don't assume which one a bare number belongs to. Build Linear links from the workspace URL pattern in the `linear_get_issue` tool description, or from a URL a tool returned. Don't guess a URL; leave a reference unlinked only when you can't work out its URL.",
|
|
15
|
+
"Link Factory work items and sessions the same way, with the `url` the Factory tools return: [#123](<item url>). When something needs a person, ask them for it here and link the item; only send them to the web UI when the channel cannot do it, and then link the exact page."
|
|
16
|
+
].join("\n");
|
|
12
17
|
/**
|
|
13
18
|
* Tells the agent how to open a reply when the run came in over a chat
|
|
14
19
|
* channel (Slack, …): acknowledge a job in one line that names it — "On it —
|
|
@@ -55,13 +60,16 @@ var FactoryChannelReplyProcessor = class {
|
|
|
55
60
|
* query through.
|
|
56
61
|
*/
|
|
57
62
|
function sessionLink({ threadId, sessionId, resourceId, factoryProjectId }) {
|
|
58
|
-
const origin =
|
|
63
|
+
const origin = factoryUiOrigin();
|
|
59
64
|
if (!origin) return void 0;
|
|
60
65
|
const isChatOnly = resourceId.startsWith("channel:");
|
|
61
66
|
const sessionParam = `?sessionId=${encodeURIComponent(sessionId)}`;
|
|
62
67
|
if (!factoryProjectId) return `${origin}/threads/${encodeURIComponent(threadId)}${sessionParam}`;
|
|
63
68
|
return `${origin}/factories/${encodeURIComponent(factoryProjectId)}/workspaces/${isChatOnly ? "channel" : encodeURIComponent(sessionId)}/threads/${encodeURIComponent(threadId)}${isChatOnly ? sessionParam : ""}`;
|
|
64
69
|
}
|
|
65
|
-
|
|
70
|
+
function modelSettingsLink(factoryProjectId) {
|
|
71
|
+
return factorySettingsUrl(factoryProjectId, "models");
|
|
72
|
+
}
|
|
73
|
+
export { FactoryChannelReplyProcessor, SESSION_LINK_LABEL, modelSettingsLink, sessionLink };
|
|
66
74
|
|
|
67
75
|
//# sourceMappingURL=channel-reply-processor.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel-reply-processor.js","names":[],"sources":["../../../src/integrations/slack/channel-reply-processor.ts"],"sourcesContent":["import type { ProcessInputStepArgs, Processor } from '@mastra/core/processors';\n\n/** Mirrors `REPLY_TOOL_NAME` in `reply-tool.ts`, which imports this module. */\nconst REPLY_TOOL = 'reply_in_thread';\n\nconst ID = 'factory-channel-reply';\n\nexport const SESSION_LINK_LABEL = 'Open session ↗';\n\n/**\n * Chat surfaces show what the agent writes as-is, so a bare `#28467`,\n * `FACT-526` or `52db47cd` is a dead end for the reader. Every channel run\n * gets this, whether it speaks in reply text or through the reply tool.\n */\nconst LINK_REFERENCES = [\n 'Write every pull request, issue, commit, Linear ticket or other reference you mention in the thread as a markdown link to its page, with the ID as plain link text: [#28467](https://github.com/<owner>/<repo>/pull/28467), [52db47cd](https://github.com/<owner>/<repo>/commit/52db47cd), [FACT-526](<the ticket URL>). Never leave one as a bare ID or wrap it in code formatting.',\n \"Build GitHub and GitLab links from the repository the reference belongs to, from the repositories listed in your environment or a tool result; with more than one repository, don't assume which one a bare number belongs to. Build Linear links from the workspace URL pattern in the `linear_get_issue` tool description, or from a URL a tool returned. Don't guess a URL; leave a reference unlinked only when you can't work out its URL.\",\n].join('\\n');\n\n/**\n * Tells the agent how to open a reply when the run came in over a chat\n * channel (Slack, …): acknowledge a job in one line that names it — \"On it —\n * sleeping 30s, then running the echo\" — before starting, so the sender sees\n * a task-specific first message rather than a silent typing status until the\n * first tool finishes. The channel driver posts that opener on its own before\n * the tool widget, so the instruction and the rendering agree. The session\n * link is not the model's job: `FactoryChannelSessionLinkProcessor` appends it\n * to the first reply of a new session, so it is there whether or not the\n * model judged the request worth an opener. A run on a Slack-linked session\n * that didn't start from Slack gets a shorter note on when to post there; runs\n * with no channel at all (Studio, API, boards) get nothing.\n */\nexport class FactoryChannelReplyProcessor implements Processor {\n readonly id = ID;\n\n processInputStep({ messageList, requestContext, tools }: ProcessInputStepArgs) {\n if (!requestContext?.get('channel')) {\n // A run on a Slack-linked session that didn't start from a Slack message\n // (a web prompt, a woken run) still has the reply tool; core's channel\n // prompt says the thread is linked, this says how to use it.\n if (!tools || !(REPLY_TOOL in tools)) return undefined;\n messageList.addSystem(\n {\n role: 'system',\n content: `Post to that thread with \\`${REPLY_TOOL}\\`. This request came from outside it, so don't post an opener or progress there; post the outcome when the people in the thread asked for this work or are waiting on it, or when you're asked to. Don't write a note about what you posted. When you open a pull request, its link is posted to this thread for you, and again when it merges, so don't post the link yourself.\\n${LINK_REFERENCES}`,\n },\n ID,\n );\n return { messageList };\n }\n // With reply text hidden the thread only sees what the reply tool posts,\n // so the opener advice becomes advice about when to call it.\n const content =\n tools && REPLY_TOOL in tools\n ? [\n `Only messages you send with \\`${REPLY_TOOL}\\` reach this thread; your other text stays in the session. Don't write a note about what you posted. After a reply, either keep working or end your turn with an empty message; any text you write after posting is wasted.`,\n `When a request will take tools or more than a moment, post one short line first that says what you are about to do, then do the work and post the outcome when you finish — not a replay of the steps.`,\n `Post again in between only when you need an answer or have news the people in the thread would want. When a message needs nothing from you, don't call it.`,\n `When you open a pull request, its link is posted to this thread for you, and again when it merges, so don't post the link yourself.`,\n ]\n : [\n 'When a request will take tools or more than a moment to answer, reply first with one short line that says what you are about to do — \"On it — <the task in a few words>.\" — then do the work; the opener posts as its own message before any progress is shown. Give direct answers and trivial replies without an opener.',\n 'Finish with the outcome, not a replay of the steps.',\n ];\n messageList.addSystem({ role: 'system', content: [...content, LINK_REFERENCES].join('\\n') }, ID);\n return { messageList };\n }\n}\n\nexport interface SessionLinkTarget {\n threadId: string;\n sessionId: string;\n resourceId: string;\n factoryProjectId?: string;\n}\n\n/**\n * The web URL for a session, or undefined without a public origin. Same\n * routes the Factory UI navigates to: a repo-backed thread's Factory session\n * id is the workspace segment; chat-only threads keep the literal `channel`\n * segment and name their session in the query; a session with no factory\n * falls back to the factory-agnostic /threads/ redirect, which forwards the\n * query through.\n */\nexport function sessionLink({\n threadId,\n sessionId,\n resourceId,\n factoryProjectId,\n}: SessionLinkTarget): string | undefined {\n const origin =
|
|
1
|
+
{"version":3,"file":"channel-reply-processor.js","names":[],"sources":["../../../src/integrations/slack/channel-reply-processor.ts"],"sourcesContent":["import type { ProcessInputStepArgs, Processor } from '@mastra/core/processors';\nimport { factorySettingsUrl, factoryUiOrigin } from '../../links.js';\n\n/** Mirrors `REPLY_TOOL_NAME` in `reply-tool.ts`, which imports this module. */\nconst REPLY_TOOL = 'reply_in_thread';\n\nconst ID = 'factory-channel-reply';\n\nexport const SESSION_LINK_LABEL = 'Open session ↗';\n\n/**\n * Chat surfaces show what the agent writes as-is, so a bare `#28467`,\n * `FACT-526` or `52db47cd` is a dead end for the reader. Every channel run\n * gets this, whether it speaks in reply text or through the reply tool.\n */\nconst LINK_REFERENCES = [\n 'Write every pull request, issue, commit, Linear ticket or other reference you mention in the thread as a markdown link to its page, with the ID as plain link text: [#28467](https://github.com/<owner>/<repo>/pull/28467), [52db47cd](https://github.com/<owner>/<repo>/commit/52db47cd), [FACT-526](<the ticket URL>). Never leave one as a bare ID or wrap it in code formatting.',\n \"Build GitHub and GitLab links from the repository the reference belongs to, from the repositories listed in your environment or a tool result; with more than one repository, don't assume which one a bare number belongs to. Build Linear links from the workspace URL pattern in the `linear_get_issue` tool description, or from a URL a tool returned. Don't guess a URL; leave a reference unlinked only when you can't work out its URL.\",\n 'Link Factory work items and sessions the same way, with the `url` the Factory tools return: [#123](<item url>). When something needs a person, ask them for it here and link the item; only send them to the web UI when the channel cannot do it, and then link the exact page.',\n].join('\\n');\n\n/**\n * Tells the agent how to open a reply when the run came in over a chat\n * channel (Slack, …): acknowledge a job in one line that names it — \"On it —\n * sleeping 30s, then running the echo\" — before starting, so the sender sees\n * a task-specific first message rather than a silent typing status until the\n * first tool finishes. The channel driver posts that opener on its own before\n * the tool widget, so the instruction and the rendering agree. The session\n * link is not the model's job: `FactoryChannelSessionLinkProcessor` appends it\n * to the first reply of a new session, so it is there whether or not the\n * model judged the request worth an opener. A run on a Slack-linked session\n * that didn't start from Slack gets a shorter note on when to post there; runs\n * with no channel at all (Studio, API, boards) get nothing.\n */\nexport class FactoryChannelReplyProcessor implements Processor {\n readonly id = ID;\n\n processInputStep({ messageList, requestContext, tools }: ProcessInputStepArgs) {\n if (!requestContext?.get('channel')) {\n // A run on a Slack-linked session that didn't start from a Slack message\n // (a web prompt, a woken run) still has the reply tool; core's channel\n // prompt says the thread is linked, this says how to use it.\n if (!tools || !(REPLY_TOOL in tools)) return undefined;\n messageList.addSystem(\n {\n role: 'system',\n content: `Post to that thread with \\`${REPLY_TOOL}\\`. This request came from outside it, so don't post an opener or progress there; post the outcome when the people in the thread asked for this work or are waiting on it, or when you're asked to. Don't write a note about what you posted. When you open a pull request, its link is posted to this thread for you, and again when it merges, so don't post the link yourself.\\n${LINK_REFERENCES}`,\n },\n ID,\n );\n return { messageList };\n }\n // With reply text hidden the thread only sees what the reply tool posts,\n // so the opener advice becomes advice about when to call it.\n const content =\n tools && REPLY_TOOL in tools\n ? [\n `Only messages you send with \\`${REPLY_TOOL}\\` reach this thread; your other text stays in the session. Don't write a note about what you posted. After a reply, either keep working or end your turn with an empty message; any text you write after posting is wasted.`,\n `When a request will take tools or more than a moment, post one short line first that says what you are about to do, then do the work and post the outcome when you finish — not a replay of the steps.`,\n `Post again in between only when you need an answer or have news the people in the thread would want. When a message needs nothing from you, don't call it.`,\n `When you open a pull request, its link is posted to this thread for you, and again when it merges, so don't post the link yourself.`,\n ]\n : [\n 'When a request will take tools or more than a moment to answer, reply first with one short line that says what you are about to do — \"On it — <the task in a few words>.\" — then do the work; the opener posts as its own message before any progress is shown. Give direct answers and trivial replies without an opener.',\n 'Finish with the outcome, not a replay of the steps.',\n ];\n messageList.addSystem({ role: 'system', content: [...content, LINK_REFERENCES].join('\\n') }, ID);\n return { messageList };\n }\n}\n\nexport interface SessionLinkTarget {\n threadId: string;\n sessionId: string;\n resourceId: string;\n factoryProjectId?: string;\n}\n\n/**\n * The web URL for a session, or undefined without a public origin. Same\n * routes the Factory UI navigates to: a repo-backed thread's Factory session\n * id is the workspace segment; chat-only threads keep the literal `channel`\n * segment and name their session in the query; a session with no factory\n * falls back to the factory-agnostic /threads/ redirect, which forwards the\n * query through.\n */\nexport function sessionLink({\n threadId,\n sessionId,\n resourceId,\n factoryProjectId,\n}: SessionLinkTarget): string | undefined {\n const origin = factoryUiOrigin();\n if (!origin) return undefined;\n const isChatOnly = resourceId.startsWith('channel:');\n const sessionParam = `?sessionId=${encodeURIComponent(sessionId)}`;\n if (!factoryProjectId) return `${origin}/threads/${encodeURIComponent(threadId)}${sessionParam}`;\n const workspaceSegment = isChatOnly ? 'channel' : encodeURIComponent(sessionId);\n return `${origin}/factories/${encodeURIComponent(factoryProjectId)}/workspaces/${workspaceSegment}/threads/${encodeURIComponent(threadId)}${isChatOnly ? sessionParam : ''}`;\n}\n\nexport function modelSettingsLink(factoryProjectId: string): string | undefined {\n return factorySettingsUrl(factoryProjectId, 'models');\n}\n"],"mappings":";;;AAIA,MAAM,aAAa;AAEnB,MAAM,KAAK;AAEX,MAAa,qBAAqB;;;;;;AAOlC,MAAM,kBAAkB;CACtB;CACA;CACA;AACF,CAAC,CAAC,KAAK,IAAI;;;;;;;;;;;;;;AAeX,IAAa,+BAAb,MAA+D;CAC7D,KAAc;CAEd,iBAAiB,EAAE,aAAa,gBAAgB,SAA+B;EAC7E,IAAI,CAAC,gBAAgB,IAAI,SAAS,GAAG;GAInC,IAAI,CAAC,SAAS,EAAE,cAAc,QAAQ,OAAO,KAAA;GAC7C,YAAY,UACV;IACE,MAAM;IACN,SAAS,8BAA8B,WAAW,qXAAqX;GACza,GACA,EACF;GACA,OAAO,EAAE,YAAY;EACvB;EAGA,MAAM,UACJ,SAAS,cAAc,QACnB;GACE,iCAAiC,WAAW;GAC5C;GACA;GACA;EACF,IACA,CACE,gUACA,qDACF;EACN,YAAY,UAAU;GAAE,MAAM;GAAU,SAAS,CAAC,GAAG,SAAS,eAAe,CAAC,CAAC,KAAK,IAAI;EAAE,GAAG,EAAE;EAC/F,OAAO,EAAE,YAAY;CACvB;AACF;;;;;;;;;AAiBA,SAAgB,YAAY,EAC1B,UACA,WACA,YACA,oBACwC;CACxC,MAAM,SAAS,gBAAgB;CAC/B,IAAI,CAAC,QAAQ,OAAO,KAAA;CACpB,MAAM,aAAa,WAAW,WAAW,UAAU;CACnD,MAAM,eAAe,cAAc,mBAAmB,SAAS;CAC/D,IAAI,CAAC,kBAAkB,OAAO,GAAG,OAAO,WAAW,mBAAmB,QAAQ,IAAI;CAElF,OAAO,GAAG,OAAO,aAAa,mBAAmB,gBAAgB,EAAE,cAD1C,aAAa,YAAY,mBAAmB,SAAS,EACoB,WAAW,mBAAmB,QAAQ,IAAI,aAAa,eAAe;AAC1K;AAEA,SAAgB,kBAAkB,kBAA8C;CAC9E,OAAO,mBAAmB,kBAAkB,QAAQ;AACtD"}
|
|
@@ -36,4 +36,5 @@ export declare class FactoryChannelSessionLinkProcessor implements Processor {
|
|
|
36
36
|
processOutputStream({ part, state, requestContext }: ProcessOutputStreamArgs): Promise<ChunkType>;
|
|
37
37
|
processOutputStep({ messageList, requestContext }: ProcessOutputStepArgs): import("@mastra/core/agent").MessageList;
|
|
38
38
|
}
|
|
39
|
+
export declare function sessionLinkFromRequest(requestContext: RequestContext): string | undefined;
|
|
39
40
|
//# sourceMappingURL=channel-session-link-processor.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel-session-link-processor.d.ts","sourceRoot":"","sources":["../../../src/integrations/slack/channel-session-link-processor.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACzG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAKrD;;;GAGG;AACH,eAAO,MAAM,uBAAuB,+BAA+B,CAAC;AAEpE,wBAAgB,qBAAqB,CAAC,cAAc,EAAE,cAAc,GAAG,IAAI,CAE1E;AAaD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,kCAAmC,YAAW,SAAS;IAClE,QAAQ,CAAC,EAAE,kCAAkC;IAEvC,mBAAmB,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,uBAAuB,GAAG,OAAO,CAAC,SAAS,CAAC;IAmBvG,iBAAiB,CAAC,EAAE,WAAW,EAAE,cAAc,EAAE,EAAE,qBAAqB;CAiBzE"}
|
|
1
|
+
{"version":3,"file":"channel-session-link-processor.d.ts","sourceRoot":"","sources":["../../../src/integrations/slack/channel-session-link-processor.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACzG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAKrD;;;GAGG;AACH,eAAO,MAAM,uBAAuB,+BAA+B,CAAC;AAEpE,wBAAgB,qBAAqB,CAAC,cAAc,EAAE,cAAc,GAAG,IAAI,CAE1E;AAaD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,kCAAmC,YAAW,SAAS;IAClE,QAAQ,CAAC,EAAE,kCAAkC;IAEvC,mBAAmB,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,uBAAuB,GAAG,OAAO,CAAC,SAAS,CAAC;IAmBvG,iBAAiB,CAAC,EAAE,WAAW,EAAE,cAAc,EAAE,EAAE,qBAAqB;CAiBzE;AAiBD,wBAAgB,sBAAsB,CAAC,cAAc,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CASzF"}
|
|
@@ -103,6 +103,6 @@ function sessionLinkFromRequest(requestContext) {
|
|
|
103
103
|
factoryProjectId: readFactorySessionScope(requestContext)?.factoryProjectId
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
|
-
export { FactoryChannelSessionLinkProcessor, NEW_CHANNEL_SESSION_KEY, markNewChannelSession };
|
|
106
|
+
export { FactoryChannelSessionLinkProcessor, NEW_CHANNEL_SESSION_KEY, markNewChannelSession, sessionLinkFromRequest };
|
|
107
107
|
|
|
108
108
|
//# sourceMappingURL=channel-session-link-processor.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel-session-link-processor.js","names":[],"sources":["../../../src/integrations/slack/channel-session-link-processor.ts"],"sourcesContent":["import type { AgentControllerRequestContext } from '@mastra/core/agent-controller';\nimport { CHAT_CHANNEL_RENDER_CONTEXT_KEY } from '@mastra/core/channels';\nimport type { ChatChannelRenderContext } from '@mastra/core/channels';\nimport type { Processor, ProcessOutputStepArgs, ProcessOutputStreamArgs } from '@mastra/core/processors';\nimport type { RequestContext } from '@mastra/core/request-context';\nimport type { ChunkType } from '@mastra/core/stream';\n\nimport { readFactorySessionScope } from '../../rules/binding-context.js';\nimport { SESSION_LINK_LABEL, sessionLink } from './channel-reply-processor.js';\n\n/**\n * Request-context marker a channel handler sets on the run that opens a new\n * thread. Only that run gets the link; follow-ups in the same thread don't.\n */\nexport const NEW_CHANNEL_SESSION_KEY = 'factory__newChannelSession';\n\nexport function markNewChannelSession(requestContext: RequestContext): void {\n requestContext.set(NEW_CHANNEL_SESSION_KEY, true);\n}\n\n/**\n * State key the core ProcessorRunner reads to re-drive one extra part through\n * the output chain after the part a processor returned. Internal to core\n * (`processors/stream-reprocess.ts`, used by `BatchPartsProcessor`); mirrored\n * here rather than exported so Factory's one use doesn't widen core's API.\n */\nconst REPROCESS_PART_KEY = '__mastraReprocessPart';\n\n/** Text-part id of the link delta; never collides with a model block id. */\nconst LINK_PART_ID = 'factory-session-link';\n\n/**\n * Emits an `Open session ↗` link right after the first reply of a new channel\n * session, so the link closes the reply rather than leading it. The channel\n * driver ends a post at `text-end`, so Slack shows the link as its own short\n * message after the reply.\n *\n * \"End of the reply\" is not `text-end`: a reply with citations is several\n * text blocks (`text … source … text`), each with its own `text-end`. The\n * reply ends at the first `tool-call` (the opener before tools run) or at\n * `step-finish` (a reply with no tools), whichever comes first after some\n * text. A processor can only return one part, so on that chunk this returns\n * a `text-delta` carrying the link and stashes the chunk itself under\n * `REPROCESS_PART_KEY`, which the runner re-drives through the chain right\n * after — the link lands before it and downstream processors see both.\n * Factory-emitted rather than left to the model so a skipped opener can't\n * drop it. No-op without the new-session marker or a public origin.\n *\n * The streamed text is also what gets persisted, and the link is a Slack\n * display detail: in memory it would show the model its own `[↗](…)` on\n * later turns (inviting it to fabricate links) and render a self-link in\n * the web transcript. The link delta opens its own text part, so\n * `processOutputStep` drops that part from the assistant message before\n * `MessageHistory` saves it.\n */\nexport class FactoryChannelSessionLinkProcessor implements Processor {\n readonly id = 'factory-channel-session-link';\n\n async processOutputStream({ part, state, requestContext }: ProcessOutputStreamArgs): Promise<ChunkType> {\n if (state.linked || !linksThisRun(requestContext)) return part;\n if (part.type === 'text-delta') {\n state.textStarted = true;\n return part;\n }\n if ((part.type !== 'tool-call' && part.type !== 'step-finish') || !state.textStarted) return part;\n state.linked = true;\n const link = sessionLinkFromRequest(requestContext);\n if (!link) return part;\n state[REPROCESS_PART_KEY] = part;\n return {\n type: 'text-delta',\n runId: part.runId,\n from: part.from,\n payload: { id: LINK_PART_ID, text: linkSuffix(link) },\n };\n }\n\n processOutputStep({ messageList, requestContext }: ProcessOutputStepArgs) {\n if (!linksThisRun(requestContext)) return messageList;\n const link = sessionLinkFromRequest(requestContext);\n if (!link) return messageList;\n const suffix = linkSuffix(link);\n for (const message of messageList.get.response.db()) {\n if (message.role !== 'assistant') continue;\n const index = message.content.parts.findIndex(part => part.type === 'text' && part.text === suffix);\n if (index === -1) continue;\n message.content.parts.splice(index, 1);\n if (typeof message.content.content === 'string' && message.content.content.endsWith(suffix)) {\n message.content.content = message.content.content.slice(0, -suffix.length);\n }\n return messageList;\n }\n return messageList;\n }\n}\n\n/**\n * With reply text hidden the reply text never reaches the thread, and\n * `reply_in_thread` appends the link to its own first post instead.\n */\nfunction linksThisRun(requestContext: RequestContext | undefined): requestContext is RequestContext {\n if (!requestContext?.get(NEW_CHANNEL_SESSION_KEY)) return false;\n const render = requestContext.get(CHAT_CHANNEL_RENDER_CONTEXT_KEY) as ChatChannelRenderContext | undefined;\n return render?.textDisplay !== 'hidden';\n}\n\nfunction linkSuffix(link: string): string {\n // Own paragraph: the link closes the message, it is not part of the last sentence.\n return `\\n\\n[${SESSION_LINK_LABEL}](${link})`;\n}\n\
|
|
1
|
+
{"version":3,"file":"channel-session-link-processor.js","names":[],"sources":["../../../src/integrations/slack/channel-session-link-processor.ts"],"sourcesContent":["import type { AgentControllerRequestContext } from '@mastra/core/agent-controller';\nimport { CHAT_CHANNEL_RENDER_CONTEXT_KEY } from '@mastra/core/channels';\nimport type { ChatChannelRenderContext } from '@mastra/core/channels';\nimport type { Processor, ProcessOutputStepArgs, ProcessOutputStreamArgs } from '@mastra/core/processors';\nimport type { RequestContext } from '@mastra/core/request-context';\nimport type { ChunkType } from '@mastra/core/stream';\n\nimport { readFactorySessionScope } from '../../rules/binding-context.js';\nimport { SESSION_LINK_LABEL, sessionLink } from './channel-reply-processor.js';\n\n/**\n * Request-context marker a channel handler sets on the run that opens a new\n * thread. Only that run gets the link; follow-ups in the same thread don't.\n */\nexport const NEW_CHANNEL_SESSION_KEY = 'factory__newChannelSession';\n\nexport function markNewChannelSession(requestContext: RequestContext): void {\n requestContext.set(NEW_CHANNEL_SESSION_KEY, true);\n}\n\n/**\n * State key the core ProcessorRunner reads to re-drive one extra part through\n * the output chain after the part a processor returned. Internal to core\n * (`processors/stream-reprocess.ts`, used by `BatchPartsProcessor`); mirrored\n * here rather than exported so Factory's one use doesn't widen core's API.\n */\nconst REPROCESS_PART_KEY = '__mastraReprocessPart';\n\n/** Text-part id of the link delta; never collides with a model block id. */\nconst LINK_PART_ID = 'factory-session-link';\n\n/**\n * Emits an `Open session ↗` link right after the first reply of a new channel\n * session, so the link closes the reply rather than leading it. The channel\n * driver ends a post at `text-end`, so Slack shows the link as its own short\n * message after the reply.\n *\n * \"End of the reply\" is not `text-end`: a reply with citations is several\n * text blocks (`text … source … text`), each with its own `text-end`. The\n * reply ends at the first `tool-call` (the opener before tools run) or at\n * `step-finish` (a reply with no tools), whichever comes first after some\n * text. A processor can only return one part, so on that chunk this returns\n * a `text-delta` carrying the link and stashes the chunk itself under\n * `REPROCESS_PART_KEY`, which the runner re-drives through the chain right\n * after — the link lands before it and downstream processors see both.\n * Factory-emitted rather than left to the model so a skipped opener can't\n * drop it. No-op without the new-session marker or a public origin.\n *\n * The streamed text is also what gets persisted, and the link is a Slack\n * display detail: in memory it would show the model its own `[↗](…)` on\n * later turns (inviting it to fabricate links) and render a self-link in\n * the web transcript. The link delta opens its own text part, so\n * `processOutputStep` drops that part from the assistant message before\n * `MessageHistory` saves it.\n */\nexport class FactoryChannelSessionLinkProcessor implements Processor {\n readonly id = 'factory-channel-session-link';\n\n async processOutputStream({ part, state, requestContext }: ProcessOutputStreamArgs): Promise<ChunkType> {\n if (state.linked || !linksThisRun(requestContext)) return part;\n if (part.type === 'text-delta') {\n state.textStarted = true;\n return part;\n }\n if ((part.type !== 'tool-call' && part.type !== 'step-finish') || !state.textStarted) return part;\n state.linked = true;\n const link = sessionLinkFromRequest(requestContext);\n if (!link) return part;\n state[REPROCESS_PART_KEY] = part;\n return {\n type: 'text-delta',\n runId: part.runId,\n from: part.from,\n payload: { id: LINK_PART_ID, text: linkSuffix(link) },\n };\n }\n\n processOutputStep({ messageList, requestContext }: ProcessOutputStepArgs) {\n if (!linksThisRun(requestContext)) return messageList;\n const link = sessionLinkFromRequest(requestContext);\n if (!link) return messageList;\n const suffix = linkSuffix(link);\n for (const message of messageList.get.response.db()) {\n if (message.role !== 'assistant') continue;\n const index = message.content.parts.findIndex(part => part.type === 'text' && part.text === suffix);\n if (index === -1) continue;\n message.content.parts.splice(index, 1);\n if (typeof message.content.content === 'string' && message.content.content.endsWith(suffix)) {\n message.content.content = message.content.content.slice(0, -suffix.length);\n }\n return messageList;\n }\n return messageList;\n }\n}\n\n/**\n * With reply text hidden the reply text never reaches the thread, and\n * `reply_in_thread` appends the link to its own first post instead.\n */\nfunction linksThisRun(requestContext: RequestContext | undefined): requestContext is RequestContext {\n if (!requestContext?.get(NEW_CHANNEL_SESSION_KEY)) return false;\n const render = requestContext.get(CHAT_CHANNEL_RENDER_CONTEXT_KEY) as ChatChannelRenderContext | undefined;\n return render?.textDisplay !== 'hidden';\n}\n\nfunction linkSuffix(link: string): string {\n // Own paragraph: the link closes the message, it is not part of the last sentence.\n return `\\n\\n[${SESSION_LINK_LABEL}](${link})`;\n}\n\nexport function sessionLinkFromRequest(requestContext: RequestContext): string | undefined {\n const controller = requestContext.get('controller') as AgentControllerRequestContext | undefined;\n if (!controller?.threadId) return undefined;\n return sessionLink({\n threadId: controller.threadId,\n sessionId: controller.sessionId,\n resourceId: controller.resourceId,\n factoryProjectId: readFactorySessionScope(requestContext)?.factoryProjectId,\n });\n}\n"],"mappings":";;;;;;;;AAcA,MAAa,0BAA0B;AAEvC,SAAgB,sBAAsB,gBAAsC;CAC1E,eAAe,IAAI,yBAAyB,IAAI;AAClD;;;;;;;AAQA,MAAM,qBAAqB;;AAG3B,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;AA0BrB,IAAa,qCAAb,MAAqE;CACnE,KAAc;CAEd,MAAM,oBAAoB,EAAE,MAAM,OAAO,kBAA+D;EACtG,IAAI,MAAM,UAAU,CAAC,aAAa,cAAc,GAAG,OAAO;EAC1D,IAAI,KAAK,SAAS,cAAc;GAC9B,MAAM,cAAc;GACpB,OAAO;EACT;EACA,IAAK,KAAK,SAAS,eAAe,KAAK,SAAS,iBAAkB,CAAC,MAAM,aAAa,OAAO;EAC7F,MAAM,SAAS;EACf,MAAM,OAAO,uBAAuB,cAAc;EAClD,IAAI,CAAC,MAAM,OAAO;EAClB,MAAM,sBAAsB;EAC5B,OAAO;GACL,MAAM;GACN,OAAO,KAAK;GACZ,MAAM,KAAK;GACX,SAAS;IAAE,IAAI;IAAc,MAAM,WAAW,IAAI;GAAE;EACtD;CACF;CAEA,kBAAkB,EAAE,aAAa,kBAAyC;EACxE,IAAI,CAAC,aAAa,cAAc,GAAG,OAAO;EAC1C,MAAM,OAAO,uBAAuB,cAAc;EAClD,IAAI,CAAC,MAAM,OAAO;EAClB,MAAM,SAAS,WAAW,IAAI;EAC9B,KAAK,MAAM,WAAW,YAAY,IAAI,SAAS,GAAG,GAAG;GACnD,IAAI,QAAQ,SAAS,aAAa;GAClC,MAAM,QAAQ,QAAQ,QAAQ,MAAM,WAAU,SAAQ,KAAK,SAAS,UAAU,KAAK,SAAS,MAAM;GAClG,IAAI,UAAU,IAAI;GAClB,QAAQ,QAAQ,MAAM,OAAO,OAAO,CAAC;GACrC,IAAI,OAAO,QAAQ,QAAQ,YAAY,YAAY,QAAQ,QAAQ,QAAQ,SAAS,MAAM,GACxF,QAAQ,QAAQ,UAAU,QAAQ,QAAQ,QAAQ,MAAM,GAAG,CAAC,OAAO,MAAM;GAE3E,OAAO;EACT;EACA,OAAO;CACT;AACF;;;;;AAMA,SAAS,aAAa,gBAA8E;CAClG,IAAI,CAAC,gBAAgB,IAAA,4BAA2B,GAAG,OAAO;CAE1D,OADe,eAAe,IAAI,+BACtB,CAAC,EAAE,gBAAgB;AACjC;AAEA,SAAS,WAAW,MAAsB;CAExC,OAAO,QAAQ,mBAAmB,IAAI,KAAK;AAC7C;AAEA,SAAgB,uBAAuB,gBAAoD;CACzF,MAAM,aAAa,eAAe,IAAI,YAAY;CAClD,IAAI,CAAC,YAAY,UAAU,OAAO,KAAA;CAClC,OAAO,YAAY;EACjB,UAAU,WAAW;EACrB,WAAW,WAAW;EACtB,YAAY,WAAW;EACvB,kBAAkB,wBAAwB,cAAc,CAAC,EAAE;CAC7D,CAAC;AACH"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reply-tool.d.ts","sourceRoot":"","sources":["../../../src/integrations/slack/reply-tool.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"reply-tool.d.ts","sourceRoot":"","sources":["../../../src/integrations/slack/reply-tool.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAClF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAIrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAInD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAE9D,eAAO,MAAM,eAAe,oBAAoB,CAAC;AAWjD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,cAAc,EAAE,cAAc,EAAE,IAAI,GAAE,iBAAsB,GAAG,gBAAgB,CAyB9G;AAuFD;;;;;;GAMG;AACH,qBAAa,2BAA4B,YAAW,SAAS;IAG/C,OAAO,CAAC,QAAQ,CAAC,IAAI;IAFjC,QAAQ,CAAC,EAAE,0BAA0B;gBAER,IAAI,GAAE,iBAAsB;IAEnD,mBAAmB,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,uBAAuB,GAAG,OAAO,CAAC,SAAS,CAAC;CAyBxG;AAmBD,kGAAkG;AAClG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAwBlF"}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import "./channel-session-link-processor.js";
|
|
1
|
+
import { SESSION_LINK_LABEL } from "./channel-reply-processor.js";
|
|
2
|
+
import { sessionLinkFromRequest } from "./channel-session-link-processor.js";
|
|
4
3
|
import { postTarget, resolveRenderContext } from "./share-file-tool.js";
|
|
5
4
|
import "@mastra/core/channels";
|
|
6
5
|
import { z } from "zod";
|
|
@@ -42,14 +41,7 @@ function createReplyTool(requestContext, deps = {}) {
|
|
|
42
41
|
*/
|
|
43
42
|
function firstReplyLink(requestContext) {
|
|
44
43
|
if (!requestContext.get("factory__newChannelSession") || requestContext.get(LINKED_KEY)) return void 0;
|
|
45
|
-
|
|
46
|
-
if (!controller?.threadId) return void 0;
|
|
47
|
-
return sessionLink({
|
|
48
|
-
threadId: controller.threadId,
|
|
49
|
-
sessionId: controller.sessionId,
|
|
50
|
-
resourceId: controller.resourceId,
|
|
51
|
-
factoryProjectId: readFactorySessionScope(requestContext)?.factoryProjectId
|
|
52
|
-
});
|
|
44
|
+
return sessionLinkFromRequest(requestContext);
|
|
53
45
|
}
|
|
54
46
|
/** Live posts of `reply_in_thread` calls whose text is still streaming in, by tool call id. */
|
|
55
47
|
const STREAMS_KEY = "factory__replyStreams";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reply-tool.js","names":[],"sources":["../../../src/integrations/slack/reply-tool.ts"],"sourcesContent":["import type { AgentControllerRequestContext } from '@mastra/core/agent-controller';\nimport { CHAT_CHANNEL_RENDER_CONTEXT_KEY } from '@mastra/core/channels';\nimport type { ChatChannelRenderContext } from '@mastra/core/channels';\nimport type { Processor, ProcessOutputStreamArgs } from '@mastra/core/processors';\nimport type { RequestContext } from '@mastra/core/request-context';\nimport type { ChunkType } from '@mastra/core/stream';\nimport { createTool } from '@mastra/core/tools';\nimport { z } from 'zod';\n\nimport { readFactorySessionScope } from '../../rules/binding-context.js';\nimport type { IntegrationTools } from '../base.js';\nimport { SESSION_LINK_LABEL, sessionLink } from './channel-reply-processor.js';\nimport { NEW_CHANNEL_SESSION_KEY } from './channel-session-link-processor.js';\nimport { postTarget, resolveRenderContext } from './share-file-tool.js';\nimport type { ShareFileToolDeps } from './share-file-tool.js';\n\nexport const REPLY_TOOL_NAME = 'reply_in_thread';\n\n/** Set once the first reply of a new session carried the session link. */\nconst LINKED_KEY = 'factory__replyLinked';\n\nconst replyInput = z\n .object({\n text: z.string().trim().min(1).max(12_000).describe('The message to post, in markdown.'),\n })\n .strict();\n\n/**\n * The only way a channel run speaks when the adapter hides reply text\n * (`textDisplay: 'hidden'`). Posting becomes a choice the agent makes, so it\n * stops narrating each step into the thread and can say nothing on purpose.\n * Offered only when the Slack adapter hides reply text and the run's thread is\n * linked to Slack; with text shown the reply stream already posts and this\n * tool would double every message.\n */\nexport function createReplyTool(requestContext: RequestContext, deps: ShareFileToolDeps = {}): IntegrationTools {\n return {\n [REPLY_TOOL_NAME]: createTool({\n id: REPLY_TOOL_NAME,\n description:\n 'Post a message in this chat thread. Nothing you write outside this tool reaches the thread, so use it for anything the people there should see: a short note that you are starting a longer job, a question you need answered, and the outcome when you finish. Post when you have something new; do not repeat yourself or post that nothing changed. To stay quiet, do not call it.',\n inputSchema: replyInput,\n execute: async (input, context) => {\n const link = firstReplyLink(requestContext);\n const suffix = link ? `\\n\\n[${SESSION_LINK_LABEL}](${link})` : '';\n // FactoryReplyStreamProcessor may already be streaming this call's text\n // into the thread; finish that post rather than posting it again.\n const toolCallId = context?.agent?.toolCallId;\n const streamed = toolCallId ? replyStreams(requestContext).get(toolCallId) : undefined;\n if (toolCallId) replyStreams(requestContext).delete(toolCallId);\n if (!streamed || !(await streamed.finish(input.text, suffix))) {\n const render = await postTarget(requestContext, deps);\n await render.chatThread.post({ markdown: `${input.text}${suffix}` });\n }\n // Marked only once posted, so a failed first post still gets the link on the retry.\n if (link) requestContext.set(LINKED_KEY, true);\n return { posted: true };\n },\n }),\n };\n}\n\n/**\n * The session link the first reply of a new session ends with. With reply\n * text hidden, `FactoryChannelSessionLinkProcessor`'s text delta never\n * reaches the thread, so the tool carries the link instead.\n */\nfunction firstReplyLink(requestContext: RequestContext): string | undefined {\n if (!requestContext.get(NEW_CHANNEL_SESSION_KEY) || requestContext.get(LINKED_KEY)) return undefined;\n const controller = requestContext.get('controller') as AgentControllerRequestContext | undefined;\n if (!controller?.threadId) return undefined;\n return sessionLink({\n threadId: controller.threadId,\n sessionId: controller.sessionId,\n resourceId: controller.resourceId,\n factoryProjectId: readFactorySessionScope(requestContext)?.factoryProjectId,\n });\n}\n\n/** Live posts of `reply_in_thread` calls whose text is still streaming in, by tool call id. */\nconst STREAMS_KEY = 'factory__replyStreams';\n\ninterface ReplyStream {\n /** Show the text decoded from the args so far. */\n update(text: string): void;\n /** End the post with the validated text. Resolves false if the post failed. */\n finish(text: string, suffix: string): Promise<boolean>;\n /** End the post with whatever has been shown, for a call that never ran. */\n close(): Promise<boolean>;\n}\n\nfunction replyStreams(requestContext: RequestContext): Map<string, ReplyStream> {\n let streams = requestContext.get(STREAMS_KEY) as Map<string, ReplyStream> | undefined;\n if (!streams) {\n streams = new Map();\n requestContext.set(STREAMS_KEY, streams);\n }\n return streams;\n}\n\n/**\n * One streamed Slack post, opened on the first visible text so an abandoned\n * call never leaves an empty message behind.\n */\nfunction openReplyStream(chatThread: ChatChannelRenderContext['chatThread']): ReplyStream {\n const queue: string[] = [];\n let sent = '';\n let closed = false;\n let wake: (() => void) | undefined;\n let done: Promise<boolean> | undefined;\n\n async function* chunks(): AsyncGenerator<string> {\n while (true) {\n while (queue.length > 0) yield queue.shift()!;\n if (closed) return;\n await new Promise<void>(resolve => (wake = resolve));\n }\n }\n const push = (text: string) => {\n if (!text) return;\n queue.push(text);\n done ??= chatThread.post(chunks()).then(\n () => true,\n error => {\n console.warn('[factory] streamed reply failed; posting it whole', { error });\n return false;\n },\n );\n wake?.();\n };\n\n return {\n update(text) {\n const visible = text.trimStart();\n if (!visible.startsWith(sent)) return;\n push(visible.slice(sent.length));\n sent = visible;\n },\n async finish(text, suffix) {\n if (text.startsWith(sent)) push(text.slice(sent.length));\n if (!done) return false;\n push(suffix);\n closed = true;\n wake?.();\n return done;\n },\n async close() {\n closed = true;\n wake?.();\n return done ?? false;\n },\n };\n}\n\n/**\n * Streams a `reply_in_thread` call's `text` into the thread while the model is\n * still writing it, so a long reply appears as it is written instead of all at\n * once when the call completes. The tool finishes the post with the validated\n * text. Runs whose adapter doesn't stream, or that show reply text, are left\n * alone.\n */\nexport class FactoryReplyStreamProcessor implements Processor {\n readonly id = 'factory-reply-stream';\n\n constructor(private readonly deps: ShareFileToolDeps = {}) {}\n\n async processOutputStream({ part, state, requestContext }: ProcessOutputStreamArgs): Promise<ChunkType> {\n if (!requestContext) return part;\n const args = (state.args ??= {}) as Record<string, string>;\n if (part.type === 'tool-call-input-streaming-start' && part.payload.toolName === REPLY_TOOL_NAME) {\n // Resolve the thread the same way the tool does, so a run started from\n // the web UI or woken by a signal streams too, not only a Slack-started one.\n const render = await resolveRenderContext(requestContext, this.deps);\n // A static adapter (`streaming: false`) posts each reply whole, so the tool does too.\n if (render?.textDisplay !== 'hidden' || !render.streaming?.enabled) return part;\n args[part.payload.toolCallId] = '';\n replyStreams(requestContext).set(part.payload.toolCallId, openReplyStream(render.chatThread));\n } else if (part.type === 'tool-call-delta' && part.payload.toolCallId in args) {\n const json = (args[part.payload.toolCallId] += part.payload.argsTextDelta);\n const text = partialStringField(json, 'text');\n if (text !== undefined) replyStreams(requestContext).get(part.payload.toolCallId)?.update(text);\n } else if ((part.type === 'tool-error' || part.type === 'tool-result') && part.payload.toolCallId in args) {\n // A call that failed validation never reaches `execute`; a successful one already finished its post.\n delete args[part.payload.toolCallId];\n await closeStream(requestContext, part.payload.toolCallId);\n } else if (RUN_END.has(part.type)) {\n for (const toolCallId of Object.keys(args)) await closeStream(requestContext, toolCallId);\n state.args = {};\n }\n return part;\n }\n}\n\n/** Chunks after which no `reply_in_thread` call of this run will still execute. */\nconst RUN_END = new Set<string>(['finish', 'abort', 'error', 'tripwire']);\n\n/**\n * Ends a streamed post whose call will never reach `execute`, keeping the text\n * shown so far, so Slack isn't left with a message that never finishes.\n */\nasync function closeStream(requestContext: RequestContext, toolCallId: string): Promise<void> {\n const streams = replyStreams(requestContext);\n const stream = streams.get(toolCallId);\n if (!stream) return;\n streams.delete(toolCallId);\n await stream.close();\n}\n\nconst ESCAPES: Record<string, string> = { '\"': '\"', '\\\\': '\\\\', '/': '/', b: '\\b', f: '\\f', n: '\\n', r: '\\r', t: '\\t' };\n\n/** The decoded value of a top-level string field in JSON that may still be cut off mid-string. */\nexport function partialStringField(json: string, field: string): string | undefined {\n const start = new RegExp(`\"${field}\"\\\\s*:\\\\s*\"`).exec(json);\n if (!start) return undefined;\n let out = '';\n for (let i = start.index + start[0].length; i < json.length; i++) {\n const ch = json[i]!;\n if (ch === '\"') return out;\n if (ch !== '\\\\') {\n out += ch;\n continue;\n }\n const next = json[i + 1];\n if (next === undefined) return out;\n if (next === 'u') {\n const hex = json.slice(i + 2, i + 6);\n if (hex.length < 4) return out;\n out += String.fromCharCode(parseInt(hex, 16));\n i += 5;\n } else {\n out += ESCAPES[next] ?? next;\n i += 1;\n }\n }\n return out;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"reply-tool.js","names":[],"sources":["../../../src/integrations/slack/reply-tool.ts"],"sourcesContent":["import { CHAT_CHANNEL_RENDER_CONTEXT_KEY } from '@mastra/core/channels';\nimport type { ChatChannelRenderContext } from '@mastra/core/channels';\nimport type { Processor, ProcessOutputStreamArgs } from '@mastra/core/processors';\nimport type { RequestContext } from '@mastra/core/request-context';\nimport type { ChunkType } from '@mastra/core/stream';\nimport { createTool } from '@mastra/core/tools';\nimport { z } from 'zod';\n\nimport type { IntegrationTools } from '../base.js';\nimport { SESSION_LINK_LABEL } from './channel-reply-processor.js';\nimport { NEW_CHANNEL_SESSION_KEY, sessionLinkFromRequest } from './channel-session-link-processor.js';\nimport { postTarget, resolveRenderContext } from './share-file-tool.js';\nimport type { ShareFileToolDeps } from './share-file-tool.js';\n\nexport const REPLY_TOOL_NAME = 'reply_in_thread';\n\n/** Set once the first reply of a new session carried the session link. */\nconst LINKED_KEY = 'factory__replyLinked';\n\nconst replyInput = z\n .object({\n text: z.string().trim().min(1).max(12_000).describe('The message to post, in markdown.'),\n })\n .strict();\n\n/**\n * The only way a channel run speaks when the adapter hides reply text\n * (`textDisplay: 'hidden'`). Posting becomes a choice the agent makes, so it\n * stops narrating each step into the thread and can say nothing on purpose.\n * Offered only when the Slack adapter hides reply text and the run's thread is\n * linked to Slack; with text shown the reply stream already posts and this\n * tool would double every message.\n */\nexport function createReplyTool(requestContext: RequestContext, deps: ShareFileToolDeps = {}): IntegrationTools {\n return {\n [REPLY_TOOL_NAME]: createTool({\n id: REPLY_TOOL_NAME,\n description:\n 'Post a message in this chat thread. Nothing you write outside this tool reaches the thread, so use it for anything the people there should see: a short note that you are starting a longer job, a question you need answered, and the outcome when you finish. Post when you have something new; do not repeat yourself or post that nothing changed. To stay quiet, do not call it.',\n inputSchema: replyInput,\n execute: async (input, context) => {\n const link = firstReplyLink(requestContext);\n const suffix = link ? `\\n\\n[${SESSION_LINK_LABEL}](${link})` : '';\n // FactoryReplyStreamProcessor may already be streaming this call's text\n // into the thread; finish that post rather than posting it again.\n const toolCallId = context?.agent?.toolCallId;\n const streamed = toolCallId ? replyStreams(requestContext).get(toolCallId) : undefined;\n if (toolCallId) replyStreams(requestContext).delete(toolCallId);\n if (!streamed || !(await streamed.finish(input.text, suffix))) {\n const render = await postTarget(requestContext, deps);\n await render.chatThread.post({ markdown: `${input.text}${suffix}` });\n }\n // Marked only once posted, so a failed first post still gets the link on the retry.\n if (link) requestContext.set(LINKED_KEY, true);\n return { posted: true };\n },\n }),\n };\n}\n\n/**\n * The session link the first reply of a new session ends with. With reply\n * text hidden, `FactoryChannelSessionLinkProcessor`'s text delta never\n * reaches the thread, so the tool carries the link instead.\n */\nfunction firstReplyLink(requestContext: RequestContext): string | undefined {\n if (!requestContext.get(NEW_CHANNEL_SESSION_KEY) || requestContext.get(LINKED_KEY)) return undefined;\n return sessionLinkFromRequest(requestContext);\n}\n\n/** Live posts of `reply_in_thread` calls whose text is still streaming in, by tool call id. */\nconst STREAMS_KEY = 'factory__replyStreams';\n\ninterface ReplyStream {\n /** Show the text decoded from the args so far. */\n update(text: string): void;\n /** End the post with the validated text. Resolves false if the post failed. */\n finish(text: string, suffix: string): Promise<boolean>;\n /** End the post with whatever has been shown, for a call that never ran. */\n close(): Promise<boolean>;\n}\n\nfunction replyStreams(requestContext: RequestContext): Map<string, ReplyStream> {\n let streams = requestContext.get(STREAMS_KEY) as Map<string, ReplyStream> | undefined;\n if (!streams) {\n streams = new Map();\n requestContext.set(STREAMS_KEY, streams);\n }\n return streams;\n}\n\n/**\n * One streamed Slack post, opened on the first visible text so an abandoned\n * call never leaves an empty message behind.\n */\nfunction openReplyStream(chatThread: ChatChannelRenderContext['chatThread']): ReplyStream {\n const queue: string[] = [];\n let sent = '';\n let closed = false;\n let wake: (() => void) | undefined;\n let done: Promise<boolean> | undefined;\n\n async function* chunks(): AsyncGenerator<string> {\n while (true) {\n while (queue.length > 0) yield queue.shift()!;\n if (closed) return;\n await new Promise<void>(resolve => (wake = resolve));\n }\n }\n const push = (text: string) => {\n if (!text) return;\n queue.push(text);\n done ??= chatThread.post(chunks()).then(\n () => true,\n error => {\n console.warn('[factory] streamed reply failed; posting it whole', { error });\n return false;\n },\n );\n wake?.();\n };\n\n return {\n update(text) {\n const visible = text.trimStart();\n if (!visible.startsWith(sent)) return;\n push(visible.slice(sent.length));\n sent = visible;\n },\n async finish(text, suffix) {\n if (text.startsWith(sent)) push(text.slice(sent.length));\n if (!done) return false;\n push(suffix);\n closed = true;\n wake?.();\n return done;\n },\n async close() {\n closed = true;\n wake?.();\n return done ?? false;\n },\n };\n}\n\n/**\n * Streams a `reply_in_thread` call's `text` into the thread while the model is\n * still writing it, so a long reply appears as it is written instead of all at\n * once when the call completes. The tool finishes the post with the validated\n * text. Runs whose adapter doesn't stream, or that show reply text, are left\n * alone.\n */\nexport class FactoryReplyStreamProcessor implements Processor {\n readonly id = 'factory-reply-stream';\n\n constructor(private readonly deps: ShareFileToolDeps = {}) {}\n\n async processOutputStream({ part, state, requestContext }: ProcessOutputStreamArgs): Promise<ChunkType> {\n if (!requestContext) return part;\n const args = (state.args ??= {}) as Record<string, string>;\n if (part.type === 'tool-call-input-streaming-start' && part.payload.toolName === REPLY_TOOL_NAME) {\n // Resolve the thread the same way the tool does, so a run started from\n // the web UI or woken by a signal streams too, not only a Slack-started one.\n const render = await resolveRenderContext(requestContext, this.deps);\n // A static adapter (`streaming: false`) posts each reply whole, so the tool does too.\n if (render?.textDisplay !== 'hidden' || !render.streaming?.enabled) return part;\n args[part.payload.toolCallId] = '';\n replyStreams(requestContext).set(part.payload.toolCallId, openReplyStream(render.chatThread));\n } else if (part.type === 'tool-call-delta' && part.payload.toolCallId in args) {\n const json = (args[part.payload.toolCallId] += part.payload.argsTextDelta);\n const text = partialStringField(json, 'text');\n if (text !== undefined) replyStreams(requestContext).get(part.payload.toolCallId)?.update(text);\n } else if ((part.type === 'tool-error' || part.type === 'tool-result') && part.payload.toolCallId in args) {\n // A call that failed validation never reaches `execute`; a successful one already finished its post.\n delete args[part.payload.toolCallId];\n await closeStream(requestContext, part.payload.toolCallId);\n } else if (RUN_END.has(part.type)) {\n for (const toolCallId of Object.keys(args)) await closeStream(requestContext, toolCallId);\n state.args = {};\n }\n return part;\n }\n}\n\n/** Chunks after which no `reply_in_thread` call of this run will still execute. */\nconst RUN_END = new Set<string>(['finish', 'abort', 'error', 'tripwire']);\n\n/**\n * Ends a streamed post whose call will never reach `execute`, keeping the text\n * shown so far, so Slack isn't left with a message that never finishes.\n */\nasync function closeStream(requestContext: RequestContext, toolCallId: string): Promise<void> {\n const streams = replyStreams(requestContext);\n const stream = streams.get(toolCallId);\n if (!stream) return;\n streams.delete(toolCallId);\n await stream.close();\n}\n\nconst ESCAPES: Record<string, string> = { '\"': '\"', '\\\\': '\\\\', '/': '/', b: '\\b', f: '\\f', n: '\\n', r: '\\r', t: '\\t' };\n\n/** The decoded value of a top-level string field in JSON that may still be cut off mid-string. */\nexport function partialStringField(json: string, field: string): string | undefined {\n const start = new RegExp(`\"${field}\"\\\\s*:\\\\s*\"`).exec(json);\n if (!start) return undefined;\n let out = '';\n for (let i = start.index + start[0].length; i < json.length; i++) {\n const ch = json[i]!;\n if (ch === '\"') return out;\n if (ch !== '\\\\') {\n out += ch;\n continue;\n }\n const next = json[i + 1];\n if (next === undefined) return out;\n if (next === 'u') {\n const hex = json.slice(i + 2, i + 6);\n if (hex.length < 4) return out;\n out += String.fromCharCode(parseInt(hex, 16));\n i += 5;\n } else {\n out += ESCAPES[next] ?? next;\n i += 1;\n }\n }\n return out;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-error-card.d.ts","sourceRoot":"","sources":["../../../src/integrations/slack/run-error-card.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAWjE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,cAAc,EAAE,EAAE,mBAAmB,8BAatF"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFactorySessionScope } from "../../rules/binding-context.js";
|
|
2
|
+
import { describeRunError } from "../../run-error.js";
|
|
3
|
+
import { modelSettingsLink } from "./channel-reply-processor.js";
|
|
4
|
+
import { sessionLinkFromRequest } from "./channel-session-link-processor.js";
|
|
5
|
+
import { providerRequestId } from "@mastra/core/error";
|
|
6
|
+
import { Actions, Card, CardText, LinkButton } from "chat";
|
|
7
|
+
// src/integrations/slack/run-error-card.ts
|
|
8
|
+
function slackRunErrorCard(error, { requestContext }) {
|
|
9
|
+
const { title, message, recoveries, model } = describeRunError(error);
|
|
10
|
+
const requestId = providerRequestId(error);
|
|
11
|
+
const facts = [model && `Model: ${model}`, requestId && `Request id: ${requestId}`].filter(Boolean).join(" · ");
|
|
12
|
+
const links = requestContext ? recoveryLinks(requestContext, recoveries) : [];
|
|
13
|
+
return Card({
|
|
14
|
+
title: `❌ ${title}`,
|
|
15
|
+
children: [
|
|
16
|
+
CardText(message),
|
|
17
|
+
...facts ? [CardText(facts, { style: "muted" })] : [],
|
|
18
|
+
...links.length ? [Actions(links.map((link) => LinkButton(link)))] : []
|
|
19
|
+
]
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function recoveryLinks(requestContext, recoveries) {
|
|
23
|
+
const factoryProjectId = readFactorySessionScope(requestContext)?.factoryProjectId;
|
|
24
|
+
const settingsUrl = factoryProjectId && modelSettingsLink(factoryProjectId);
|
|
25
|
+
const sessionUrl = sessionLinkFromRequest(requestContext);
|
|
26
|
+
const links = recoveries.flatMap((recovery) => {
|
|
27
|
+
if (recovery === "credentials") return settingsUrl ? [{
|
|
28
|
+
url: settingsUrl,
|
|
29
|
+
label: "Review credentials"
|
|
30
|
+
}] : [];
|
|
31
|
+
return sessionUrl ? [{
|
|
32
|
+
url: sessionUrl,
|
|
33
|
+
label: "Change model"
|
|
34
|
+
}] : [];
|
|
35
|
+
});
|
|
36
|
+
if (links.length || !sessionUrl) return links;
|
|
37
|
+
return [{
|
|
38
|
+
url: sessionUrl,
|
|
39
|
+
label: "Open session"
|
|
40
|
+
}];
|
|
41
|
+
}
|
|
42
|
+
export { slackRunErrorCard };
|
|
43
|
+
|
|
44
|
+
//# sourceMappingURL=run-error-card.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-error-card.js","names":[],"sources":["../../../src/integrations/slack/run-error-card.ts"],"sourcesContent":["import type { ChannelErrorContext } from '@mastra/core/channels';\nimport { providerRequestId } from '@mastra/core/error';\nimport type { RequestContext } from '@mastra/core/request-context';\nimport { Actions, Card, CardText, LinkButton } from 'chat';\n\nimport { readFactorySessionScope } from '../../rules/binding-context.js';\nimport type { RunErrorRecovery } from '../../run-error.js';\nimport { describeRunError } from '../../run-error.js';\nimport { modelSettingsLink } from './channel-reply-processor.js';\nimport { sessionLinkFromRequest } from './channel-session-link-processor.js';\n\nexport function slackRunErrorCard(error: Error, { requestContext }: ChannelErrorContext) {\n const { title, message, recoveries, model } = describeRunError(error);\n const requestId = providerRequestId(error);\n const facts = [model && `Model: ${model}`, requestId && `Request id: ${requestId}`].filter(Boolean).join(' · ');\n const links = requestContext ? recoveryLinks(requestContext, recoveries) : [];\n return Card({\n title: `❌ ${title}`,\n children: [\n CardText(message),\n ...(facts ? [CardText(facts, { style: 'muted' })] : []),\n ...(links.length ? [Actions(links.map(link => LinkButton(link)))] : []),\n ],\n });\n}\n\nfunction recoveryLinks(requestContext: RequestContext, recoveries: RunErrorRecovery[]) {\n const factoryProjectId = readFactorySessionScope(requestContext)?.factoryProjectId;\n const settingsUrl = factoryProjectId && modelSettingsLink(factoryProjectId);\n const sessionUrl = sessionLinkFromRequest(requestContext);\n const links = recoveries.flatMap(recovery => {\n if (recovery === 'credentials') return settingsUrl ? [{ url: settingsUrl, label: 'Review credentials' }] : [];\n return sessionUrl ? [{ url: sessionUrl, label: 'Change model' }] : [];\n });\n if (links.length || !sessionUrl) return links;\n return [{ url: sessionUrl, label: 'Open session' }];\n}\n"],"mappings":";;;;;;;AAWA,SAAgB,kBAAkB,OAAc,EAAE,kBAAuC;CACvF,MAAM,EAAE,OAAO,SAAS,YAAY,UAAU,iBAAiB,KAAK;CACpE,MAAM,YAAY,kBAAkB,KAAK;CACzC,MAAM,QAAQ,CAAC,SAAS,UAAU,SAAS,aAAa,eAAe,WAAW,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC,KAAK,KAAK;CAC9G,MAAM,QAAQ,iBAAiB,cAAc,gBAAgB,UAAU,IAAI,CAAC;CAC5E,OAAO,KAAK;EACV,OAAO,KAAK;EACZ,UAAU;GACR,SAAS,OAAO;GAChB,GAAI,QAAQ,CAAC,SAAS,OAAO,EAAE,OAAO,QAAQ,CAAC,CAAC,IAAI,CAAC;GACrD,GAAI,MAAM,SAAS,CAAC,QAAQ,MAAM,KAAI,SAAQ,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;EACvE;CACF,CAAC;AACH;AAEA,SAAS,cAAc,gBAAgC,YAAgC;CACrF,MAAM,mBAAmB,wBAAwB,cAAc,CAAC,EAAE;CAClE,MAAM,cAAc,oBAAoB,kBAAkB,gBAAgB;CAC1E,MAAM,aAAa,uBAAuB,cAAc;CACxD,MAAM,QAAQ,WAAW,SAAQ,aAAY;EAC3C,IAAI,aAAa,eAAe,OAAO,cAAc,CAAC;GAAE,KAAK;GAAa,OAAO;EAAqB,CAAC,IAAI,CAAC;EAC5G,OAAO,aAAa,CAAC;GAAE,KAAK;GAAY,OAAO;EAAe,CAAC,IAAI,CAAC;CACtE,CAAC;CACD,IAAI,MAAM,UAAU,CAAC,YAAY,OAAO;CACxC,OAAO,CAAC;EAAE,KAAK;EAAY,OAAO;CAAe,CAAC;AACpD"}
|