managed-deepagents 0.10.0-dev.3 → 0.10.0-dev.5
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 +104 -6
- package/dist/channels/github/index.d.ts +1 -8
- package/dist/channels/github/index.d.ts.map +1 -1
- package/dist/channels/github/index.js +4 -50
- package/dist/channels/github/index.js.map +1 -1
- package/dist/channels/http/http.d.ts.map +1 -1
- package/dist/channels/http/http.js +162 -17
- package/dist/channels/http/http.js.map +1 -1
- package/dist/channels/http/ingress.d.ts +16 -0
- package/dist/channels/http/ingress.d.ts.map +1 -1
- package/dist/channels/http/ingress.js +30 -5
- package/dist/channels/http/ingress.js.map +1 -1
- package/dist/channels/http/reply.d.ts +20 -0
- package/dist/channels/http/reply.d.ts.map +1 -0
- package/dist/channels/http/reply.js +116 -0
- package/dist/channels/http/reply.js.map +1 -0
- package/dist/channels/http/studio.d.ts +13 -0
- package/dist/channels/http/studio.d.ts.map +1 -0
- package/dist/channels/http/studio.js +80 -0
- package/dist/channels/http/studio.js.map +1 -0
- package/dist/channels/http/types.d.ts +32 -2
- package/dist/channels/http/types.d.ts.map +1 -1
- package/dist/channels/linear/connections.d.ts +11 -0
- package/dist/channels/linear/connections.d.ts.map +1 -0
- package/dist/channels/linear/connections.js +14 -0
- package/dist/channels/linear/connections.js.map +1 -0
- package/dist/channels/linear/graphql.d.ts +69 -0
- package/dist/channels/linear/graphql.d.ts.map +1 -0
- package/dist/channels/linear/graphql.js +169 -0
- package/dist/channels/linear/graphql.js.map +1 -0
- package/dist/channels/linear/index.d.ts +9 -0
- package/dist/channels/linear/index.d.ts.map +1 -0
- package/dist/channels/linear/index.js +466 -0
- package/dist/channels/linear/index.js.map +1 -0
- package/dist/channels/linear/schema.d.ts +1035 -0
- package/dist/channels/linear/schema.d.ts.map +1 -0
- package/dist/channels/linear/schema.js +216 -0
- package/dist/channels/linear/schema.js.map +1 -0
- package/dist/channels/linear/types.d.ts +112 -0
- package/dist/channels/linear/types.d.ts.map +1 -0
- package/dist/channels/linear/types.js +2 -0
- package/dist/channels/linear/types.js.map +1 -0
- package/dist/channels/reserved-connections.d.ts +2 -2
- package/dist/channels/reserved-connections.d.ts.map +1 -1
- package/dist/channels/reserved-connections.js +26 -9
- package/dist/channels/reserved-connections.js.map +1 -1
- package/dist/channels/source-providers.js +1 -1
- package/dist/channels/source-providers.js.map +1 -1
- package/dist/connections.d.ts +14 -3
- package/dist/connections.d.ts.map +1 -1
- package/dist/connections.js +32 -9
- package/dist/connections.js.map +1 -1
- package/dist/credential-gate.d.ts.map +1 -1
- package/dist/credential-gate.js +8 -6
- package/dist/credential-gate.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/ingress/runs.d.ts +5 -0
- package/dist/ingress/runs.d.ts.map +1 -1
- package/dist/ingress/runs.js +33 -1
- package/dist/ingress/runs.js.map +1 -1
- package/dist/namespaces.d.ts +15 -0
- package/dist/namespaces.d.ts.map +1 -1
- package/dist/namespaces.js +2 -0
- package/dist/namespaces.js.map +1 -1
- package/dist/runtime/auth.d.ts +2 -0
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +138 -22
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/channel-icon.d.ts +22 -0
- package/dist/runtime/channel-icon.d.ts.map +1 -0
- package/dist/runtime/channel-icon.js +70 -0
- package/dist/runtime/channel-icon.js.map +1 -0
- package/dist/runtime/channel.d.ts +6 -0
- package/dist/runtime/channel.d.ts.map +1 -1
- package/dist/runtime/channel.js.map +1 -1
- package/dist/runtime/identity-http.d.ts +2 -0
- package/dist/runtime/identity-http.d.ts.map +1 -1
- package/dist/runtime/identity-http.js +35 -0
- package/dist/runtime/identity-http.js.map +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/schedule-auth.d.ts.map +1 -1
- package/dist/runtime/schedule-auth.js +19 -2
- package/dist/runtime/schedule-auth.js.map +1 -1
- package/package.json +7 -7
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { connections } from "../../connections.js";
|
|
2
|
+
import { LINEAR_TOKEN_SLUG } from "./connections.js";
|
|
3
|
+
const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
4
|
+
const LINEAR_API_TIMEOUT_MS = 10_000;
|
|
5
|
+
/** The largest Linear response body read. */
|
|
6
|
+
const LINEAR_RESPONSE_MAX_CHARS = 1_000_000;
|
|
7
|
+
/** How long the app token read from Agent Auth is reused. */
|
|
8
|
+
const TOKEN_TTL_MS = 5 * 60_000;
|
|
9
|
+
let appToken;
|
|
10
|
+
/**
|
|
11
|
+
* Where GraphQL requests go: Linear's API, or under `mda dev`
|
|
12
|
+
* (`MDA_LOCAL_DEV=1`) the stand-in `MDA_LINEAR_API_URL` names, so local
|
|
13
|
+
* tests never reach Linear. A deployment always uses Linear's API.
|
|
14
|
+
*/
|
|
15
|
+
function graphqlUrl(env = process.env) {
|
|
16
|
+
const override = env.MDA_LINEAR_API_URL?.trim();
|
|
17
|
+
if (env.MDA_LOCAL_DEV !== "1" || !override)
|
|
18
|
+
return LINEAR_GRAPHQL_URL;
|
|
19
|
+
const url = new URL(override);
|
|
20
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
21
|
+
throw new TypeError("MDA_LINEAR_API_URL must be an http(s) URL");
|
|
22
|
+
return url.toString();
|
|
23
|
+
}
|
|
24
|
+
/** Linear refused a request for exceeding its rate limit. */
|
|
25
|
+
export class LinearRateLimitError extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super("Linear rate limit exceeded");
|
|
28
|
+
this.name = "LinearRateLimitError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The app's access token, read from Agent Auth at most every few minutes. */
|
|
32
|
+
function linearToken() {
|
|
33
|
+
if (appToken && appToken.expiresAt > Date.now())
|
|
34
|
+
return appToken.value;
|
|
35
|
+
const entry = {
|
|
36
|
+
value: connections.resolveDeploymentSecret(LINEAR_TOKEN_SLUG),
|
|
37
|
+
expiresAt: Date.now() + TOKEN_TTL_MS,
|
|
38
|
+
};
|
|
39
|
+
entry.value.catch(() => {
|
|
40
|
+
if (appToken === entry)
|
|
41
|
+
appToken = undefined;
|
|
42
|
+
});
|
|
43
|
+
appToken = entry;
|
|
44
|
+
return entry.value;
|
|
45
|
+
}
|
|
46
|
+
/** Forget the cached app token; for tests. */
|
|
47
|
+
export function resetLinearToken() {
|
|
48
|
+
appToken = undefined;
|
|
49
|
+
}
|
|
50
|
+
function errorCodes(body) {
|
|
51
|
+
return (body.errors ?? []).flatMap((error) => typeof error.extensions?.code === "string" ? [error.extensions.code] : []);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Run a GraphQL operation as the app. `query` is a constant; everything that
|
|
55
|
+
* varies goes in `variables`. A rejected token is read again once. Returns
|
|
56
|
+
* the response's `data`.
|
|
57
|
+
*/
|
|
58
|
+
export async function linearRequest(query, variables = {}) {
|
|
59
|
+
const send = async () => fetch(graphqlUrl(), {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: {
|
|
62
|
+
Authorization: `Bearer ${await linearToken()}`,
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify({ query, variables }),
|
|
66
|
+
signal: AbortSignal.timeout(LINEAR_API_TIMEOUT_MS),
|
|
67
|
+
});
|
|
68
|
+
let response = await send();
|
|
69
|
+
let body = await readBody(response);
|
|
70
|
+
if (response.status === 401 ||
|
|
71
|
+
errorCodes(body).includes("AUTHENTICATION_ERROR")) {
|
|
72
|
+
appToken = undefined;
|
|
73
|
+
response = await send();
|
|
74
|
+
body = await readBody(response);
|
|
75
|
+
}
|
|
76
|
+
if (errorCodes(body).includes("RATELIMITED"))
|
|
77
|
+
throw new LinearRateLimitError();
|
|
78
|
+
if (!response.ok || body.errors?.length || body.data === undefined)
|
|
79
|
+
throw new Error(`Linear refused the request (HTTP ${response.status})`);
|
|
80
|
+
return body.data;
|
|
81
|
+
}
|
|
82
|
+
async function readBody(response) {
|
|
83
|
+
const text = await response.text();
|
|
84
|
+
if (text.length > LINEAR_RESPONSE_MAX_CHARS)
|
|
85
|
+
return {};
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(text);
|
|
88
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
89
|
+
? parsed
|
|
90
|
+
: {};
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const VIEWER_QUERY = "query MdaLinearViewer { viewer { id displayName } }";
|
|
97
|
+
let appUser;
|
|
98
|
+
/**
|
|
99
|
+
* The Linear user the app acts as, read once per process: its own comments
|
|
100
|
+
* and changes are ignored, and a comment mentioning its `displayName` starts
|
|
101
|
+
* an agent session.
|
|
102
|
+
*/
|
|
103
|
+
export function linearAppUser() {
|
|
104
|
+
appUser ??= linearRequest(VIEWER_QUERY).then(({ viewer }) => {
|
|
105
|
+
if (typeof viewer?.id !== "string" ||
|
|
106
|
+
!viewer.id ||
|
|
107
|
+
typeof viewer.displayName !== "string")
|
|
108
|
+
throw new Error("Linear did not report the app's user");
|
|
109
|
+
return { id: viewer.id, displayName: viewer.displayName };
|
|
110
|
+
});
|
|
111
|
+
appUser.catch(() => {
|
|
112
|
+
appUser = undefined;
|
|
113
|
+
});
|
|
114
|
+
return appUser;
|
|
115
|
+
}
|
|
116
|
+
/** Forget the cached app user; for tests. */
|
|
117
|
+
export function resetLinearAppUser() {
|
|
118
|
+
appUser = undefined;
|
|
119
|
+
}
|
|
120
|
+
const COMMENT_CREATE_MUTATION = `mutation MdaLinearCommentCreate($input: CommentCreateInput!) {
|
|
121
|
+
commentCreate(input: $input) { success comment { id url } }
|
|
122
|
+
}`;
|
|
123
|
+
/**
|
|
124
|
+
* Comment on an issue as the app, threaded under `parentId` when given.
|
|
125
|
+
* Returns the comment's id and URL.
|
|
126
|
+
*/
|
|
127
|
+
export async function createLinearComment(input) {
|
|
128
|
+
const data = await linearRequest(COMMENT_CREATE_MUTATION, { input });
|
|
129
|
+
const comment = data.commentCreate?.comment;
|
|
130
|
+
if (!data.commentCreate?.success || !comment?.id)
|
|
131
|
+
throw new Error("Linear did not create the comment");
|
|
132
|
+
return { id: comment.id, ...(comment.url && { url: comment.url }) };
|
|
133
|
+
}
|
|
134
|
+
const COMMENT_SESSION_QUERY = `query MdaLinearCommentSession($id: String!) {
|
|
135
|
+
comment(id: $id) { agentSession { id } }
|
|
136
|
+
}`;
|
|
137
|
+
/** Whether a comment belongs to an agent session's thread. */
|
|
138
|
+
export async function commentInAgentSession(commentId) {
|
|
139
|
+
const data = await linearRequest(COMMENT_SESSION_QUERY, { id: commentId });
|
|
140
|
+
return Boolean(data.comment?.agentSession?.id);
|
|
141
|
+
}
|
|
142
|
+
const ACTIVITY_CREATE_MUTATION = `mutation MdaLinearActivityCreate($input: AgentActivityCreateInput!) {
|
|
143
|
+
agentActivityCreate(input: $input) { success agentActivity { id } }
|
|
144
|
+
}`;
|
|
145
|
+
/**
|
|
146
|
+
* Post an activity to an agent session as the app. `ephemeral` thoughts and
|
|
147
|
+
* actions are replaced by the next activity; a `select` signal offers
|
|
148
|
+
* options to choose from.
|
|
149
|
+
*/
|
|
150
|
+
export async function createLinearActivity(input) {
|
|
151
|
+
const data = await linearRequest(ACTIVITY_CREATE_MUTATION, { input });
|
|
152
|
+
const activity = data.agentActivityCreate?.agentActivity;
|
|
153
|
+
if (!data.agentActivityCreate?.success || !activity?.id)
|
|
154
|
+
throw new Error("Linear did not create the activity");
|
|
155
|
+
return { id: activity.id };
|
|
156
|
+
}
|
|
157
|
+
const SESSION_UPDATE_MUTATION = `mutation MdaLinearSessionUpdate($id: String!, $input: AgentSessionUpdateInput!) {
|
|
158
|
+
agentSessionUpdate(id: $id, input: $input) { success }
|
|
159
|
+
}`;
|
|
160
|
+
/** Link an agent session to a page about it, such as its Studio thread. */
|
|
161
|
+
export async function addLinearSessionUrl(agentSessionId, link) {
|
|
162
|
+
const data = await linearRequest(SESSION_UPDATE_MUTATION, {
|
|
163
|
+
id: agentSessionId,
|
|
164
|
+
input: { addedExternalUrls: [link] },
|
|
165
|
+
});
|
|
166
|
+
if (!data.agentSessionUpdate?.success)
|
|
167
|
+
throw new Error("Linear did not update the session");
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=graphql.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"graphql.js","sourceRoot":"","sources":["../../../src/channels/linear/graphql.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type HttpChannel } from "../http/index.js";
|
|
2
|
+
import type { LinearChannelOptions } from "./types.js";
|
|
3
|
+
export type { LinearAppOptions, LinearChannelOptions, LinearChannelRuntime, LinearEvent, LinearEventCallback, LinearEventKey, LinearEventResult, LinearPayloadFor, } from "./types.js";
|
|
4
|
+
export { LinearRateLimitError } from "./graphql.js";
|
|
5
|
+
/** Adapt verified Linear webhooks into HTTP channel messages or skips. */
|
|
6
|
+
export declare function linearChannel(options?: LinearChannelOptions): HttpChannel;
|
|
7
|
+
/** Forget the cached webhook secret; for tests. */
|
|
8
|
+
export declare function resetLinearWebhookSecret(): void;
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/channels/linear/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,WAAW,EAMjB,MAAM,kBAAkB,CAAC;AA2B1B,OAAO,KAAK,EAEV,oBAAoB,EAIrB,MAAM,YAAY,CAAC;AAGpB,YAAY,EACV,gBAAgB,EAChB,oBAAoB,EACpB,oBAAoB,EACpB,WAAW,EACX,mBAAmB,EACnB,cAAc,EACd,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAgBpD,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,WAAW,CAiD7E;AA0FD,mDAAmD;AACnD,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C"}
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { channelThreadId, httpChannel, } from "../http/index.js";
|
|
3
|
+
import { studioUrl } from "../http/studio.js";
|
|
4
|
+
import { isJsonValue } from "../message-schema.js";
|
|
5
|
+
import { immutableCopy, requireTextContent } from "../runtime.js";
|
|
6
|
+
import { logger } from "../../runtime/log.js";
|
|
7
|
+
import { validateChannelIconPath } from "../../runtime/channel-icon.js";
|
|
8
|
+
import { ConnectionNotFoundError, DEPLOYMENT_ID_ENV, connections, } from "../../connections.js";
|
|
9
|
+
import { linearAppConnectionSlug } from "./connections.js";
|
|
10
|
+
import { addLinearSessionUrl, commentInAgentSession, createLinearActivity, createLinearComment, linearAppUser, } from "./graphql.js";
|
|
11
|
+
import { SUPPORTED_EVENT_KEYS, linearEventSchema, linearReceiptSchema, } from "./schema.js";
|
|
12
|
+
export { LinearRateLimitError } from "./graphql.js";
|
|
13
|
+
const SESSION_KEYS = new Set([
|
|
14
|
+
"AgentSessionEvent.created",
|
|
15
|
+
"AgentSessionEvent.prompted",
|
|
16
|
+
]);
|
|
17
|
+
const APP_OPTION_KEYS = new Set([
|
|
18
|
+
"name",
|
|
19
|
+
"description",
|
|
20
|
+
"icon",
|
|
21
|
+
"teams",
|
|
22
|
+
]);
|
|
23
|
+
/** Adapt verified Linear webhooks into HTTP channel messages or skips. */
|
|
24
|
+
export function linearChannel(options = {}) {
|
|
25
|
+
const handlers = { ...options.onEvent };
|
|
26
|
+
// A misspelled option would silently widen what starts runs.
|
|
27
|
+
const unknown = Object.keys(options.app ?? {}).filter((key) => !APP_OPTION_KEYS.has(key));
|
|
28
|
+
if (unknown.length)
|
|
29
|
+
throw new TypeError(`unknown Linear app option ${unknown.join(", ")}; use ${[...APP_OPTION_KEYS].join(", ")}`);
|
|
30
|
+
if (options.app)
|
|
31
|
+
validateAppDisplay(options.app);
|
|
32
|
+
const teams = options.app?.teams?.map((team) => team.trim().toLowerCase());
|
|
33
|
+
if (teams?.length === 0 || teams?.some((team) => !team))
|
|
34
|
+
throw new TypeError("Linear app.teams must list team keys; omit it to allow every team");
|
|
35
|
+
const filters = {
|
|
36
|
+
teams,
|
|
37
|
+
ignoreBots: options.ignoreBots ?? true,
|
|
38
|
+
sessions: options.sessions ?? true,
|
|
39
|
+
};
|
|
40
|
+
const channel = httpChannel({
|
|
41
|
+
provider: "linear",
|
|
42
|
+
...(options.multitaskStrategy && {
|
|
43
|
+
multitaskStrategy: options.multitaskStrategy,
|
|
44
|
+
}),
|
|
45
|
+
providerConfig: {
|
|
46
|
+
// Deploy subscribes the app's webhook to the handled events.
|
|
47
|
+
events: handledEvents(handlers, filters.sessions),
|
|
48
|
+
...(options.app && { app: appConfig(options.app) }),
|
|
49
|
+
},
|
|
50
|
+
verify: verifyLinearRequest,
|
|
51
|
+
parse: (context) => parseLinearRequest(context, handlers, channelName(channel), filters),
|
|
52
|
+
input: (_message, rawEvent) => handlerInput(handlers, rawEvent, channelName(channel)),
|
|
53
|
+
post: postReply,
|
|
54
|
+
onDelivered: acknowledgeSession,
|
|
55
|
+
onCancelled: confirmStopped,
|
|
56
|
+
onError: reportError,
|
|
57
|
+
onPaused: notifyPaused,
|
|
58
|
+
});
|
|
59
|
+
// Served at /channels/{name}/icon; deploy points the app's icon at it.
|
|
60
|
+
const icon = options.app?.icon;
|
|
61
|
+
return icon === undefined
|
|
62
|
+
? channel
|
|
63
|
+
: Object.assign(channel, {
|
|
64
|
+
icon: validateChannelIconPath(icon, "Linear app.icon"),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function channelName(channel) {
|
|
68
|
+
return channel.name ?? channel.kind;
|
|
69
|
+
}
|
|
70
|
+
/** The events deploy subscribes the app's webhook to. */
|
|
71
|
+
function handledEvents(handlers, sessions) {
|
|
72
|
+
const handled = Object.keys(handlers).filter((key) => handlers[key] !== undefined);
|
|
73
|
+
return [...new Set([...handled, ...(sessions ? SESSION_KEYS : [])])].sort();
|
|
74
|
+
}
|
|
75
|
+
/** Check how the app looks against Linear's rules, before deploy sends it. */
|
|
76
|
+
function validateAppDisplay({ name, description, icon }) {
|
|
77
|
+
if (name !== undefined) {
|
|
78
|
+
const trimmed = name.trim();
|
|
79
|
+
if (trimmed.length < 2 ||
|
|
80
|
+
trimmed.length > 80 ||
|
|
81
|
+
/linear|https?:\/\//i.test(trimmed))
|
|
82
|
+
throw new TypeError('Linear app.name must be 2-80 characters, without "Linear" or a URL');
|
|
83
|
+
}
|
|
84
|
+
if (description !== undefined &&
|
|
85
|
+
(!description.trim() || description.trim().length > 1000))
|
|
86
|
+
throw new TypeError("Linear app.description must be 1-1000 characters");
|
|
87
|
+
if (icon !== undefined)
|
|
88
|
+
validateChannelIconPath(icon, "Linear app.icon");
|
|
89
|
+
}
|
|
90
|
+
/** The app options deploy reads, without unset fields. */
|
|
91
|
+
function appConfig(app) {
|
|
92
|
+
return Object.fromEntries(Object.entries({
|
|
93
|
+
name: app.name?.trim(),
|
|
94
|
+
description: app.description?.trim(),
|
|
95
|
+
icon: app.icon && validateChannelIconPath(app.icon, "Linear app.icon"),
|
|
96
|
+
teams: app.teams && [...app.teams],
|
|
97
|
+
}).filter(([, value]) => value !== undefined));
|
|
98
|
+
}
|
|
99
|
+
/** How long the webhook secret read from Agent Auth is reused. */
|
|
100
|
+
const SECRET_TTL_MS = 5 * 60_000;
|
|
101
|
+
/** How long a missing secret is remembered, so unsigned traffic can't hammer Agent Auth. */
|
|
102
|
+
const MISSING_SECRET_TTL_MS = 60_000;
|
|
103
|
+
let webhookSecret;
|
|
104
|
+
/**
|
|
105
|
+
* The app's webhook signing secret: `LINEAR_WEBHOOK_SECRET` when set,
|
|
106
|
+
* otherwise the agent secret stored at
|
|
107
|
+
* `mda/linear-app/{deployment}/webhook-secret`. Only a missing connection
|
|
108
|
+
* reads as `undefined`; credential store failures propagate.
|
|
109
|
+
*/
|
|
110
|
+
async function linearWebhookSecret() {
|
|
111
|
+
const override = process.env.LINEAR_WEBHOOK_SECRET?.trim();
|
|
112
|
+
if (override)
|
|
113
|
+
return override;
|
|
114
|
+
const deploymentId = process.env[DEPLOYMENT_ID_ENV]?.trim();
|
|
115
|
+
if (!deploymentId)
|
|
116
|
+
return undefined;
|
|
117
|
+
if (webhookSecret && webhookSecret.expiresAt > Date.now())
|
|
118
|
+
return webhookSecret.value;
|
|
119
|
+
// Concurrent deliveries share the in-flight read.
|
|
120
|
+
const entry = {
|
|
121
|
+
value: connections
|
|
122
|
+
.resolveDeploymentSecret(linearAppConnectionSlug(deploymentId, "webhook-secret"))
|
|
123
|
+
.catch((error) => {
|
|
124
|
+
if (error instanceof ConnectionNotFoundError) {
|
|
125
|
+
entry.expiresAt = Date.now() + MISSING_SECRET_TTL_MS;
|
|
126
|
+
logger.error("Linear webhook secret is not stored in Agent Auth");
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
// Read again on the next delivery rather than caching a failure.
|
|
130
|
+
if (webhookSecret === entry)
|
|
131
|
+
webhookSecret = undefined;
|
|
132
|
+
throw error;
|
|
133
|
+
}),
|
|
134
|
+
expiresAt: Date.now() + SECRET_TTL_MS,
|
|
135
|
+
};
|
|
136
|
+
webhookSecret = entry;
|
|
137
|
+
return entry.value;
|
|
138
|
+
}
|
|
139
|
+
/** Forget the cached webhook secret; for tests. */
|
|
140
|
+
export function resetLinearWebhookSecret() {
|
|
141
|
+
webhookSecret = undefined;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* How far a delivery's `webhookTimestamp` may be from now. Linear recommends
|
|
145
|
+
* rejecting older payloads, so a captured delivery can't be replayed.
|
|
146
|
+
*/
|
|
147
|
+
const WEBHOOK_TOLERANCE_MS = 60_000;
|
|
148
|
+
async function verifyLinearRequest({ request, rawBody, }) {
|
|
149
|
+
// Reject unsigned deliveries before reading the secret from Agent Auth.
|
|
150
|
+
const signature = request.headers.get("linear-signature") ?? "";
|
|
151
|
+
if (!/^[0-9a-f]{64}$/i.test(signature))
|
|
152
|
+
return false;
|
|
153
|
+
const secret = await linearWebhookSecret();
|
|
154
|
+
if (!secret)
|
|
155
|
+
return false;
|
|
156
|
+
if (!timingSafeEqual(createHmac("sha256", secret).update(rawBody).digest(), Buffer.from(signature, "hex")))
|
|
157
|
+
return false;
|
|
158
|
+
return isFresh(rawBody);
|
|
159
|
+
}
|
|
160
|
+
/** Whether a signed delivery was sent within the replay window. */
|
|
161
|
+
function isFresh(rawBody) {
|
|
162
|
+
let timestamp;
|
|
163
|
+
try {
|
|
164
|
+
timestamp = JSON.parse(new TextDecoder().decode(rawBody)).webhookTimestamp;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return (typeof timestamp === "number" &&
|
|
170
|
+
Math.abs(Date.now() - timestamp) <= WEBHOOK_TOLERANCE_MS);
|
|
171
|
+
}
|
|
172
|
+
const EVENT_KEYS = new Set(SUPPORTED_EVENT_KEYS);
|
|
173
|
+
/**
|
|
174
|
+
* Whether another app or an integration made the change. The delivery's actor
|
|
175
|
+
* says who acted. An issue's or comment's `botActor` is the bot that created
|
|
176
|
+
* it, so it names the actor only when the event is its creation.
|
|
177
|
+
*/
|
|
178
|
+
function byBot(event) {
|
|
179
|
+
const actor = event.payload.actor;
|
|
180
|
+
if (typeof actor?.type === "string" && actor.type.toLowerCase() !== "user")
|
|
181
|
+
return true;
|
|
182
|
+
if (event.key === "Issue.update")
|
|
183
|
+
return false;
|
|
184
|
+
const data = event.payload.data;
|
|
185
|
+
return data?.botActor !== undefined && data.botActor !== null;
|
|
186
|
+
}
|
|
187
|
+
async function parseLinearRequest({ request }, handlers, channelName, filters) {
|
|
188
|
+
const receipt = linearReceiptSchema.parse({
|
|
189
|
+
deliveryId: request.headers.get("linear-delivery"),
|
|
190
|
+
payload: await request.json(),
|
|
191
|
+
});
|
|
192
|
+
const { deliveryId, payload } = receipt;
|
|
193
|
+
const key = `${payload.type}.${payload.action}`;
|
|
194
|
+
logger.info(`linear receipt ${JSON.stringify({
|
|
195
|
+
event: logger.logIdentifier(key),
|
|
196
|
+
deliveryId: logger.logIdentifier(deliveryId),
|
|
197
|
+
})}`);
|
|
198
|
+
// Linear expects a 200 for every delivery it should not retry.
|
|
199
|
+
const response = Response.json({ status: "received" }, { status: 200 });
|
|
200
|
+
if (key === "OAuthApp.revoked") {
|
|
201
|
+
logger.warn("Linear app access was revoked; reinstall it with mda deploy");
|
|
202
|
+
return { type: "ignore", response };
|
|
203
|
+
}
|
|
204
|
+
// Events nothing handles stop here, before run creation. Sessions have a
|
|
205
|
+
// default handler while they are on.
|
|
206
|
+
const handled = EVENT_KEYS.has(key) &&
|
|
207
|
+
((filters.sessions && SESSION_KEYS.has(key)) ||
|
|
208
|
+
(Object.hasOwn(handlers, key) &&
|
|
209
|
+
handlers[key] !== undefined));
|
|
210
|
+
if (!handled)
|
|
211
|
+
return { type: "ignore", response };
|
|
212
|
+
const event = linearEventSchema.parse({ key, deliveryId, payload });
|
|
213
|
+
// Issues of teams the channel is not limited to start nothing.
|
|
214
|
+
if (filters.teams &&
|
|
215
|
+
!(event.team && filters.teams.includes(event.team.key.toLowerCase())))
|
|
216
|
+
return { type: "ignore", response };
|
|
217
|
+
if (event.type !== "AgentSessionEvent" && (await ignored(event, filters)))
|
|
218
|
+
return { type: "ignore", response };
|
|
219
|
+
// Preserve the original event even if a callback modifies its argument.
|
|
220
|
+
if (!isJsonValue(event))
|
|
221
|
+
throw new TypeError("invalid Linear event");
|
|
222
|
+
const eventSnapshot = immutableCopy(event);
|
|
223
|
+
// One thread per issue, so the agent keeps the conversation.
|
|
224
|
+
const message = {
|
|
225
|
+
threadId: channelThreadId(channelName, `${event.organizationId}/${event.issueId}`),
|
|
226
|
+
userId: "mda:channel",
|
|
227
|
+
target: linearTarget(event),
|
|
228
|
+
deliveryKey: deliveryId,
|
|
229
|
+
};
|
|
230
|
+
// Stop in a session cancels the conversation's run.
|
|
231
|
+
if (event.key === "AgentSessionEvent.prompted" &&
|
|
232
|
+
event.payload.agentActivity.signal === "stop")
|
|
233
|
+
return { type: "cancel", message, rawEvent: eventSnapshot, response };
|
|
234
|
+
// What the run starts with, or how the event answers an interrupt, is
|
|
235
|
+
// decided once MDA knows whether the thread waits on one.
|
|
236
|
+
return { type: "message", message, rawEvent: eventSnapshot, response };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Whether a webhook event starts nothing: another app's change, the app's own
|
|
240
|
+
* activity, or a comment an agent session already delivers.
|
|
241
|
+
*/
|
|
242
|
+
async function ignored(event, filters) {
|
|
243
|
+
if (event.type === "AgentSessionEvent")
|
|
244
|
+
return false;
|
|
245
|
+
if (filters.ignoreBots && byBot(event))
|
|
246
|
+
return true;
|
|
247
|
+
// Linear reports the app's comments as its user's, not as a bot's.
|
|
248
|
+
const appUser = await linearAppUser();
|
|
249
|
+
if (event.actor?.id === appUser.id)
|
|
250
|
+
return true;
|
|
251
|
+
if (event.key !== "Comment.create")
|
|
252
|
+
return false;
|
|
253
|
+
const comment = event.payload.data;
|
|
254
|
+
if ((comment.user?.id ?? comment.userId) === appUser.id)
|
|
255
|
+
return true;
|
|
256
|
+
return filters.sessions && sessionComment(comment, appUser);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Whether a comment is part of an agent session, which delivers it as a
|
|
260
|
+
* session event too: one that mentions the app opens a session, and a reply
|
|
261
|
+
* in a session's thread prompts it.
|
|
262
|
+
*/
|
|
263
|
+
async function sessionComment(comment, appUser) {
|
|
264
|
+
if (mentionsApp(comment.body, appUser.displayName))
|
|
265
|
+
return true;
|
|
266
|
+
return comment.parentId ? commentInAgentSession(comment.parentId) : false;
|
|
267
|
+
}
|
|
268
|
+
function mentionsApp(body, displayName) {
|
|
269
|
+
if (!displayName)
|
|
270
|
+
return false;
|
|
271
|
+
const name = displayName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
272
|
+
return new RegExp(`(^|[^\\w@])@${name}(?![\\w-])`, "i").test(body);
|
|
273
|
+
}
|
|
274
|
+
/** Where replies go: the issue, and the comment thread or session that asked. */
|
|
275
|
+
function linearTarget(event) {
|
|
276
|
+
const identifier = event.key === "Comment.create"
|
|
277
|
+
? event.payload.data.issue?.identifier
|
|
278
|
+
: event.type === "AgentSessionEvent"
|
|
279
|
+
? event.payload.agentSession.issue?.identifier
|
|
280
|
+
: event.payload.data.identifier;
|
|
281
|
+
return {
|
|
282
|
+
organizationId: event.organizationId,
|
|
283
|
+
issueId: event.issueId,
|
|
284
|
+
...(identifier && { identifier }),
|
|
285
|
+
// Replies go into the comment's thread; Linear threads one level deep.
|
|
286
|
+
...(event.key === "Comment.create" && {
|
|
287
|
+
commentId: event.payload.data.parentId ?? event.payload.data.id,
|
|
288
|
+
}),
|
|
289
|
+
...(event.sessionId && { sessionId: event.sessionId }),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** What the event's handler starts a run with; `undefined` when it skips or fails. */
|
|
293
|
+
async function handlerInput(handlers, rawEvent, channelName) {
|
|
294
|
+
const event = linearEvent(rawEvent);
|
|
295
|
+
if (!event)
|
|
296
|
+
return undefined;
|
|
297
|
+
const handler = (Object.hasOwn(handlers, event.key) ? handlers[event.key] : undefined);
|
|
298
|
+
const receipt = {
|
|
299
|
+
event: logger.logIdentifier(event.key),
|
|
300
|
+
deliveryId: logger.logIdentifier(event.deliveryId),
|
|
301
|
+
};
|
|
302
|
+
let input;
|
|
303
|
+
try {
|
|
304
|
+
input = handler
|
|
305
|
+
? await handler(event, { channel: { name: channelName } })
|
|
306
|
+
: sessionInput(event);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
logger.error(`linear callback failed ${JSON.stringify(receipt)} (${logger.safeErrorLabel(error)})`);
|
|
310
|
+
input = undefined;
|
|
311
|
+
}
|
|
312
|
+
if (input === undefined || input === null) {
|
|
313
|
+
// A session that gets no run would wait for an answer that never comes,
|
|
314
|
+
// whether it just opened or this is a follow-up in it.
|
|
315
|
+
if (event.type === "AgentSessionEvent")
|
|
316
|
+
await say(event.sessionId, {
|
|
317
|
+
type: "response",
|
|
318
|
+
body: "I'm not set up to work on this request.",
|
|
319
|
+
});
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
logger.info(`Linear handler returned run input ${JSON.stringify(receipt)}`);
|
|
323
|
+
return input;
|
|
324
|
+
}
|
|
325
|
+
/** What a session event starts a run with when no handler decides. */
|
|
326
|
+
function sessionInput(event) {
|
|
327
|
+
if (event.key === "AgentSessionEvent.created") {
|
|
328
|
+
const { promptContext, agentSession } = event.payload;
|
|
329
|
+
return (promptContext?.trim() ||
|
|
330
|
+
agentSession.comment?.body?.trim() ||
|
|
331
|
+
agentSession.issue?.title?.trim() ||
|
|
332
|
+
undefined);
|
|
333
|
+
}
|
|
334
|
+
if (event.key === "AgentSessionEvent.prompted")
|
|
335
|
+
return event.payload.agentActivity.content.body?.trim() || undefined;
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
/** The parsed event a delivery carries, as handlers may change it. */
|
|
339
|
+
function linearEvent(rawEvent) {
|
|
340
|
+
if (!rawEvent || typeof rawEvent !== "object" || Array.isArray(rawEvent))
|
|
341
|
+
return undefined;
|
|
342
|
+
const event = structuredClone(rawEvent);
|
|
343
|
+
return EVENT_KEYS.has(event.key) ? event : undefined;
|
|
344
|
+
}
|
|
345
|
+
/** Say something in a session; a failure is logged, never thrown. */
|
|
346
|
+
async function say(sessionId, content, extra = {}) {
|
|
347
|
+
if (!sessionId)
|
|
348
|
+
return;
|
|
349
|
+
try {
|
|
350
|
+
await createLinearActivity({
|
|
351
|
+
agentSessionId: sessionId,
|
|
352
|
+
content,
|
|
353
|
+
...extra,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
logger.error(`linear session activity failed (${logger.safeErrorLabel(error)})`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Post the agent's reply: as the session's response when a session asked,
|
|
362
|
+
* otherwise as a comment in the thread of the comment that started the run.
|
|
363
|
+
* A post that starts a new thread, such as a scheduled one, comments on the
|
|
364
|
+
* issue itself.
|
|
365
|
+
*/
|
|
366
|
+
async function postReply(input) {
|
|
367
|
+
const { target, newThread, ...message } = input;
|
|
368
|
+
if (!isLinearTarget(target))
|
|
369
|
+
throw new TypeError("invalid Linear target");
|
|
370
|
+
const body = requireTextContent(message);
|
|
371
|
+
if (target.sessionId && !newThread)
|
|
372
|
+
return createLinearActivity({
|
|
373
|
+
agentSessionId: target.sessionId,
|
|
374
|
+
content: { type: "response", body },
|
|
375
|
+
});
|
|
376
|
+
return createLinearComment({
|
|
377
|
+
issueId: target.issueId,
|
|
378
|
+
body,
|
|
379
|
+
...(target.commentId && !newThread && { parentId: target.commentId }),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Linear marks a session unresponsive without an activity within seconds, even
|
|
384
|
+
* while the run waits behind another one: acknowledge it at once, and link a
|
|
385
|
+
* new session to its thread in Studio.
|
|
386
|
+
*/
|
|
387
|
+
async function acknowledgeSession(target, run) {
|
|
388
|
+
if (!isLinearTarget(target) || !target.sessionId)
|
|
389
|
+
return;
|
|
390
|
+
await say(target.sessionId, { type: "thought", body: "Working on it." }, {
|
|
391
|
+
ephemeral: true,
|
|
392
|
+
});
|
|
393
|
+
const url = studioUrl(run);
|
|
394
|
+
if (!url || linearEvent(run.rawEvent)?.key !== "AgentSessionEvent.created")
|
|
395
|
+
return;
|
|
396
|
+
try {
|
|
397
|
+
await addLinearSessionUrl(target.sessionId, {
|
|
398
|
+
label: "LangSmith Studio",
|
|
399
|
+
url,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
logger.error(`linear session link failed (${logger.safeErrorLabel(error)})`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** After Stop, Linear waits for a final word from the agent. */
|
|
407
|
+
async function confirmStopped(target) {
|
|
408
|
+
if (!isLinearTarget(target) || !target.sessionId)
|
|
409
|
+
return;
|
|
410
|
+
await say(target.sessionId, { type: "response", body: "Stopped." });
|
|
411
|
+
}
|
|
412
|
+
const ERROR_MESSAGE = "Something went wrong while working on this.";
|
|
413
|
+
/** Tell the session or the issue that the run failed. */
|
|
414
|
+
async function reportError(_error, target) {
|
|
415
|
+
if (!isLinearTarget(target))
|
|
416
|
+
return;
|
|
417
|
+
if (target.sessionId) {
|
|
418
|
+
await createLinearActivity({
|
|
419
|
+
agentSessionId: target.sessionId,
|
|
420
|
+
content: { type: "error", body: ERROR_MESSAGE },
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
await createLinearComment({
|
|
425
|
+
issueId: target.issueId,
|
|
426
|
+
body: ERROR_MESSAGE,
|
|
427
|
+
...(target.commentId && { parentId: target.commentId }),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The conversation waits on a decision, made in LangSmith Studio: say so with
|
|
432
|
+
* a link. In a session it asks for input, so the session waits rather than
|
|
433
|
+
* ends: once the decision is made, the resumed run's answer is the session's
|
|
434
|
+
* response. Otherwise it is a comment.
|
|
435
|
+
*/
|
|
436
|
+
async function notifyPaused(target, run) {
|
|
437
|
+
if (!isLinearTarget(target))
|
|
438
|
+
return;
|
|
439
|
+
const url = studioUrl(run);
|
|
440
|
+
const studio = url ? `[LangSmith Studio](${url})` : "LangSmith Studio";
|
|
441
|
+
const body = run.stopped
|
|
442
|
+
? `I need a decision before I continue. Review the pending step in ${studio}.`
|
|
443
|
+
: `I'm waiting for a decision on an earlier step, so I haven't read this yet. Review the pending step in ${studio}, then ask again.`;
|
|
444
|
+
if (target.sessionId) {
|
|
445
|
+
await createLinearActivity({
|
|
446
|
+
agentSessionId: target.sessionId,
|
|
447
|
+
content: { type: "elicitation", body },
|
|
448
|
+
});
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
await createLinearComment({
|
|
452
|
+
issueId: target.issueId,
|
|
453
|
+
body,
|
|
454
|
+
...(target.commentId && { parentId: target.commentId }),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
function isLinearTarget(value) {
|
|
458
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
459
|
+
return false;
|
|
460
|
+
const { organizationId, issueId, identifier, commentId, sessionId } = value;
|
|
461
|
+
const nonblank = (field) => typeof field === "string" && field.trim().length > 0;
|
|
462
|
+
return (nonblank(organizationId) &&
|
|
463
|
+
nonblank(issueId) &&
|
|
464
|
+
[identifier, commentId, sessionId].every((field) => field === undefined || nonblank(field)));
|
|
465
|
+
}
|
|
466
|
+
//# sourceMappingURL=index.js.map
|