@vellumai/assistant 0.4.46 → 0.4.48
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/ARCHITECTURE.md +5 -5
- package/docs/architecture/security.md +5 -5
- package/package.json +1 -1
- package/src/__tests__/browser-fill-credential.test.ts +5 -2
- package/src/__tests__/bundled-skill-retrieval-guard.test.ts +2 -1
- package/src/__tests__/channel-readiness-routes.test.ts +20 -19
- package/src/__tests__/cli.test.ts +23 -0
- package/src/__tests__/credential-broker-browser-fill.test.ts +23 -22
- package/src/__tests__/credential-broker-server-use.test.ts +22 -21
- package/src/__tests__/credential-broker.test.ts +2 -1
- package/src/__tests__/credential-metadata-store.test.ts +240 -18
- package/src/__tests__/credential-resolve.test.ts +5 -4
- package/src/__tests__/credential-security-e2e.test.ts +8 -8
- package/src/__tests__/credential-security-invariants.test.ts +104 -6
- package/src/__tests__/credential-vault-unit.test.ts +22 -20
- package/src/__tests__/credential-vault.test.ts +284 -12
- package/src/__tests__/credentials-cli.test.ts +11 -6
- package/src/__tests__/gateway-only-enforcement.test.ts +4 -2
- package/src/__tests__/gemini-image-service.test.ts +75 -45
- package/src/__tests__/gemini-provider.test.ts +9 -6
- package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -33
- package/src/__tests__/guardian-action-copy-generator.test.ts +0 -20
- package/src/__tests__/guardian-action-followup-executor.test.ts +1 -28
- package/src/__tests__/guardian-action-followup-store.test.ts +1 -1
- package/src/__tests__/guardian-grant-minting.test.ts +35 -0
- package/src/__tests__/integration-status.test.ts +53 -21
- package/src/__tests__/managed-proxy-context.test.ts +5 -3
- package/src/__tests__/media-generate-image.test.ts +63 -2
- package/src/__tests__/media-reuse-story.e2e.test.ts +7 -3
- package/src/__tests__/messaging-send-tool.test.ts +4 -6
- package/src/__tests__/provider-fail-open-selection.test.ts +3 -1
- package/src/__tests__/provider-managed-proxy-integration.test.ts +70 -6
- package/src/__tests__/schema-transforms.test.ts +226 -0
- package/src/__tests__/script-proxy-injection-runtime.test.ts +23 -13
- package/src/__tests__/script-proxy-policy-runtime.test.ts +1 -1
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -1
- package/src/__tests__/secret-onetime-send.test.ts +5 -3
- package/src/__tests__/session-messaging-secret-redirect.test.ts +5 -4
- package/src/__tests__/skills-uninstall.test.ts +2 -2
- package/src/__tests__/skills.test.ts +0 -9
- package/src/__tests__/slack-channel-config.test.ts +9 -8
- package/src/__tests__/slack-share-routes.test.ts +11 -6
- package/src/__tests__/telegram-bot-username-resolution.test.ts +3 -0
- package/src/__tests__/twilio-config.test.ts +2 -1
- package/src/__tests__/twilio-provider.test.ts +4 -2
- package/src/__tests__/twilio-routes.test.ts +5 -4
- package/src/calls/call-domain.ts +7 -4
- package/src/calls/twilio-config.ts +2 -1
- package/src/calls/twilio-provider.ts +2 -1
- package/src/calls/twilio-rest.ts +2 -1
- package/src/cli/commands/browser-relay.ts +40 -15
- package/src/cli/commands/credentials.ts +9 -8
- package/src/cli/commands/oauth.ts +1 -1
- package/src/cli.ts +3 -2
- package/src/config/bundled-skills/claude-code/TOOLS.json +0 -4
- package/src/config/bundled-skills/contacts/tools/google-contacts.ts +29 -32
- package/src/config/bundled-skills/gmail/SKILL.md +4 -4
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +54 -61
- package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +25 -28
- package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +14 -17
- package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +39 -44
- package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +61 -58
- package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +50 -49
- package/src/config/bundled-skills/gmail/tools/gmail-label.ts +11 -13
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +148 -146
- package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +4 -7
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +175 -173
- package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +4 -7
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +71 -76
- package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +32 -38
- package/src/config/bundled-skills/google-calendar/SKILL.md +2 -2
- package/src/config/bundled-skills/google-calendar/calendar-client.ts +70 -29
- package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +9 -10
- package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +5 -6
- package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +4 -5
- package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +14 -15
- package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +37 -37
- package/src/config/bundled-skills/google-calendar/tools/shared.ts +4 -9
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +24 -3
- package/src/config/bundled-skills/messaging/SKILL.md +6 -6
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +62 -63
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +15 -16
- package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +4 -5
- package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +6 -7
- package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +4 -5
- package/src/config/bundled-skills/messaging/tools/messaging-read.ts +14 -15
- package/src/config/bundled-skills/messaging/tools/messaging-search.ts +4 -5
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +128 -128
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +33 -34
- package/src/config/bundled-skills/messaging/tools/shared.ts +11 -11
- package/src/config/bundled-skills/slack/tools/shared.ts +4 -10
- package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +4 -5
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +15 -16
- package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +4 -5
- package/src/config/bundled-skills/slack/tools/slack-edit-message.ts +4 -5
- package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +4 -5
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +95 -92
- package/src/config/loader.ts +6 -0
- package/src/daemon/computer-use-session.ts +7 -1
- package/src/daemon/guardian-action-generators.ts +4 -5
- package/src/daemon/handlers/config-slack-channel.ts +37 -20
- package/src/daemon/handlers/config-telegram.ts +33 -20
- package/src/daemon/lifecycle.ts +9 -1
- package/src/daemon/message-types/integrations.ts +1 -0
- package/src/daemon/ride-shotgun-handler.ts +3 -1
- package/src/daemon/session-messaging.ts +3 -1
- package/src/daemon/session-tool-setup.ts +18 -2
- package/src/daemon/session.ts +1 -1
- package/src/email/providers/index.ts +2 -1
- package/src/instrument.ts +15 -1
- package/src/media/app-icon-generator.ts +30 -4
- package/src/media/avatar-router.ts +26 -3
- package/src/media/gemini-image-service.ts +28 -2
- package/src/memory/guardian-action-store.ts +1 -1
- package/src/memory/schema/guardian.ts +1 -1
- package/src/messaging/provider.ts +16 -10
- package/src/messaging/providers/gmail/adapter.ts +40 -23
- package/src/messaging/providers/gmail/client.ts +203 -122
- package/src/messaging/providers/gmail/people-client.ts +26 -18
- package/src/messaging/providers/slack/adapter.ts +29 -19
- package/src/messaging/providers/slack/client.ts +265 -78
- package/src/messaging/providers/telegram-bot/adapter.ts +5 -4
- package/src/messaging/providers/whatsapp/adapter.ts +6 -3
- package/src/messaging/registry.ts +2 -1
- package/src/oauth/byo-connection.test.ts +436 -0
- package/src/oauth/byo-connection.ts +112 -0
- package/src/oauth/connect-orchestrator.ts +27 -0
- package/src/oauth/connection-resolver.ts +34 -0
- package/src/oauth/connection.ts +38 -0
- package/src/oauth/platform-connection.test.ts +163 -0
- package/src/oauth/platform-connection.ts +110 -0
- package/src/oauth/provider-base-urls.ts +21 -0
- package/src/oauth/provider-profiles.ts +1 -1
- package/src/oauth/token-persistence.ts +20 -20
- package/src/permissions/checker.ts +5 -1
- package/src/prompts/system-prompt.ts +49 -12
- package/src/providers/gemini/client.ts +15 -6
- package/src/providers/managed-proxy/constants.ts +2 -2
- package/src/providers/managed-proxy/context.ts +5 -1
- package/src/providers/ratelimit.ts +17 -0
- package/src/providers/registry.ts +2 -2
- package/src/runtime/AGENTS.md +17 -0
- package/src/runtime/channel-invite-transports/telegram.ts +2 -1
- package/src/runtime/channel-readiness-service.ts +168 -195
- package/src/runtime/channel-readiness-types.ts +4 -0
- package/src/runtime/guardian-action-conversation-turn.ts +1 -3
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-action-message-composer.ts +3 -23
- package/src/runtime/http-server.ts +9 -4
- package/src/runtime/http-types.ts +0 -1
- package/src/runtime/middleware/rate-limiter.ts +74 -20
- package/src/runtime/routes/channel-readiness-routes.ts +2 -0
- package/src/runtime/routes/diagnostics-routes.ts +11 -9
- package/src/runtime/routes/guardian-approval-interception.ts +20 -5
- package/src/runtime/routes/integrations/slack/share.ts +3 -2
- package/src/runtime/routes/integrations/twilio.ts +6 -5
- package/src/runtime/routes/secret-routes.ts +3 -2
- package/src/runtime/routes/settings-routes.ts +75 -17
- package/src/runtime/telegram-streaming-delivery.test.ts +132 -0
- package/src/runtime/telegram-streaming-delivery.ts +11 -1
- package/src/schedule/integration-status.ts +5 -4
- package/src/security/credential-key.ts +170 -0
- package/src/security/token-manager.ts +36 -7
- package/src/tools/apps/definitions.ts +0 -5
- package/src/tools/assets/materialize.ts +0 -5
- package/src/tools/assets/search.ts +0 -5
- package/src/tools/browser/headless-browser.ts +1 -67
- package/src/tools/claude-code/claude-code.ts +0 -5
- package/src/tools/computer-use/request-computer-control.ts +0 -5
- package/src/tools/credentials/broker.ts +6 -4
- package/src/tools/credentials/metadata-store.ts +72 -20
- package/src/tools/credentials/resolve.ts +2 -1
- package/src/tools/credentials/vault.ts +77 -16
- package/src/tools/filesystem/edit.ts +1 -6
- package/src/tools/filesystem/read.ts +0 -5
- package/src/tools/filesystem/write.ts +1 -6
- package/src/tools/host-filesystem/edit.ts +1 -6
- package/src/tools/host-filesystem/read.ts +1 -6
- package/src/tools/host-filesystem/write.ts +1 -6
- package/src/tools/mcp/mcp-tool-factory.ts +18 -1
- package/src/tools/memory/definitions.ts +0 -5
- package/src/tools/network/web-fetch.ts +0 -5
- package/src/tools/network/web-search.ts +0 -5
- package/src/tools/schema-transforms.ts +99 -0
- package/src/tools/skills/load.ts +0 -5
- package/src/tools/swarm/delegate.ts +0 -5
- package/src/tools/system/avatar-generator.ts +0 -5
- package/src/tools/ui-surface/definitions.ts +0 -15
- package/src/tools/watch/screen-watch.ts +0 -5
- package/src/version.ts +10 -0
- package/src/watcher/providers/github.ts +51 -52
- package/src/watcher/providers/gmail.ts +88 -80
- package/src/watcher/providers/google-calendar.ts +93 -86
- package/src/watcher/providers/linear.ts +87 -93
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
listEvents,
|
|
12
12
|
} from "../../config/bundled-skills/google-calendar/calendar-client.js";
|
|
13
13
|
import type { CalendarEvent } from "../../config/bundled-skills/google-calendar/types.js";
|
|
14
|
-
import {
|
|
14
|
+
import type { OAuthConnection } from "../../oauth/connection.js";
|
|
15
|
+
import { resolveOAuthConnection } from "../../oauth/connection-resolver.js";
|
|
16
|
+
import { GOOGLE_CALENDAR_BASE_URL } from "../../oauth/provider-base-urls.js";
|
|
15
17
|
import { getLogger } from "../../util/logger.js";
|
|
16
18
|
import type {
|
|
17
19
|
FetchResult,
|
|
@@ -21,8 +23,6 @@ import type {
|
|
|
21
23
|
|
|
22
24
|
const log = getLogger("watcher:google-calendar");
|
|
23
25
|
|
|
24
|
-
const CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3";
|
|
25
|
-
|
|
26
26
|
/** The credential service — calendar shares OAuth tokens with Gmail. */
|
|
27
27
|
const CREDENTIAL_SERVICE = "integration:gmail";
|
|
28
28
|
|
|
@@ -69,7 +69,7 @@ interface SyncResponse {
|
|
|
69
69
|
* Returns all accumulated events and the final nextSyncToken.
|
|
70
70
|
*/
|
|
71
71
|
async function incrementalSync(
|
|
72
|
-
|
|
72
|
+
connection: OAuthConnection,
|
|
73
73
|
syncToken: string,
|
|
74
74
|
): Promise<SyncResponse> {
|
|
75
75
|
let allItems: CalendarEvent[] = [];
|
|
@@ -77,27 +77,32 @@ async function incrementalSync(
|
|
|
77
77
|
let nextSyncToken: string | undefined;
|
|
78
78
|
|
|
79
79
|
do {
|
|
80
|
-
const
|
|
81
|
-
if (pageToken)
|
|
82
|
-
|
|
83
|
-
const resp = await
|
|
84
|
-
|
|
80
|
+
const query: Record<string, string> = { syncToken };
|
|
81
|
+
if (pageToken) query.pageToken = pageToken;
|
|
82
|
+
|
|
83
|
+
const resp = await connection.request({
|
|
84
|
+
method: "GET",
|
|
85
|
+
path: "/calendars/primary/events",
|
|
86
|
+
query,
|
|
87
|
+
baseUrl: GOOGLE_CALENDAR_BASE_URL,
|
|
85
88
|
});
|
|
86
89
|
|
|
87
|
-
if (
|
|
88
|
-
const
|
|
90
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
91
|
+
const bodyStr =
|
|
92
|
+
typeof resp.body === "string"
|
|
93
|
+
? resp.body
|
|
94
|
+
: JSON.stringify(resp.body ?? "");
|
|
89
95
|
if (resp.status === 410) {
|
|
90
|
-
throw new SyncTokenExpiredError(
|
|
96
|
+
throw new SyncTokenExpiredError(bodyStr);
|
|
91
97
|
}
|
|
92
|
-
// Throw CalendarApiError so withValidToken can detect 401s via the status property
|
|
93
98
|
throw new CalendarApiError(
|
|
94
99
|
resp.status,
|
|
95
|
-
|
|
96
|
-
`Calendar Sync API ${resp.status}: ${
|
|
100
|
+
"",
|
|
101
|
+
`Calendar Sync API ${resp.status}: ${bodyStr}`,
|
|
97
102
|
);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
const page =
|
|
105
|
+
const page = resp.body as SyncResponse;
|
|
101
106
|
if (page.items) allItems = allItems.concat(page.items);
|
|
102
107
|
pageToken = page.nextPageToken;
|
|
103
108
|
nextSyncToken = page.nextSyncToken;
|
|
@@ -119,16 +124,48 @@ export const googleCalendarProvider: WatcherProvider = {
|
|
|
119
124
|
requiredCredentialService: CREDENTIAL_SERVICE,
|
|
120
125
|
|
|
121
126
|
async getInitialWatermark(credentialService: string): Promise<string> {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
const connection = resolveOAuthConnection(credentialService);
|
|
128
|
+
|
|
129
|
+
// Do a full sync with a narrow window to get the initial syncToken.
|
|
130
|
+
// The API may paginate even for small result sets, so follow nextPageToken
|
|
131
|
+
// until we reach the final page that carries the nextSyncToken.
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
let pageToken: string | undefined;
|
|
134
|
+
let syncToken: string | undefined;
|
|
135
|
+
|
|
136
|
+
do {
|
|
137
|
+
const result = await listEvents(connection, "primary", {
|
|
138
|
+
timeMin: now,
|
|
139
|
+
maxResults: 250,
|
|
140
|
+
singleEvents: true,
|
|
141
|
+
pageToken,
|
|
142
|
+
});
|
|
143
|
+
syncToken = result.nextSyncToken;
|
|
144
|
+
pageToken = result.nextPageToken;
|
|
145
|
+
} while (pageToken && !syncToken);
|
|
146
|
+
|
|
147
|
+
if (!syncToken) {
|
|
148
|
+
throw new Error("Calendar API did not return a syncToken");
|
|
149
|
+
}
|
|
150
|
+
return syncToken;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async fetchNew(
|
|
154
|
+
credentialService: string,
|
|
155
|
+
watermark: string | null,
|
|
156
|
+
_config: Record<string, unknown>,
|
|
157
|
+
_watcherKey: string,
|
|
158
|
+
): Promise<FetchResult> {
|
|
159
|
+
const connection = resolveOAuthConnection(credentialService);
|
|
160
|
+
|
|
161
|
+
if (!watermark) {
|
|
162
|
+
// No watermark — paginate through to get the initial syncToken, return no items
|
|
126
163
|
const now = new Date().toISOString();
|
|
127
164
|
let pageToken: string | undefined;
|
|
128
165
|
let syncToken: string | undefined;
|
|
129
166
|
|
|
130
167
|
do {
|
|
131
|
-
const result = await listEvents(
|
|
168
|
+
const result = await listEvents(connection, "primary", {
|
|
132
169
|
timeMin: now,
|
|
133
170
|
maxResults: 250,
|
|
134
171
|
singleEvents: true,
|
|
@@ -138,82 +175,52 @@ export const googleCalendarProvider: WatcherProvider = {
|
|
|
138
175
|
pageToken = result.nextPageToken;
|
|
139
176
|
} while (pageToken && !syncToken);
|
|
140
177
|
|
|
141
|
-
|
|
142
|
-
|
|
178
|
+
return { items: [], watermark: syncToken ?? "" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const syncResp = await incrementalSync(connection, watermark);
|
|
183
|
+
const newWatermark = syncResp.nextSyncToken ?? watermark;
|
|
184
|
+
|
|
185
|
+
if (!syncResp.items || syncResp.items.length === 0) {
|
|
186
|
+
return { items: [], watermark: newWatermark };
|
|
143
187
|
}
|
|
144
|
-
return syncToken;
|
|
145
|
-
});
|
|
146
|
-
},
|
|
147
188
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
let pageToken: string | undefined;
|
|
159
|
-
let syncToken: string | undefined;
|
|
160
|
-
|
|
161
|
-
do {
|
|
162
|
-
const result = await listEvents(token, "primary", {
|
|
163
|
-
timeMin: now,
|
|
164
|
-
maxResults: 250,
|
|
165
|
-
singleEvents: true,
|
|
166
|
-
pageToken,
|
|
167
|
-
});
|
|
168
|
-
syncToken = result.nextSyncToken;
|
|
169
|
-
pageToken = result.nextPageToken;
|
|
170
|
-
} while (pageToken && !syncToken);
|
|
171
|
-
|
|
172
|
-
return { items: [], watermark: syncToken ?? "" };
|
|
189
|
+
// Convert events to watcher items, distinguishing new vs updated
|
|
190
|
+
const items: WatcherItem[] = [];
|
|
191
|
+
for (const event of syncResp.items) {
|
|
192
|
+
if (event.status === "cancelled") continue;
|
|
193
|
+
|
|
194
|
+
const eventType =
|
|
195
|
+
event.created === event.updated
|
|
196
|
+
? "new_calendar_event"
|
|
197
|
+
: "updated_calendar_event";
|
|
198
|
+
items.push(eventToItem(event, eventType));
|
|
173
199
|
}
|
|
174
200
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const items: WatcherItem[] = [];
|
|
185
|
-
for (const event of syncResp.items) {
|
|
186
|
-
if (event.status === "cancelled") continue;
|
|
187
|
-
|
|
188
|
-
const eventType =
|
|
189
|
-
event.created === event.updated
|
|
190
|
-
? "new_calendar_event"
|
|
191
|
-
: "updated_calendar_event";
|
|
192
|
-
items.push(eventToItem(event, eventType));
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
log.info(
|
|
196
|
-
{ count: items.length, watermark: newWatermark },
|
|
197
|
-
"Calendar: fetched event changes",
|
|
198
|
-
);
|
|
199
|
-
return { items, watermark: newWatermark };
|
|
200
|
-
} catch (err) {
|
|
201
|
-
if (err instanceof SyncTokenExpiredError) {
|
|
202
|
-
log.warn("Calendar syncToken expired, falling back to recent events");
|
|
203
|
-
return fallbackFetch(token);
|
|
204
|
-
}
|
|
205
|
-
throw err;
|
|
201
|
+
log.info(
|
|
202
|
+
{ count: items.length, watermark: newWatermark },
|
|
203
|
+
"Calendar: fetched event changes",
|
|
204
|
+
);
|
|
205
|
+
return { items, watermark: newWatermark };
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (err instanceof SyncTokenExpiredError) {
|
|
208
|
+
log.warn("Calendar syncToken expired, falling back to recent events");
|
|
209
|
+
return fallbackFetch(connection);
|
|
206
210
|
}
|
|
207
|
-
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
208
213
|
},
|
|
209
214
|
};
|
|
210
215
|
|
|
211
216
|
/**
|
|
212
217
|
* Fallback when syncToken expires: list upcoming events from today.
|
|
213
218
|
*/
|
|
214
|
-
async function fallbackFetch(
|
|
219
|
+
async function fallbackFetch(
|
|
220
|
+
connection: OAuthConnection,
|
|
221
|
+
): Promise<FetchResult> {
|
|
215
222
|
const now = new Date().toISOString();
|
|
216
|
-
const result = await listEvents(
|
|
223
|
+
const result = await listEvents(connection, "primary", {
|
|
217
224
|
timeMin: now,
|
|
218
225
|
maxResults: 25,
|
|
219
226
|
singleEvents: true,
|
|
@@ -229,7 +236,7 @@ async function fallbackFetch(token: string): Promise<FetchResult> {
|
|
|
229
236
|
let syncToken: string | undefined;
|
|
230
237
|
|
|
231
238
|
do {
|
|
232
|
-
const syncResult = await listEvents(
|
|
239
|
+
const syncResult = await listEvents(connection, "primary", {
|
|
233
240
|
timeMin: now,
|
|
234
241
|
maxResults: 250,
|
|
235
242
|
singleEvents: true,
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* and issues.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import type { OAuthConnection } from "../../oauth/connection.js";
|
|
17
|
+
import { resolveOAuthConnection } from "../../oauth/connection-resolver.js";
|
|
17
18
|
import { getLogger } from "../../util/logger.js";
|
|
18
19
|
import { truncate } from "../../util/truncate.js";
|
|
19
20
|
import type {
|
|
@@ -24,8 +25,6 @@ import type {
|
|
|
24
25
|
|
|
25
26
|
const log = getLogger("watcher:linear");
|
|
26
27
|
|
|
27
|
-
const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
28
|
-
|
|
29
28
|
// ── GraphQL response types ────────────────────────────────────────────────────
|
|
30
29
|
|
|
31
30
|
interface LinearNotification {
|
|
@@ -89,27 +88,23 @@ interface LinearViewer {
|
|
|
89
88
|
// ── GraphQL helpers ───────────────────────────────────────────────────────────
|
|
90
89
|
|
|
91
90
|
async function graphql<T>(
|
|
92
|
-
|
|
91
|
+
connection: OAuthConnection,
|
|
93
92
|
query: string,
|
|
94
93
|
variables?: Record<string, unknown>,
|
|
95
94
|
): Promise<T> {
|
|
96
|
-
const resp = await
|
|
95
|
+
const resp = await connection.request({
|
|
97
96
|
method: "POST",
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// is required for all token types per Linear's API docs.
|
|
101
|
-
Authorization: `Bearer ${token}`,
|
|
102
|
-
"Content-Type": "application/json",
|
|
103
|
-
},
|
|
104
|
-
body: JSON.stringify({ query, variables }),
|
|
97
|
+
path: "/graphql",
|
|
98
|
+
body: { query, variables },
|
|
105
99
|
});
|
|
106
100
|
|
|
107
|
-
if (
|
|
108
|
-
const body =
|
|
101
|
+
if (resp.status >= 400) {
|
|
102
|
+
const body =
|
|
103
|
+
typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body);
|
|
109
104
|
throw new Error(`Linear API ${resp.status}: ${body}`);
|
|
110
105
|
}
|
|
111
106
|
|
|
112
|
-
const result =
|
|
107
|
+
const result = resp.body as {
|
|
113
108
|
data?: T;
|
|
114
109
|
errors?: Array<{ message: string }>;
|
|
115
110
|
};
|
|
@@ -128,9 +123,9 @@ async function graphql<T>(
|
|
|
128
123
|
}
|
|
129
124
|
|
|
130
125
|
/** Fetch the authenticated user's ID and name. */
|
|
131
|
-
async function fetchViewer(
|
|
126
|
+
async function fetchViewer(connection: OAuthConnection): Promise<LinearViewer> {
|
|
132
127
|
const data = await graphql<{ viewer: LinearViewer }>(
|
|
133
|
-
|
|
128
|
+
connection,
|
|
134
129
|
`
|
|
135
130
|
query {
|
|
136
131
|
viewer {
|
|
@@ -150,7 +145,7 @@ async function fetchViewer(token: string): Promise<LinearViewer> {
|
|
|
150
145
|
* between polls.
|
|
151
146
|
*/
|
|
152
147
|
async function fetchNotifications(
|
|
153
|
-
|
|
148
|
+
connection: OAuthConnection,
|
|
154
149
|
since: string,
|
|
155
150
|
): Promise<LinearNotification[]> {
|
|
156
151
|
const allNodes: LinearNotification[] = [];
|
|
@@ -165,7 +160,7 @@ async function fetchNotifications(
|
|
|
165
160
|
|
|
166
161
|
do {
|
|
167
162
|
const data: NotificationsResponse = await graphql<NotificationsResponse>(
|
|
168
|
-
|
|
163
|
+
connection,
|
|
169
164
|
`
|
|
170
165
|
query FetchNotifications($after: DateTime, $cursor: String) {
|
|
171
166
|
notifications(
|
|
@@ -242,7 +237,7 @@ async function fetchNotifications(
|
|
|
242
237
|
* `pageInfo.hasNextPage` is false so updates beyond the first 50 aren't skipped.
|
|
243
238
|
*/
|
|
244
239
|
async function fetchAssignedIssueUpdates(
|
|
245
|
-
|
|
240
|
+
connection: OAuthConnection,
|
|
246
241
|
viewerId: string,
|
|
247
242
|
since: string,
|
|
248
243
|
): Promise<LinearIssue[]> {
|
|
@@ -258,7 +253,7 @@ async function fetchAssignedIssueUpdates(
|
|
|
258
253
|
|
|
259
254
|
do {
|
|
260
255
|
const data: IssuesResponse = await graphql<IssuesResponse>(
|
|
261
|
-
|
|
256
|
+
connection,
|
|
262
257
|
`
|
|
263
258
|
query FetchAssignedIssues(
|
|
264
259
|
$assigneeId: ID
|
|
@@ -319,7 +314,7 @@ async function fetchAssignedIssueUpdates(
|
|
|
319
314
|
* complete set — needed for accurate eviction and reassignment detection.
|
|
320
315
|
*/
|
|
321
316
|
async function fetchAllAssignedIssueIds(
|
|
322
|
-
|
|
317
|
+
connection: OAuthConnection,
|
|
323
318
|
viewerId: string,
|
|
324
319
|
): Promise<Set<string>> {
|
|
325
320
|
const ids = new Set<string>();
|
|
@@ -334,7 +329,7 @@ async function fetchAllAssignedIssueIds(
|
|
|
334
329
|
|
|
335
330
|
do {
|
|
336
331
|
const data: IdsResponse = await graphql<IdsResponse>(
|
|
337
|
-
|
|
332
|
+
connection,
|
|
338
333
|
`
|
|
339
334
|
query FetchAllAssignedIssueIds($assigneeId: ID, $cursor: String) {
|
|
340
335
|
issues(
|
|
@@ -517,87 +512,86 @@ export const linearProvider: WatcherProvider = {
|
|
|
517
512
|
_config: Record<string, unknown>,
|
|
518
513
|
watcherKey: string,
|
|
519
514
|
): Promise<FetchResult> {
|
|
520
|
-
|
|
521
|
-
|
|
515
|
+
const connection = resolveOAuthConnection(credentialService);
|
|
516
|
+
const since = watermark ?? new Date().toISOString();
|
|
522
517
|
|
|
523
|
-
|
|
524
|
-
|
|
518
|
+
// Resolve the authenticated viewer's ID once per poll for the assigned-issues query
|
|
519
|
+
const viewer = await fetchViewer(connection);
|
|
525
520
|
|
|
526
|
-
|
|
527
|
-
|
|
521
|
+
// Fetch notifications (assignments, mentions, status changes via notification feed)
|
|
522
|
+
const notifications = await fetchNotifications(connection, since);
|
|
528
523
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
524
|
+
// Only surface notification types that warrant attention
|
|
525
|
+
const relevantTypes = new Set([
|
|
526
|
+
"issueAssignedToYou",
|
|
527
|
+
"issueMentionedYou",
|
|
528
|
+
"issueCommentMentionedYou",
|
|
529
|
+
"issueStatusChanged",
|
|
530
|
+
]);
|
|
536
531
|
|
|
537
|
-
|
|
532
|
+
const items: WatcherItem[] = [];
|
|
538
533
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
534
|
+
for (const n of notifications) {
|
|
535
|
+
if (!relevantTypes.has(n.type)) continue;
|
|
536
|
+
items.push(notificationToItem(n));
|
|
537
|
+
}
|
|
543
538
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
}
|
|
579
|
-
stateCache.set(issue.id, issue.state.id);
|
|
539
|
+
// Fetch the complete set of currently assigned issue IDs (no updatedAt
|
|
540
|
+
// filter) so we can accurately evict stale cache entries and guard against
|
|
541
|
+
// false-positive status change events on reassignment.
|
|
542
|
+
const currentAssignedIds = await fetchAllAssignedIssueIds(
|
|
543
|
+
connection,
|
|
544
|
+
viewer.id,
|
|
545
|
+
);
|
|
546
|
+
const previousAssignedIds = lastSeenAssignedIdsByWatcher.get(watcherKey);
|
|
547
|
+
|
|
548
|
+
// Also poll assigned issues directly for status changes not covered by
|
|
549
|
+
// notifications (e.g., bulk team updates). We only emit an event when the
|
|
550
|
+
// state ID differs from what we recorded on the previous poll — any other
|
|
551
|
+
// field update (title, description, etc.) does not constitute a status change.
|
|
552
|
+
// On first sight of an issue we seed the map without emitting, so we don't
|
|
553
|
+
// fire false-positive events after a daemon restart.
|
|
554
|
+
const assignedIssues = await fetchAssignedIssueUpdates(
|
|
555
|
+
connection,
|
|
556
|
+
viewer.id,
|
|
557
|
+
since,
|
|
558
|
+
);
|
|
559
|
+
const stateCache = getStateCache(watcherKey);
|
|
560
|
+
for (const issue of assignedIssues) {
|
|
561
|
+
const previousStateId = stateCache.get(issue.id);
|
|
562
|
+
// Only emit a status change if: (1) we have a cached state that differs,
|
|
563
|
+
// AND (2) the issue was also assigned in the previous poll. Condition (2)
|
|
564
|
+
// prevents false-positive events when an issue is unassigned, changes
|
|
565
|
+
// state while unassigned, and is then reassigned.
|
|
566
|
+
const wasPreviouslySeen = previousAssignedIds?.has(issue.id) ?? false;
|
|
567
|
+
if (
|
|
568
|
+
previousStateId !== undefined &&
|
|
569
|
+
previousStateId !== issue.state.id &&
|
|
570
|
+
wasPreviouslySeen
|
|
571
|
+
) {
|
|
572
|
+
items.push(issueToStatusChangeItem(issue, previousStateId));
|
|
580
573
|
}
|
|
574
|
+
stateCache.set(issue.id, issue.state.id);
|
|
575
|
+
}
|
|
581
576
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
}
|
|
577
|
+
// Evict cached state for issues that left the assigned set so stale
|
|
578
|
+
// entries don't accumulate and don't cause false-positive events if the
|
|
579
|
+
// issue is later reassigned.
|
|
580
|
+
if (previousAssignedIds) {
|
|
581
|
+
for (const id of previousAssignedIds) {
|
|
582
|
+
if (!currentAssignedIds.has(id)) {
|
|
583
|
+
stateCache.delete(id);
|
|
590
584
|
}
|
|
591
585
|
}
|
|
592
|
-
|
|
586
|
+
}
|
|
587
|
+
lastSeenAssignedIdsByWatcher.set(watcherKey, currentAssignedIds);
|
|
593
588
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
589
|
+
const newWatermark = new Date().toISOString();
|
|
590
|
+
log.info(
|
|
591
|
+
{ count: items.length, viewer: viewer.name, watermark: newWatermark },
|
|
592
|
+
"Linear: fetched new notifications",
|
|
593
|
+
);
|
|
599
594
|
|
|
600
|
-
|
|
601
|
-
});
|
|
595
|
+
return { items, watermark: newWatermark };
|
|
602
596
|
},
|
|
603
597
|
};
|