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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cloud-team-implementer/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
* and duplicate PRs for the same issue — so this handler declares no
* triggers and only logs if cloud ever routes an event to it directly.
*/
import { defineAgent, type WorkforceCtx, type WorkforceEvent } from '@agentworkforce/runtime';
import { defineAgent, isCronTickEvent, type WorkforceCtx, type WorkforceEvent } from '@agentworkforce/runtime';

export async function handleUnexpectedEvent(ctx: WorkforceCtx, event: WorkforceEvent): Promise<void> {
ctx.log('warn', 'cloud-team-implementer received a direct event; members are launched by the team dispatcher, not by subscriptions', {
eventId: event.id,
source: event.source,
type: 'type' in event ? event.type : undefined
source: isCronTickEvent(event) ? 'cron' : event.resource.provider,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since handleUnexpectedEvent is specifically designed to handle unexpected or malformed events, we should not assume that event.resource is defined or has a provider property. Accessing event.resource.provider directly could throw a TypeError and crash the logging/handling flow. Use optional chaining to safely access the provider.

Suggested change
source: isCronTickEvent(event) ? 'cron' : event.resource.provider,
source: isCronTickEvent(event) ? 'cron' : event.resource?.provider,

type: event.type
});
}

Expand Down
6 changes: 3 additions & 3 deletions cloud-team-reviewer/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
* and duplicate reviews for the same issue — so this handler declares no
* triggers and only logs if cloud ever routes an event to it directly.
*/
import { defineAgent, type WorkforceCtx, type WorkforceEvent } from '@agentworkforce/runtime';
import { defineAgent, isCronTickEvent, type WorkforceCtx, type WorkforceEvent } from '@agentworkforce/runtime';

export async function handleUnexpectedEvent(ctx: WorkforceCtx, event: WorkforceEvent): Promise<void> {
ctx.log('warn', 'cloud-team-reviewer received a direct event; members are launched by the team dispatcher, not by subscriptions', {
eventId: event.id,
source: event.source,
type: 'type' in event ? event.type : undefined
source: isCronTickEvent(event) ? 'cron' : event.resource.provider,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since handleUnexpectedEvent is specifically designed to handle unexpected or malformed events, we should not assume that event.resource is defined or has a provider property. Accessing event.resource.provider directly could throw a TypeError and crash the logging/handling flow. Use optional chaining to safely access the provider.

Suggested change
source: isCronTickEvent(event) ? 'cron' : event.resource.provider,
source: isCronTickEvent(event) ? 'cron' : event.resource?.provider,

type: event.type
});
}

Expand Down
2 changes: 1 addition & 1 deletion granola/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default defineAgent({
handler: async (ctx, event) => {
// Notes arrive via the Nango sync as storage events (defineAgent narrows
// `event` to the declared granola trigger, so there's no clock case here).
const notePath = readNotePath(event.payload);
const notePath = readNotePath((await event.expand('full')).data);
if (!notePath || !notePath.includes('/granola/notes/')) return; // ignore folders/other writes

const transcript = await readNote(ctx, notePath);
Expand Down
4 changes: 2 additions & 2 deletions hn-monitor/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* → summarize with ctx.llm
* → post to Slack
*/
import { defineAgent, type WorkforceCtx } from '@agentworkforce/runtime';
import { defineAgent, isCronTickEvent, type WorkforceCtx } from '@agentworkforce/runtime';
import { slackClient } from '@relayfile/relay-helpers';

export interface Story {
Expand All @@ -21,7 +21,7 @@ export default defineAgent({
// Runs on a clock (09:00 & 17:00), not an event. No triggers needed.
schedules: [{ name: 'scan', cron: '0 9,17 * * *', tz: 'America/New_York' }],
handler: async (ctx, event) => {
if (event.source !== 'cron') return;
if (!isCronTickEvent(event)) return;

const channel = input(ctx, 'SLACK_CHANNEL');
if (!channel) throw new Error('SLACK_CHANNEL is required');
Expand Down
10 changes: 5 additions & 5 deletions linear-slack/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import {
defineAgent,
type WorkforceCtx,
type WorkforceProviderEvent,
type WorkforceEvent,
} from '@agentworkforce/runtime';
import { linearClient, slackClient } from '@relayfile/relay-helpers';

Expand Down Expand Up @@ -115,16 +115,16 @@ export default defineAgent({

export async function handleSlackEvent(
ctx: WorkforceCtx,
event: WorkforceProviderEvent,
event: WorkforceEvent,
slack: SlackClientLike,
linear: LinearWriteClient,
): Promise<void> {
if (event.source !== 'slack') {
if (!event.type.startsWith('slack.')) {
logSkip(ctx, event, 'non-slack event source');
return;
}

const msg = readSlackMessage(event.payload);
const msg = readSlackMessage((await event.expand('full')).data);
if (!msg) {
logSkip(ctx, event, 'unparseable slack payload');
return;
Expand Down Expand Up @@ -412,7 +412,7 @@ function errorMessage(error: unknown): string {

function logSkip(
ctx: WorkforceCtx,
event: WorkforceProviderEvent,
event: WorkforceEvent,
reason: string,
attrs: Record<string, unknown> = {},
): void {
Expand Down
54 changes: 29 additions & 25 deletions linear/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import {
defineAgent,
type WorkforceCtx,
type WorkforceProviderEvent,
type WorkforceEvent,
} from '@agentworkforce/runtime';
import { linearClient } from '@relayfile/relay-helpers';

Expand Down Expand Up @@ -73,36 +73,38 @@ export default defineAgent({

export async function handleLinearEvent(
ctx: WorkforceCtx,
event: WorkforceProviderEvent,
event: WorkforceEvent,
linear: LinearClientLike,
): Promise<void> {
// v4: the provider payload is reached via expand (no sync `event.payload`).
const payload = (await event.expand('full')).data;
ctx.log?.('info', 'linear event', {
eventId: event.id,
type: event.type,
payloadKeys: payloadKeys(event.payload),
recordKeys: payloadKeys(linearRecordPayload(event.payload)),
hasIssueId: Boolean(readIssueId(event.payload, event.type)),
hasSessionId: Boolean(readSessionId(event.payload)),
payloadKeys: payloadKeys(payload),
recordKeys: payloadKeys(linearRecordPayload(payload)),
hasIssueId: Boolean(readIssueId(payload, event.type)),
hasSessionId: Boolean(readSessionId(payload)),
});

if (event.source !== 'linear') {
if (!event.type.startsWith('linear.')) {
logSkip(ctx, event, 'non-linear event source');
return;
}
Comment on lines +79 to 93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The event is expanded via await event.expand('full') before checking if the event is actually a Linear event. For non-Linear events (such as cron or other providers), this performs an unnecessary and potentially expensive network/database call to expand the event, and then logs attributes that might not exist or make sense. Moving the type check to the top of the function avoids this overhead.

Suggested change
// v4: the provider payload is reached via expand (no sync `event.payload`).
const payload = (await event.expand('full')).data;
ctx.log?.('info', 'linear event', {
eventId: event.id,
type: event.type,
payloadKeys: payloadKeys(event.payload),
recordKeys: payloadKeys(linearRecordPayload(event.payload)),
hasIssueId: Boolean(readIssueId(event.payload, event.type)),
hasSessionId: Boolean(readSessionId(event.payload)),
payloadKeys: payloadKeys(payload),
recordKeys: payloadKeys(linearRecordPayload(payload)),
hasIssueId: Boolean(readIssueId(payload, event.type)),
hasSessionId: Boolean(readSessionId(payload)),
});
if (event.source !== 'linear') {
if (!event.type.startsWith('linear.')) {
logSkip(ctx, event, 'non-linear event source');
return;
}
if (!event.type.startsWith('linear.')) {
logSkip(ctx, event, 'non-linear event source');
return;
}
// v4: the provider payload is reached via expand (no sync event.payload).
const payload = (await event.expand('full')).data;
ctx.log?.('info', 'linear event', {
eventId: event.id,
type: event.type,
payloadKeys: payloadKeys(payload),
recordKeys: payloadKeys(linearRecordPayload(payload)),
hasIssueId: Boolean(readIssueId(payload, event.type)),
hasSessionId: Boolean(readSessionId(payload)),
});


if (isOwnEvent(ctx, event)) {
if (isOwnEvent(ctx, event, payload)) {
logSkip(ctx, event, 'own activity');
return;
}

const eventContext = linearEventContext(event);
const eventContext = linearEventContext(event, payload);
if (!eventContext.issueId) {
logSkip(ctx, event, 'missing issue id');
return;
}

if (eventContext.fallbackComment) {
const mention = commentMentionsAgent(ctx, event.payload);
const mention = commentMentionsAgent(ctx, payload);
if (!mention.matched) {
logSkip(ctx, event, mention.reason, mention.attrs);
return;
Expand Down Expand Up @@ -138,14 +140,16 @@ export async function handleLinearEvent(
await rememberTurn(ctx, eventContext, 'assistant', intent.reply);
}

function linearEventContext(event: WorkforceProviderEvent): LinearEventContext {
const record = linearRecordPayload(event.payload) as Record<string, unknown>;
function linearEventContext(event: WorkforceEvent, payload: unknown): LinearEventContext {
const record = linearRecordPayload(payload) as Record<string, unknown>;
return {
record,
issueId: readIssueId(event.payload, event.type),
sessionId: readSessionId(event.payload),
body: readPromptBody(event.payload),
fallbackComment: event.type === 'AppUserNotification.issueCommentMention' || event.type === 'comment.create',
issueId: readIssueId(payload, event.type),
sessionId: readSessionId(payload),
body: readPromptBody(payload),
fallbackComment:
event.type === 'linear.AppUserNotification.issueCommentMention' ||
event.type === 'linear.comment.create',
};
}

Expand Down Expand Up @@ -175,7 +179,7 @@ async function replyToLinear(

async function classifyIntent(
ctx: WorkforceCtx,
event: WorkforceProviderEvent,
event: WorkforceEvent,
eventContext: LinearEventContext,
issue: LinearIssue,
history: string[],
Expand Down Expand Up @@ -219,7 +223,7 @@ function parseChatIntent(response: string, eventType: string, body: string): Cha
}

function inferIntent(eventType: string, body: string): ChatIntent['intent'] {
if (eventType === 'issue.create') return 'implement';
if (eventType === 'linear.issue.create') return 'implement';
return /\b(implement|fix|ship|code|open\s+(?:a\s+)?pr|pull request)\b/iu.test(body)
? 'implement'
: 'chat';
Expand Down Expand Up @@ -369,7 +373,7 @@ function readIssueId(payload: unknown, eventType?: string): string | undefined {
p?.issueId ??
p?.issue_id ??
p?.issue?.id ??
(eventType === 'comment.create' ? undefined : p?.data?.id ?? rec?.id)
(eventType === 'linear.comment.create' ? undefined : p?.data?.id ?? rec?.id)
);
}

Expand Down Expand Up @@ -430,12 +434,12 @@ function commentBody(payload: unknown): string {
);
}

function isOwnEvent(ctx: WorkforceCtx, event: WorkforceProviderEvent): boolean {
if (event.type === 'comment.create') {
return isOwnComment(ctx, event.payload);
function isOwnEvent(ctx: WorkforceCtx, event: WorkforceEvent, payload: unknown): boolean {
if (event.type === 'linear.comment.create') {
return isOwnComment(ctx, payload);
}
const rec = linearRecordPayload(event.payload) as { agentActivity?: unknown } | null;
return commentAuthorMatchesAgent(ctx, rec?.agentActivity ?? event.payload);
const rec = linearRecordPayload(payload) as { agentActivity?: unknown } | null;
return commentAuthorMatchesAgent(ctx, rec?.agentActivity ?? payload);
}

/** True if a comment event is the agent's own PR-link reply (loop guard). */
Expand Down Expand Up @@ -696,7 +700,7 @@ function asRecord(value: unknown): Record<string, unknown> | null {

function logSkip(
ctx: WorkforceCtx,
event: WorkforceProviderEvent,
event: WorkforceEvent,
reason: string,
attrs: Record<string, unknown> = {},
): void {
Expand Down
Loading