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
81 changes: 81 additions & 0 deletions packages/core/src/__tests__/slack-channel-dir-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Deriving the Slack channel directory that inbound replies arrive in.
*
* Relayfile materializes a channel under BOTH `<id>` and `<id>__<name>`, and
* delivers inbound messages and thread replies to the suffixed one. The existing
* subscription code slugified whatever identifier the caller passed: correct for a
* channel NAME, useless for a channel ID (the documented alternative), since
* slugifying `C0B9Z4CLG1J` yields `C0B9Z4CLG1J__c0b9z4clg1j`.
*
* Real failure this covers: a gate asked in `C0B9Z4CLG1J`, a human replied
* correctly in-thread 71s later, and the answer was never observed. The run watched
* `/slack/channels/C0B9Z4CLG1J/**` (30 entries, newest 2026-07-10) while the reply
* landed in `/slack/channels/C0B9Z4CLG1J__watchdog-test/**` (281 entries, current).
*
* The title now comes from the synced channel index (`id -> title`), so the suffix
* is derived from data rather than guessed from the caller's input.
*/
import { describe, it, expect } from 'vitest';
import { slackChannelTitleSlug, slackSuffixedChannelPath } from '../runner.js';

const ID = 'C0B9Z4CLG1J';

describe('slackChannelTitleSlug', () => {
it('slugifies a channel title the way Relayfile names its directories', () => {
expect(slackChannelTitleSlug('watchdog-test')).toBe('watchdog-test');
expect(slackChannelTitleSlug('proj-relay-core')).toBe('proj-relay-core');
});

it('normalises case, spaces, #, and punctuation', () => {
expect(slackChannelTitleSlug('#Ops NightCTO Notifications')).toBe('ops-nightcto-notifications');
expect(slackChannelTitleSlug(' GTM / Marketing ')).toBe('gtm-marketing');
});

it('collapses runs of separators and trims them from the ends', () => {
expect(slackChannelTitleSlug('--a__b c--')).toBe('a-b-c');
});

it('returns an empty string for a title with nothing slug-worthy', () => {
expect(slackChannelTitleSlug(' ')).toBe('');
expect(slackChannelTitleSlug('###')).toBe('');
});
});

describe('slackSuffixedChannelPath', () => {
it('builds the path replies actually arrive in', () => {
// The exact case that broke: id + real title from the index.
expect(slackSuffixedChannelPath(ID, 'watchdog-test')).toBe(
`/slack/channels/${ID}__watchdog-test/**`
);
});

it('is undefined without a title, rather than fabricating a path', () => {
// Subscribing to a guessed directory is what caused the original failure, so
// the absence of a title must surface as "unknown", not as a wrong path.
expect(slackSuffixedChannelPath(ID, undefined)).toBeUndefined();
expect(slackSuffixedChannelPath(ID, '')).toBeUndefined();
expect(slackSuffixedChannelPath(ID, ' ')).toBeUndefined();
});

it('never reproduces the old id-slugified guess', () => {
// Regression guard: `C0B9Z4CLG1J__c0b9z4clg1j` is the path that does not exist.
expect(slackSuffixedChannelPath(ID, 'watchdog-test')).not.toContain('c0b9z4clg1j');
});

it('tolerates a leading # and whitespace on the id', () => {
expect(slackSuffixedChannelPath(` #${ID} `, 'watchdog-test')).toBe(
`/slack/channels/${ID}__watchdog-test/**`
);
});

it('is undefined for an empty id', () => {
expect(slackSuffixedChannelPath('', 'watchdog-test')).toBeUndefined();
expect(slackSuffixedChannelPath(' ', 'watchdog-test')).toBeUndefined();
});

it('slugifies a title that needs normalising', () => {
expect(slackSuffixedChannelPath('C0BAYBD2QDT', 'Ops NightCTO Notifications')).toBe(
'/slack/channels/C0BAYBD2QDT__ops-nightcto-notifications/**'
);
});
});
114 changes: 113 additions & 1 deletion packages/core/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,36 @@ export function chooseIntegrationWorkspace(input: {
};
}

/**
* Slugify a Slack channel title the way Relayfile names its `<id>__<name>` dirs.
*/
export function slackChannelTitleSlug(title: string): string {
return title
.trim()
.replace(/^#/, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

/**
* The subscription glob for a channel's `<id>__<name>` directory.
*
* Inbound Slack messages and thread replies are delivered under this path, not the
* bare-id one. Returns undefined when there is no usable title, so the caller can
* say so rather than subscribing to a fabricated path.
*/
export function slackSuffixedChannelPath(
channelId: string,
title: string | undefined
): string | undefined {
const id = channelId.trim().replace(/^#/, '');
if (!id) return undefined;
const slug = title ? slackChannelTitleSlug(title) : '';
if (!slug) return undefined;
return `/slack/channels/${id}__${slug}/**`;
}

/** Decode a Relayfile JWT payload. Returns undefined for anything unparseable. */
export function relayfileJwtPayloadOf(token: string): Record<string, unknown> | undefined {
const parts = token.split('.');
Expand Down Expand Up @@ -8923,7 +8953,13 @@ export class WorkflowRunner {
const event = await this.waitForRelayfileEvent(
{
name: 'slack-human-answer',
paths: this.slackHumanAnswerSubscriptionPaths(channelPrefix, input.channel),
paths: await this.resolveSlackHumanAnswerPaths({
client,
workspaceId: runtime.workspaceId,
channelPrefix,
channelId: channel,
requestedChannel: input.channel,
}),
provider: 'slack',
source: 'workflow',
},
Expand All @@ -8949,10 +8985,86 @@ export class WorkflowRunner {
private slackHumanAnswerSubscriptionPaths(channelPrefix: string, requestedChannel: string): string[] {
const paths = new Set<string>([`${channelPrefix}/**`]);
const slug = this.slackChannelAliasSlug(requestedChannel);
// Kept as a fallback for the case this used to be written for: a channel
// NAME, where the slug does reconstruct the `<id>__<name>` directory. It is
// useless for a channel id, which is what discovery below covers.
if (slug) paths.add(`${channelPrefix}__${slug}/**`);
return [...paths];
}

/**
* Subscription paths for a Slack human answer, including the `<id>__<name>`
* directory that inbound replies actually arrive in.
*
* {@link slackHumanAnswerSubscriptionPaths} derives the suffix by slugifying the
* identifier it was handed. That reconstructs the real directory when a caller
* passes a channel NAME, but a caller may pass a channel ID — the documented
* alternative — and slugifying `C0B9Z4CLG1J` yields
* `C0B9Z4CLG1J__c0b9z4clg1j`, a directory that does not exist. The run then
* watches only the bare-id path while Relayfile delivers the reply to
* `C0B9Z4CLG1J__watchdog-test`, so a correctly threaded human answer is never
* observed and the gate times out with no indication why.
*
* The channel index already maps id -> title, and the runner already reads it to
* resolve a name to an id. This reads it the other way to recover the title, so
* the suffix comes from data rather than from a guess about the caller's input.
*
* A missing or unreadable index is non-fatal: it falls back to the derived paths
* rather than taking the run down.
*/
private async resolveSlackHumanAnswerPaths(input: {
client: RelayFileClient;
workspaceId: string;
channelPrefix: string;
channelId: string;
requestedChannel: string;
}): Promise<string[]> {
const paths = new Set(
this.slackHumanAnswerSubscriptionPaths(input.channelPrefix, input.requestedChannel)
);

const title = await this.resolveSlackChannelTitleById(
input.client,
input.workspaceId,
input.channelId
);
const discovered = slackSuffixedChannelPath(input.channelId, title);
if (discovered) {
if (!paths.has(discovered)) {
this.log(
`Slack human assistance watching "${discovered}" (resolved from the Slack ` +
`channel index) in addition to the bare-id path.`
);
}
paths.add(discovered);
} else {
this.log(
`Slack human assistance could not resolve a channel title for "${input.channelId}" ` +
`from the Slack channel index; watching derived paths only, which may miss ` +
`replies delivered to an "<id>__<name>" directory.`
);
}
return [...paths];
}

/** Look up a Slack channel's title by id via the synced channel index. */
private async resolveSlackChannelTitleById(
client: RelayFileClient,
workspaceId: string,
channelId: string
): Promise<string | undefined> {
for (const indexPath of ['/slack/channels/_index.json', '/discovery/slack/channels/_index.json']) {
const raw = await this.readRelayfileSlackLookupFile(client, workspaceId, indexPath);
if (!raw) continue;
for (const entry of this.parseSlackChannelIndex(raw)) {
if (entry.id !== channelId) continue;
const title = entry.title ?? entry.name;
if (title && title.trim()) return title.trim();
}
}
return undefined;
}

private isRelayfileAuthExpiredError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return new RegExp(
Expand Down