mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-18 20:15:20 +02:00
feat: surface Slack failures and add cross-OS auth fallbacks
Classify agent-slack errors (not_authed/rate_limited/network/bad_channel), persist per-source sync status with rate-limit backoff, and expose it via slack:knowledgeStatus. Fix the Settings Enable bounce-back with actionable copy, a browser-paste (parse-curl) fallback, and a Windows quit-Slack-and-import button; add home-feed empty/error states.
This commit is contained in:
parent
2554a9b8da
commit
2421a40886
9 changed files with 827 additions and 68 deletions
|
|
@ -16,8 +16,12 @@ import { bus } from '@x/core/dist/runs/bus.js';
|
|||
import { serviceBus } from '@x/core/dist/services/service_bus.js';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
import fs from 'node:fs/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import z from 'zod';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
import { RunEvent } from '@x/shared/dist/runs.js';
|
||||
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
|
|
@ -34,10 +38,10 @@ import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
|
|||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||
import { runAgentSlack, getAgentSlackCliStatus } from '@x/core/dist/slack/agent-slack-exec.js';
|
||||
import { runAgentSlack, getAgentSlackCliStatus, AgentSlackRunError } from '@x/core/dist/slack/agent-slack-exec.js';
|
||||
import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js';
|
||||
import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
|
||||
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync } from '@x/core/dist/knowledge/sources/sync_slack.js';
|
||||
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
|
||||
import * as composioHandler from './composio-handler.js';
|
||||
|
|
@ -97,6 +101,53 @@ type SlackHomeMessage = {
|
|||
url?: string;
|
||||
};
|
||||
|
||||
function parseWhoamiWorkspaces(data: unknown): Array<{ url: string; name: string }> {
|
||||
const parsed = (data ?? {}) as { workspaces?: Array<{ workspace_url?: string; workspace_name?: string }> };
|
||||
return (parsed.workspaces || []).map((w) => ({
|
||||
url: w.workspace_url || '',
|
||||
name: w.workspace_name || '',
|
||||
}));
|
||||
}
|
||||
|
||||
type SlackAuthResult = {
|
||||
ok: boolean;
|
||||
workspaces: Array<{ url: string; name: string }>;
|
||||
error?: string;
|
||||
errorKind?: 'not_installed' | 'timeout' | 'parse_error' | 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown';
|
||||
};
|
||||
|
||||
// Run `auth import-desktop`, then read back the workspaces via `auth whoami`.
|
||||
// Shared by the plain and the quit-Slack-first import handlers.
|
||||
async function importDesktopAndReadWorkspaces(): Promise<SlackAuthResult> {
|
||||
const imported = await runAgentSlack(['auth', 'import-desktop'], { timeoutMs: 20000, parseJson: false });
|
||||
if (!imported.ok) {
|
||||
return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
|
||||
}
|
||||
const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
|
||||
if (!whoami.ok) {
|
||||
return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
|
||||
}
|
||||
const workspaces = parseWhoamiWorkspaces(whoami.data);
|
||||
if (workspaces.length === 0) {
|
||||
return { ok: false, workspaces: [], error: 'No signed-in Slack workspaces found in the desktop app.', errorKind: 'not_authed' };
|
||||
}
|
||||
return { ok: true, workspaces };
|
||||
}
|
||||
|
||||
// Windows force-quits Slack so its exclusive Cookies-DB lock releases before
|
||||
// desktop import (the EBUSY cause). No-op on mac/Linux, where import works with
|
||||
// Slack open. taskkill exits non-zero when nothing matches — that's fine.
|
||||
async function quitSlackIfWindows(): Promise<void> {
|
||||
if (process.platform !== 'win32') return;
|
||||
try {
|
||||
await execFileAsync('taskkill', ['/F', '/IM', 'Slack.exe'], { timeout: 10000, windowsHide: true });
|
||||
} catch {
|
||||
// No running Slack process to kill — nothing to do.
|
||||
}
|
||||
// Give Windows a moment to release the file handles before we copy them.
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
}
|
||||
|
||||
function extractArrayPayload(parsed: unknown): unknown[] {
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
|
|
@ -842,18 +893,53 @@ export function setupIpcHandlers() {
|
|||
'slack:cliStatus': async () => {
|
||||
return await getAgentSlackCliStatus();
|
||||
},
|
||||
'slack:knowledgeStatus': async () => {
|
||||
return {
|
||||
cli: await getAgentSlackCliStatus(),
|
||||
sources: getSlackKnowledgeSyncStatus(),
|
||||
};
|
||||
},
|
||||
'slack:listWorkspaces': async () => {
|
||||
const result = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
|
||||
if (!result.ok) {
|
||||
return { workspaces: [], error: result.message };
|
||||
return { workspaces: [], error: result.message, errorKind: result.kind };
|
||||
}
|
||||
const parsed = (result.data ?? {}) as { workspaces?: Array<{ workspace_url?: string; workspace_name?: string }> };
|
||||
const workspaces = (parsed.workspaces || []).map((w) => ({
|
||||
url: w.workspace_url || '',
|
||||
name: w.workspace_name || '',
|
||||
}));
|
||||
const workspaces = parseWhoamiWorkspaces(result.data);
|
||||
return { workspaces };
|
||||
},
|
||||
'slack:importDesktopAuth': async () => {
|
||||
// Pull xoxc token(s) + cookie from the running/installed Slack desktop
|
||||
// app into agent-slack's credential store, then read back the workspaces.
|
||||
return await importDesktopAndReadWorkspaces();
|
||||
},
|
||||
'slack:quitAndImportDesktop': async () => {
|
||||
// Windows-only convenience: kill Slack (which locks its Cookies DB) then
|
||||
// run the normal desktop import in one click.
|
||||
await quitSlackIfWindows();
|
||||
return await importDesktopAndReadWorkspaces();
|
||||
},
|
||||
'slack:parseCurlAuth': async (_event, args) => {
|
||||
// Cross-OS fallback to desktop import: the user pastes a "Copy as cURL"
|
||||
// request from a signed-in Slack web tab; parse-curl reads it from stdin
|
||||
// and extracts the xoxc token + xoxd cookie. No leveldb, no OS keychain.
|
||||
const curl = (args.curl ?? '').trim();
|
||||
if (!curl) {
|
||||
return { ok: false, workspaces: [], error: 'Paste the copied cURL command first.', errorKind: 'unknown' as const };
|
||||
}
|
||||
const imported = await runAgentSlack(['auth', 'parse-curl'], { timeoutMs: 15000, parseJson: false, input: curl });
|
||||
if (!imported.ok) {
|
||||
return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
|
||||
}
|
||||
const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
|
||||
if (!whoami.ok) {
|
||||
return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
|
||||
}
|
||||
const workspaces = parseWhoamiWorkspaces(whoami.data);
|
||||
if (workspaces.length === 0) {
|
||||
return { ok: false, workspaces: [], error: 'Tokens were saved but no workspace was found. Double-check the copied request.', errorKind: 'not_authed' as const };
|
||||
}
|
||||
return { ok: true, workspaces };
|
||||
},
|
||||
'slack:listChannels': async (_event, args) => {
|
||||
const result = await runAgentSlack(['channel', 'list', '--all', '--workspace', args.workspaceUrl, '--limit', '200'], { timeoutMs: 15000 });
|
||||
if (!result.ok) {
|
||||
|
|
@ -902,7 +988,7 @@ export function setupIpcHandlers() {
|
|||
for (const workspace of config.workspaces) {
|
||||
const channelList = await runAgentSlack(['channel', 'list', '--workspace', workspace.url, '--limit', '12'], { timeoutMs: 15000 });
|
||||
if (!channelList.ok) {
|
||||
throw new Error(channelList.message);
|
||||
throw new AgentSlackRunError(channelList.kind, channelList.message);
|
||||
}
|
||||
const rawChannels = extractArrayPayload(channelList.data);
|
||||
for (const raw of rawChannels) {
|
||||
|
|
@ -965,7 +1051,8 @@ export function setupIpcHandlers() {
|
|||
return { enabled: true, messages: rankedMessages };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load Slack messages';
|
||||
return { enabled: true, messages: [], error: message };
|
||||
const errorKind = err instanceof AgentSlackRunError ? err.kind : undefined;
|
||||
return { enabled: true, messages: [], error: message, errorKind };
|
||||
}
|
||||
},
|
||||
'knowledgeSources:getConfig': async () => {
|
||||
|
|
|
|||
|
|
@ -112,6 +112,21 @@ function relativeSlackTs(ts: string): string {
|
|||
return relativeAgo(iso)
|
||||
}
|
||||
|
||||
// Short, non-actionable copy for the home feed — the actionable fix lives in
|
||||
// Settings, so every failure routes the user there.
|
||||
function homeSlackErrorCopy(kind: string | null): string {
|
||||
switch (kind) {
|
||||
case 'not_authed':
|
||||
return 'Slack needs reconnecting — open Settings → Connected accounts.'
|
||||
case 'network':
|
||||
return "Couldn't reach Slack. Check your connection."
|
||||
case 'rate_limited':
|
||||
return 'Slack is rate-limiting requests — will retry shortly.'
|
||||
default:
|
||||
return "Couldn't load Slack right now — see Settings."
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllDay(s: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||
if (!m) return null
|
||||
|
|
@ -239,6 +254,7 @@ export function HomeView({
|
|||
const [slackEnabled, setSlackEnabled] = useState(false)
|
||||
const [slackMessages, setSlackMessages] = useState<SlackFeedMessage[]>([])
|
||||
const [slackError, setSlackError] = useState<string | null>(null)
|
||||
const [slackErrorKind, setSlackErrorKind] = useState<string | null>(null)
|
||||
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
|
||||
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
|
||||
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
|
||||
|
|
@ -287,11 +303,13 @@ export function HomeView({
|
|||
setSlackEnabled(result.enabled)
|
||||
setSlackMessages(result.messages)
|
||||
setSlackError(result.error ?? null)
|
||||
setSlackErrorKind(result.errorKind ?? null)
|
||||
} catch (err) {
|
||||
console.error('Home: failed to load Slack messages', err)
|
||||
setSlackEnabled(false)
|
||||
setSlackMessages([])
|
||||
setSlackError(null)
|
||||
setSlackErrorKind(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -505,9 +523,9 @@ export function HomeView({
|
|||
<span className="text-xs text-muted-foreground">Latest messages</span>
|
||||
</div>
|
||||
{slackError ? (
|
||||
<div className="py-1 text-[12.5px] text-muted-foreground">{slackError}</div>
|
||||
<div className="py-1 text-[12.5px] text-muted-foreground">{homeSlackErrorCopy(slackErrorKind)}</div>
|
||||
) : slackMessages.length === 0 ? (
|
||||
<div className="py-1 text-[12.5px] text-muted-foreground">No recent Slack messages found.</div>
|
||||
<div className="py-1 text-[12.5px] text-muted-foreground">No messages worth surfacing right now.</div>
|
||||
) : slackMessages.map((message, i) => (
|
||||
<div
|
||||
key={message.id}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,30 @@ import { Switch } from "@/components/ui/switch"
|
|||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
import { useConnectors } from "@/hooks/useConnectors"
|
||||
import { useConnectors, actionableSlackError } from "@/hooks/useConnectors"
|
||||
|
||||
interface ConnectedAccountsSettingsProps {
|
||||
dialogOpen: boolean
|
||||
}
|
||||
|
||||
function relativeTime(iso?: string): string {
|
||||
if (!iso) return "never"
|
||||
const then = Date.parse(iso)
|
||||
if (!Number.isFinite(then)) return "never"
|
||||
const diffSec = Math.round((Date.now() - then) / 1000)
|
||||
if (diffSec < 60) return "just now"
|
||||
const diffMin = Math.round(diffSec / 60)
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
const diffHr = Math.round(diffMin / 60)
|
||||
if (diffHr < 24) return `${diffHr}h ago`
|
||||
return `${Math.round(diffHr / 24)}d ago`
|
||||
}
|
||||
|
||||
export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSettingsProps) {
|
||||
const c = useConnectors(dialogOpen)
|
||||
// Windows exclusively locks Slack's Cookies DB while it runs, so we offer a
|
||||
// "quit Slack first" one-click import there. mac/Linux import with Slack open.
|
||||
const isWindows = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win')
|
||||
|
||||
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
|
||||
const state = c.providerStates[provider] || {
|
||||
|
|
@ -293,7 +309,66 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
</div>
|
||||
{c.slackPickerOpen && (
|
||||
<div className="mt-2 ml-10 space-y-2">
|
||||
{c.slackDiscoverError ? (
|
||||
{c.slackNeedsAuth ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{c.slackDiscoverError ?? 'Connect your signed-in Slack desktop app to continue.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={c.handleSlackImportDesktop}
|
||||
disabled={c.slackAuthImporting}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{c.slackAuthImporting ? <Loader2 className="size-3 animate-spin" /> : "Connect Slack"}
|
||||
</Button>
|
||||
{isWindows && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={c.handleSlackQuitAndImport}
|
||||
disabled={c.slackAuthImporting}
|
||||
className="h-7 px-3 text-xs"
|
||||
title="Closes Slack so its data unlocks, then connects"
|
||||
>
|
||||
Quit Slack & connect
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => c.setSlackCurlOpen(!c.slackCurlOpen)}
|
||||
className="text-xs text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
Paste from browser instead
|
||||
</button>
|
||||
</div>
|
||||
{c.slackCurlOpen && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
In a browser signed in to Slack, open DevTools → Network, click any
|
||||
request to <code>app.slack.com</code>, right-click → Copy → Copy as cURL,
|
||||
then paste it below.
|
||||
</p>
|
||||
<Textarea
|
||||
value={c.slackCurlValue}
|
||||
onChange={(event) => c.setSlackCurlValue(event.target.value)}
|
||||
placeholder="curl 'https://your-team.slack.com/api/...' -H 'Cookie: d=xoxd-...' ..."
|
||||
className="min-h-20 text-[11px] font-mono"
|
||||
disabled={c.slackCurlSubmitting}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={c.handleSlackParseCurl}
|
||||
disabled={c.slackCurlSubmitting || c.slackCurlValue.trim().length === 0}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{c.slackCurlSubmitting ? <Loader2 className="size-3 animate-spin" /> : "Connect with cURL"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : c.slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -379,6 +454,19 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
|||
{c.slackKnowledgeSaving ? <Loader2 className="size-3 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
{c.slackKnowledgeEnabled && c.slackSyncStatuses.filter(s => s.enabled).map(status => (
|
||||
<div key={status.id} className="flex items-center gap-1.5 text-xs">
|
||||
{status.lastStatus === 'error' ? (
|
||||
<span className="text-amber-600 truncate">
|
||||
Sync failing — {actionableSlackError(status.lastError?.kind, status.lastError?.message)}
|
||||
</span>
|
||||
) : status.lastSyncAt ? (
|
||||
<span className="text-muted-foreground">Last synced {relativeTime(status.lastSyncAt)}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Not synced yet — first sync runs shortly</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,46 @@ type KnowledgeSourceConfig = {
|
|||
filters?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SlackSyncStatus = {
|
||||
id: string
|
||||
enabled: boolean
|
||||
lastSyncAt?: string
|
||||
lastStatus?: 'ok' | 'error'
|
||||
lastError?: { kind: string; message: string }
|
||||
nextDueAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a structured agent-slack failure to actionable user copy. The key
|
||||
* distinction (raised by real usage): a missing Slack desktop app needs a
|
||||
* different instruction than a signed-out one.
|
||||
*/
|
||||
export function actionableSlackError(kind?: string, message?: string): string {
|
||||
// Windows locks Slack's Cookies/LevelDB files while it's running, so the
|
||||
// desktop import copy fails with EBUSY. This can surface under any kind, so
|
||||
// check the message first.
|
||||
if (message && /EBUSY|resource busy|locked|copyfile/i.test(message)) {
|
||||
return 'Slack is open and locking its data. Click "Quit Slack & connect" to close it automatically, or use "Paste from browser instead".'
|
||||
}
|
||||
switch (kind) {
|
||||
case 'not_installed':
|
||||
return 'The Slack helper is unavailable in this build. Please update or reinstall Rowboat.'
|
||||
case 'network':
|
||||
return "Couldn't reach Slack. Check your internet connection and try again."
|
||||
case 'rate_limited':
|
||||
return 'Slack is rate-limiting requests right now. Wait a minute and try again.'
|
||||
case 'bad_channel':
|
||||
return message || "A configured channel couldn't be found. Check the channel names in Settings."
|
||||
case 'not_authed':
|
||||
if (message && /Desktop data not found|not supported/i.test(message)) {
|
||||
return 'No Slack desktop app was found. Install Slack, sign in to your workspace, then click Connect.'
|
||||
}
|
||||
return 'No signed-in Slack account found. Open the Slack desktop app, sign in, then click Connect.'
|
||||
default:
|
||||
return message || "Couldn't connect to Slack. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
export function useConnectors(active: boolean) {
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
|
|
@ -49,9 +89,19 @@ export function useConnectors(active: boolean) {
|
|||
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
|
||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
// True when discovery succeeded but no workspaces are connected yet, so the
|
||||
// user needs to import auth from the Slack desktop app (fixes the silent
|
||||
// "Enable" bounce-back where the button never progressed).
|
||||
const [slackNeedsAuth, setSlackNeedsAuth] = useState(false)
|
||||
const [slackAuthImporting, setSlackAuthImporting] = useState(false)
|
||||
// Cross-OS "paste cURL from a browser tab" fallback when desktop import fails.
|
||||
const [slackCurlOpen, setSlackCurlOpen] = useState(false)
|
||||
const [slackCurlValue, setSlackCurlValue] = useState("")
|
||||
const [slackCurlSubmitting, setSlackCurlSubmitting] = useState(false)
|
||||
const [slackKnowledgeEnabled, setSlackKnowledgeEnabled] = useState(false)
|
||||
const [slackKnowledgeChannels, setSlackKnowledgeChannels] = useState("")
|
||||
const [slackKnowledgeSaving, setSlackKnowledgeSaving] = useState(false)
|
||||
const [slackSyncStatuses, setSlackSyncStatuses] = useState<SlackSyncStatus[]>([])
|
||||
|
||||
// Composio Gmail/Calendar sync was removed. These flags are seeded false
|
||||
// and never flipped — the IPC that used to set them is gone. The setters
|
||||
|
|
@ -136,26 +186,105 @@ export function useConnectors(active: boolean) {
|
|||
const handleSlackEnable = useCallback(async () => {
|
||||
setSlackDiscovering(true)
|
||||
setSlackDiscoverError(null)
|
||||
setSlackNeedsAuth(false)
|
||||
setSlackCurlOpen(false)
|
||||
setSlackCurlValue("")
|
||||
setSlackPickerOpen(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:listWorkspaces', null)
|
||||
if (result.error || result.workspaces.length === 0) {
|
||||
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
|
||||
setSlackAvailableWorkspaces([])
|
||||
setSlackPickerOpen(true)
|
||||
} else {
|
||||
if (result.workspaces.length > 0) {
|
||||
// Already-connected workspaces → straight to the picker.
|
||||
setSlackAvailableWorkspaces(result.workspaces)
|
||||
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
|
||||
setSlackPickerOpen(true)
|
||||
} else {
|
||||
// CLI ran but nothing is connected yet (or it errored): offer a
|
||||
// concrete next step instead of a dead-end message.
|
||||
setSlackAvailableWorkspaces([])
|
||||
setSlackNeedsAuth(true)
|
||||
setSlackDiscoverError(result.error ? actionableSlackError(result.errorKind, result.error) : null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to discover Slack workspaces:', error)
|
||||
setSlackDiscoverError('Failed to discover Slack workspaces')
|
||||
setSlackPickerOpen(true)
|
||||
setSlackNeedsAuth(true)
|
||||
setSlackDiscoverError("Couldn't start Slack discovery. Please try again.")
|
||||
} finally {
|
||||
setSlackDiscovering(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Shared success path for both auth methods: show the discovered workspaces
|
||||
// in the picker, preselected. Returns true when workspaces were found.
|
||||
const applyDiscoveredWorkspaces = useCallback((result: { ok: boolean; workspaces: Array<{ url: string; name: string }>; error?: string; errorKind?: string }) => {
|
||||
if (result.ok && result.workspaces.length > 0) {
|
||||
setSlackAvailableWorkspaces(result.workspaces)
|
||||
setSlackSelectedUrls(new Set(result.workspaces.map((w) => w.url)))
|
||||
setSlackNeedsAuth(false)
|
||||
setSlackCurlOpen(false)
|
||||
setSlackCurlValue("")
|
||||
return true
|
||||
}
|
||||
setSlackDiscoverError(actionableSlackError(result.errorKind, result.error))
|
||||
return false
|
||||
}, [])
|
||||
|
||||
// Import xoxc token + cookie from the signed-in Slack desktop app, then show
|
||||
// the discovered workspaces in the picker.
|
||||
const handleSlackImportDesktop = useCallback(async () => {
|
||||
setSlackAuthImporting(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:importDesktopAuth', null)
|
||||
// Desktop import is best-effort: it fails when Slack is running and locks
|
||||
// its Cookies DB (EBUSY on Windows), or on unsupported Slack builds. On
|
||||
// any failure, reveal the browser-paste fallback so the user is never
|
||||
// stuck — it has no file-lock dependency and works cross-OS.
|
||||
if (!applyDiscoveredWorkspaces(result)) {
|
||||
setSlackCurlOpen(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to import Slack desktop auth:', error)
|
||||
setSlackDiscoverError("Couldn't import from the Slack desktop app. Please try again, or paste from your browser below.")
|
||||
setSlackCurlOpen(true)
|
||||
} finally {
|
||||
setSlackAuthImporting(false)
|
||||
}
|
||||
}, [applyDiscoveredWorkspaces])
|
||||
|
||||
// Windows-only: force-quit Slack (releases its Cookies-DB lock) then import.
|
||||
// One click instead of the manual taskkill dance.
|
||||
const handleSlackQuitAndImport = useCallback(async () => {
|
||||
setSlackAuthImporting(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:quitAndImportDesktop', null)
|
||||
if (!applyDiscoveredWorkspaces(result)) {
|
||||
setSlackCurlOpen(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to quit Slack and import:', error)
|
||||
setSlackDiscoverError("Couldn't import after closing Slack. Please try again, or paste from your browser below.")
|
||||
setSlackCurlOpen(true)
|
||||
} finally {
|
||||
setSlackAuthImporting(false)
|
||||
}
|
||||
}, [applyDiscoveredWorkspaces])
|
||||
|
||||
// Fallback: parse a "Copy as cURL" request pasted from a signed-in Slack web
|
||||
// tab. Works on every OS — no desktop app, leveldb, or keychain needed.
|
||||
const handleSlackParseCurl = useCallback(async () => {
|
||||
setSlackCurlSubmitting(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:parseCurlAuth', { curl: slackCurlValue })
|
||||
applyDiscoveredWorkspaces(result)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse Slack cURL:', error)
|
||||
setSlackDiscoverError("Couldn't read that cURL command. Please try again.")
|
||||
} finally {
|
||||
setSlackCurlSubmitting(false)
|
||||
}
|
||||
}, [applyDiscoveredWorkspaces, slackCurlValue])
|
||||
|
||||
const handleSlackSaveWorkspaces = useCallback(async () => {
|
||||
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
||||
try {
|
||||
|
|
@ -164,6 +293,7 @@ export function useConnectors(active: boolean) {
|
|||
setSlackEnabled(true)
|
||||
setSlackWorkspaces(selected)
|
||||
setSlackPickerOpen(false)
|
||||
setSlackNeedsAuth(false)
|
||||
toast.success('Slack enabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to save Slack config:', error)
|
||||
|
|
@ -180,6 +310,9 @@ export function useConnectors(active: boolean) {
|
|||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
setSlackPickerOpen(false)
|
||||
setSlackNeedsAuth(false)
|
||||
setSlackCurlOpen(false)
|
||||
setSlackCurlValue("")
|
||||
await window.ipc.invoke('knowledgeSources:upsert', {
|
||||
id: 'slack',
|
||||
provider: 'slack',
|
||||
|
|
@ -200,6 +333,16 @@ export function useConnectors(active: boolean) {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const refreshSlackKnowledgeStatus = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:knowledgeStatus', null)
|
||||
setSlackSyncStatuses(result.sources)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Slack knowledge status:', error)
|
||||
setSlackSyncStatuses([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshKnowledgeSources = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('knowledgeSources:getConfig', null)
|
||||
|
|
@ -515,6 +658,7 @@ export function useConnectors(active: boolean) {
|
|||
refreshGranolaConfig()
|
||||
refreshSlackConfig()
|
||||
refreshKnowledgeSources()
|
||||
refreshSlackKnowledgeStatus()
|
||||
|
||||
if (useComposioForGoogle) {
|
||||
refreshGmailStatus()
|
||||
|
|
@ -559,7 +703,7 @@ export function useConnectors(active: boolean) {
|
|||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshKnowledgeSources, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshKnowledgeSources, refreshSlackKnowledgeStatus, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
|
||||
|
||||
// Refresh when active or providers change
|
||||
useEffect(() => {
|
||||
|
|
@ -685,12 +829,23 @@ export function useConnectors(active: boolean) {
|
|||
setSlackPickerOpen,
|
||||
slackDiscovering,
|
||||
slackDiscoverError,
|
||||
slackNeedsAuth,
|
||||
slackAuthImporting,
|
||||
slackCurlOpen,
|
||||
setSlackCurlOpen,
|
||||
slackCurlValue,
|
||||
setSlackCurlValue,
|
||||
slackCurlSubmitting,
|
||||
slackSyncStatuses,
|
||||
slackKnowledgeEnabled,
|
||||
setSlackKnowledgeEnabled,
|
||||
slackKnowledgeChannels,
|
||||
setSlackKnowledgeChannels,
|
||||
slackKnowledgeSaving,
|
||||
handleSlackEnable,
|
||||
handleSlackImportDesktop,
|
||||
handleSlackQuitAndImport,
|
||||
handleSlackParseCurl,
|
||||
handleSlackSaveWorkspaces,
|
||||
handleSlackDisable,
|
||||
handleSlackKnowledgeSave,
|
||||
|
|
|
|||
182
apps/x/packages/core/src/knowledge/sources/sync_slack.test.ts
Normal file
182
apps/x/packages/core/src/knowledge/sources/sync_slack.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { KnowledgeSourceConfig } from './types.js';
|
||||
|
||||
// WorkDir is resolved when config.js loads, so the env override must be in
|
||||
// place before sync_slack.js (which imports it) is loaded — hence the
|
||||
// dynamic imports in beforeAll.
|
||||
const tmpWorkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slack-sync-test-'));
|
||||
process.env.ROWBOAT_WORKDIR = tmpWorkDir;
|
||||
|
||||
const sourceA: KnowledgeSourceConfig = {
|
||||
id: 'slack-a',
|
||||
provider: 'slack',
|
||||
enabled: true,
|
||||
artifactDir: 'knowledge_sources/slack',
|
||||
syncMode: 'poll',
|
||||
intervalMs: 5 * 60 * 1000,
|
||||
scopes: [{ type: 'channel', id: 'C-AAA', name: '#alpha' }],
|
||||
};
|
||||
const sourceB: KnowledgeSourceConfig = {
|
||||
...sourceA,
|
||||
id: 'slack-b',
|
||||
scopes: [{ type: 'channel', id: 'C-BBB', name: '#beta' }],
|
||||
};
|
||||
|
||||
vi.mock('./repo.js', () => ({
|
||||
knowledgeSourcesRepo: {
|
||||
listEnabledSources: vi.fn(() => [sourceA, sourceB]),
|
||||
getConfig: vi.fn(() => ({ sources: [sourceA, sourceB] })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/service_logger.js', () => ({
|
||||
serviceLogger: {
|
||||
startRun: vi.fn(async () => ({ service: 'slack', runId: 'test-run', startedAt: Date.now() })),
|
||||
log: vi.fn(async () => { }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../events/producer.js', () => ({
|
||||
createEvent: vi.fn(async () => { }),
|
||||
}));
|
||||
|
||||
vi.mock('../../slack/agent-slack-exec.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../slack/agent-slack-exec.js')>();
|
||||
return { ...actual, runAgentSlack: vi.fn() };
|
||||
});
|
||||
|
||||
type SyncModule = typeof import('./sync_slack.js');
|
||||
type ExecModule = typeof import('../../slack/agent-slack-exec.js');
|
||||
|
||||
let sync: SyncModule;
|
||||
let execMock: ReturnType<typeof vi.mocked<ExecModule['runAgentSlack']>>;
|
||||
|
||||
const stateFile = path.join(tmpWorkDir, 'slack_knowledge_sync_state.json');
|
||||
|
||||
function readState() {
|
||||
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
}
|
||||
|
||||
/** Rewind a source's lastSyncAt so it counts as due again. */
|
||||
function rewindSource(sourceId: string, ms: number) {
|
||||
const state = readState();
|
||||
state.sources[sourceId].lastSyncAt = new Date(Date.now() - ms).toISOString();
|
||||
fs.writeFileSync(stateFile, JSON.stringify(state), 'utf-8');
|
||||
}
|
||||
|
||||
const okEmpty = { ok: true as const, stdout: '[]', data: [] };
|
||||
const rateLimited = {
|
||||
ok: false as const, kind: 'rate_limited' as const, stderr: 'ratelimited',
|
||||
message: 'A rate-limit has been reached, you may retry this request in 30 seconds',
|
||||
};
|
||||
const badChannel = {
|
||||
ok: false as const, kind: 'bad_channel' as const, stderr: '',
|
||||
message: 'Could not resolve channel name: #alpha',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
sync = await import('./sync_slack.js');
|
||||
const exec = await import('../../slack/agent-slack-exec.js');
|
||||
execMock = vi.mocked(exec.runAgentSlack);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
execMock.mockReset();
|
||||
fs.rmSync(stateFile, { force: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpWorkDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('syncSlackKnowledgeSources status persistence', () => {
|
||||
it('records ok status and lastSyncAt per source', async () => {
|
||||
execMock.mockResolvedValue(okEmpty);
|
||||
await sync.syncSlackKnowledgeSources();
|
||||
const state = readState();
|
||||
for (const id of ['slack-a', 'slack-b']) {
|
||||
expect(state.sources[id].lastStatus).toBe('ok');
|
||||
expect(Date.parse(state.sources[id].lastSyncAt)).toBeGreaterThan(Date.now() - 60_000);
|
||||
expect(state.sources[id].lastError).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('persists lastError and lets other sources continue past a bad one', async () => {
|
||||
execMock.mockImplementation(async (args: string[]) =>
|
||||
args.includes('C-AAA') ? badChannel : okEmpty);
|
||||
await sync.syncSlackKnowledgeSources();
|
||||
const state = readState();
|
||||
expect(state.sources['slack-a']).toMatchObject({
|
||||
lastStatus: 'error',
|
||||
lastError: { kind: 'bad_channel', message: 'Could not resolve channel name: #alpha' },
|
||||
});
|
||||
// slack-b synced despite slack-a failing
|
||||
expect(state.sources['slack-b'].lastStatus).toBe('ok');
|
||||
expect(execMock.mock.calls.some(call => call[0].includes('C-BBB'))).toBe(true);
|
||||
});
|
||||
|
||||
it('stops the run on rate limit without touching later sources', async () => {
|
||||
execMock.mockResolvedValue(rateLimited);
|
||||
await sync.syncSlackKnowledgeSources();
|
||||
const state = readState();
|
||||
expect(state.sources['slack-a'].lastError.kind).toBe('rate_limited');
|
||||
expect(state.sources['slack-b']).toBeUndefined();
|
||||
expect(execMock.mock.calls.every(call => !call[0].includes('C-BBB'))).toBe(true);
|
||||
});
|
||||
|
||||
it('grows backoff on consecutive rate limits and resets it on success', async () => {
|
||||
execMock.mockResolvedValue(rateLimited);
|
||||
await sync.syncSlackKnowledgeSources();
|
||||
expect(readState().sources['slack-a'].backoffMultiplier).toBe(2);
|
||||
|
||||
rewindSource('slack-a', 60 * 60 * 1000);
|
||||
await sync.syncSlackKnowledgeSources();
|
||||
expect(readState().sources['slack-a'].backoffMultiplier).toBe(4);
|
||||
|
||||
rewindSource('slack-a', 60 * 60 * 1000);
|
||||
execMock.mockResolvedValue(okEmpty);
|
||||
await sync.syncSlackKnowledgeSources();
|
||||
expect(readState().sources['slack-a'].backoffMultiplier).toBeUndefined();
|
||||
expect(readState().sources['slack-a'].lastStatus).toBe('ok');
|
||||
});
|
||||
|
||||
it('does not re-sync a rate-limited source before its backed-off interval elapses', async () => {
|
||||
execMock.mockResolvedValue(rateLimited);
|
||||
// First run rate-limits slack-a and breaks before slack-b.
|
||||
await sync.syncSlackKnowledgeSources();
|
||||
execMock.mockClear();
|
||||
// Second run: slack-a is backed off (not due) but slack-b never ran, so
|
||||
// it's still due. slack-a must not be retried; slack-b may be.
|
||||
await sync.syncSlackKnowledgeSources();
|
||||
expect(execMock.mock.calls.every(call => !call[0].includes('C-AAA'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveIntervalMs', () => {
|
||||
it('multiplies the base interval by the backoff and caps at 30 minutes', () => {
|
||||
expect(sync.effectiveIntervalMs(sourceA, undefined)).toBe(5 * 60 * 1000);
|
||||
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 2 })).toBe(10 * 60 * 1000);
|
||||
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 4 })).toBe(20 * 60 * 1000);
|
||||
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 8 })).toBe(30 * 60 * 1000);
|
||||
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 1024 })).toBe(30 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSlackKnowledgeSyncStatus', () => {
|
||||
it('reports per-source status with nextDueAt from interval + backoff', async () => {
|
||||
execMock.mockImplementation(async (args: string[]) =>
|
||||
args.includes('C-AAA') ? badChannel : okEmpty);
|
||||
await sync.syncSlackKnowledgeSources();
|
||||
|
||||
const statuses = sync.getSlackKnowledgeSyncStatus();
|
||||
const a = statuses.find(s => s.id === 'slack-a');
|
||||
const b = statuses.find(s => s.id === 'slack-b');
|
||||
expect(a).toMatchObject({ enabled: true, lastStatus: 'error', lastError: { kind: 'bad_channel' } });
|
||||
expect(b).toMatchObject({ enabled: true, lastStatus: 'ok' });
|
||||
// nextDueAt ≈ lastSyncAt + 5 min
|
||||
expect(Date.parse(b!.nextDueAt!)).toBeCloseTo(Date.parse(b!.lastSyncAt!) + 5 * 60 * 1000, -3);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import { runAgentSlack as execAgentSlack } from '../../slack/agent-slack-exec.js';
|
||||
import { AgentSlackRunError, runAgentSlack as execAgentSlack } from '../../slack/agent-slack-exec.js';
|
||||
import type { AgentSlackErrorKind } from '../../slack/agent-slack-exec.js';
|
||||
import { serviceLogger } from '../../services/service_logger.js';
|
||||
import { limitEventItems } from '../limit_event_items.js';
|
||||
import { createEvent } from '../../events/producer.js';
|
||||
|
|
@ -14,9 +15,18 @@ const DEFAULT_RECENT_BACKFILL_SECONDS = 6 * 60 * 60;
|
|||
const STATE_FILE = path.join(WorkDir, 'slack_knowledge_sync_state.json');
|
||||
const ARTIFACT_ROOT = path.join(WorkDir, 'knowledge_sources', 'slack');
|
||||
|
||||
export type SlackSourceSyncState = {
|
||||
/** Time of the last sync attempt (success or failure). */
|
||||
lastSyncAt?: string;
|
||||
lastStatus?: 'ok' | 'error';
|
||||
lastError?: { kind: AgentSlackErrorKind | 'unknown'; message: string };
|
||||
/** Rate-limit backoff: multiplies the source interval; reset on success. */
|
||||
backoffMultiplier?: number;
|
||||
};
|
||||
|
||||
type SlackSyncState = {
|
||||
lastSyncAt?: string;
|
||||
sources?: Record<string, { lastSyncAt?: string }>;
|
||||
sources?: Record<string, SlackSourceSyncState>;
|
||||
channels: Record<string, { lastSeenTs?: string }>;
|
||||
};
|
||||
|
||||
|
|
@ -54,12 +64,20 @@ function saveState(state: SlackSyncState): void {
|
|||
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
const MAX_SOURCE_SYNC_INTERVAL_MS = 30 * 60 * 1000;
|
||||
|
||||
/** Source interval with rate-limit backoff applied, capped at 30 minutes. */
|
||||
export function effectiveIntervalMs(source: KnowledgeSourceConfig, sourceState?: SlackSourceSyncState): number {
|
||||
const base = source.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
|
||||
const multiplier = Math.max(1, sourceState?.backoffMultiplier ?? 1);
|
||||
return Math.min(base * multiplier, MAX_SOURCE_SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function isSourceDue(source: KnowledgeSourceConfig, state: SlackSyncState): boolean {
|
||||
const sourceState = state.sources?.[source.id];
|
||||
if (!sourceState?.lastSyncAt) return true;
|
||||
const lastSyncMs = Date.parse(sourceState.lastSyncAt);
|
||||
const intervalMs = source.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
|
||||
return !Number.isFinite(lastSyncMs) || Date.now() - lastSyncMs >= intervalMs;
|
||||
return !Number.isFinite(lastSyncMs) || Date.now() - lastSyncMs >= effectiveIntervalMs(source, sourceState);
|
||||
}
|
||||
|
||||
function safeSegment(value: string): string {
|
||||
|
|
@ -118,8 +136,7 @@ function getMessageAuthor(message: SlackMessage): string {
|
|||
async function runAgentSlack(args: string[]): Promise<unknown> {
|
||||
const result = await execAgentSlack(args, { timeoutMs: 30_000, maxBuffer: 2 * 1024 * 1024 });
|
||||
if (!result.ok) {
|
||||
// Sync error handling stays throw-based for now; callers log per run.
|
||||
throw new Error(`agent-slack ${result.kind}: ${result.message}`);
|
||||
throw new AgentSlackRunError(result.kind, result.message);
|
||||
}
|
||||
return result.data ?? [];
|
||||
}
|
||||
|
|
@ -249,23 +266,17 @@ async function publishSlackSyncEvent(files: string[]): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
async function syncSource(source: KnowledgeSourceConfig): Promise<string[]> {
|
||||
if (!source.enabled || source.provider !== 'slack') return [];
|
||||
/**
|
||||
* Sync one source's channels into artifact files. Mutates state.channels as
|
||||
* it goes; throws AgentSlackRunError on CLI failure (status bookkeeping is
|
||||
* the caller's job).
|
||||
*/
|
||||
async function syncSource(source: KnowledgeSourceConfig, state: SlackSyncState): Promise<string[]> {
|
||||
if (source.scopes.length === 0) {
|
||||
console.log(`[SlackKnowledge] Source ${source.id} has no channel scopes; skipping`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const state = loadState();
|
||||
const sourceState = state.sources?.[source.id];
|
||||
const intervalMs = source.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
|
||||
if (sourceState?.lastSyncAt) {
|
||||
const lastSyncMs = Date.parse(sourceState.lastSyncAt);
|
||||
if (Number.isFinite(lastSyncMs) && Date.now() - lastSyncMs < intervalMs) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const writtenFiles: string[] = [];
|
||||
|
||||
for (const scope of source.scopes.filter(scope => scope.type === 'channel')) {
|
||||
|
|
@ -294,15 +305,29 @@ async function syncSource(source: KnowledgeSourceConfig): Promise<string[]> {
|
|||
state.channels[key] = { lastSeenTs: newestTs };
|
||||
}
|
||||
|
||||
state.lastSyncAt = new Date().toISOString();
|
||||
state.sources = {
|
||||
...(state.sources ?? {}),
|
||||
[source.id]: { lastSyncAt: state.lastSyncAt },
|
||||
};
|
||||
saveState(state);
|
||||
return writtenFiles;
|
||||
}
|
||||
|
||||
function recordSourceResult(state: SlackSyncState, sourceId: string, error?: { kind: AgentSlackErrorKind | 'unknown'; message: string }): void {
|
||||
const previous = state.sources?.[sourceId];
|
||||
const now = new Date().toISOString();
|
||||
const next: SlackSourceSyncState = { lastSyncAt: now };
|
||||
if (error) {
|
||||
next.lastStatus = 'error';
|
||||
next.lastError = error;
|
||||
if (error.kind === 'rate_limited') {
|
||||
// Doubles each consecutive rate limit; effectiveIntervalMs caps
|
||||
// the resulting interval at 30 min, the clamp keeps the stored
|
||||
// value sane in the state file.
|
||||
next.backoffMultiplier = Math.min(Math.max(2, (previous?.backoffMultiplier ?? 1) * 2), 1024);
|
||||
}
|
||||
} else {
|
||||
next.lastStatus = 'ok';
|
||||
}
|
||||
state.lastSyncAt = now;
|
||||
state.sources = { ...(state.sources ?? {}), [sourceId]: next };
|
||||
}
|
||||
|
||||
export async function syncSlackKnowledgeSources(): Promise<string[]> {
|
||||
const state = loadState();
|
||||
const sources = knowledgeSourcesRepo
|
||||
|
|
@ -321,13 +346,38 @@ export async function syncSlackKnowledgeSources(): Promise<string[]> {
|
|||
const writtenFiles: string[] = [];
|
||||
let hadError = false;
|
||||
|
||||
try {
|
||||
for (const source of sources) {
|
||||
const files = await syncSource(source);
|
||||
for (const source of sources) {
|
||||
let rateLimited = false;
|
||||
try {
|
||||
const files = await syncSource(source, state);
|
||||
writtenFiles.push(...files);
|
||||
recordSourceResult(state, source.id);
|
||||
} catch (error) {
|
||||
// One failing source must not abort the others.
|
||||
hadError = true;
|
||||
const kind = error instanceof AgentSlackRunError ? error.kind : 'unknown';
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
recordSourceResult(state, source.id, { kind, message });
|
||||
rateLimited = kind === 'rate_limited';
|
||||
console.error(`[SlackKnowledge] Sync failed for source ${source.id} (${kind}):`, message);
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'error',
|
||||
message: `Slack knowledge sync error for source ${source.id} (${kind})`,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
// Persist after every source so progress and status survive a crash.
|
||||
saveState(state);
|
||||
// Rate limits are per-token, so the remaining sources would hit the
|
||||
// same wall — end this run; they stay due for the next tick.
|
||||
if (rateLimited) break;
|
||||
}
|
||||
|
||||
if (writtenFiles.length > 0) {
|
||||
if (writtenFiles.length > 0) {
|
||||
try {
|
||||
const relativeFiles = writtenFiles.map(file => path.relative(WorkDir, file));
|
||||
const limitedFiles = limitEventItems(relativeFiles);
|
||||
await serviceLogger.log({
|
||||
|
|
@ -341,18 +391,10 @@ export async function syncSlackKnowledgeSources(): Promise<string[]> {
|
|||
truncated: limitedFiles.truncated,
|
||||
});
|
||||
await publishSlackSyncEvent(writtenFiles);
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error('[SlackKnowledge] Failed to publish sync results:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error('[SlackKnowledge] Sync failed:', error);
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'error',
|
||||
message: 'Slack knowledge sync error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
await serviceLogger.log({
|
||||
|
|
@ -373,6 +415,39 @@ export function getSlackKnowledgeArtifactRoot(): string {
|
|||
return ARTIFACT_ROOT;
|
||||
}
|
||||
|
||||
export type SlackKnowledgeSourceStatus = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
lastSyncAt?: string;
|
||||
lastStatus?: 'ok' | 'error';
|
||||
lastError?: { kind: string; message: string };
|
||||
/** When the source next becomes due, given interval + backoff. */
|
||||
nextDueAt?: string;
|
||||
};
|
||||
|
||||
/** Per-source sync status for the slack:knowledgeStatus IPC channel. */
|
||||
export function getSlackKnowledgeSyncStatus(): SlackKnowledgeSourceStatus[] {
|
||||
const state = loadState();
|
||||
return knowledgeSourcesRepo
|
||||
.getConfig()
|
||||
.sources
|
||||
.filter(source => source.provider === 'slack')
|
||||
.map(source => {
|
||||
const sourceState = state.sources?.[source.id];
|
||||
const lastMs = sourceState?.lastSyncAt ? Date.parse(sourceState.lastSyncAt) : NaN;
|
||||
return {
|
||||
id: source.id,
|
||||
enabled: source.enabled,
|
||||
lastSyncAt: sourceState?.lastSyncAt,
|
||||
lastStatus: sourceState?.lastStatus,
|
||||
lastError: sourceState?.lastError,
|
||||
nextDueAt: Number.isFinite(lastMs)
|
||||
? new Date(lastMs + effectiveIntervalMs(source, sourceState)).toISOString()
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
let wakeResolve: (() => void) | null = null;
|
||||
|
||||
export function triggerSync(): void {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import fs from 'node:fs';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { agentSlackShimEnv, resolveAgentSlackCli, runAgentSlack } from './agent-slack-exec.js';
|
||||
import { agentSlackShimEnv, classifyAgentSlackStderr, resolveAgentSlackCli, runAgentSlack } from './agent-slack-exec.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
|
|
@ -15,6 +15,7 @@ let jsonCli: string;
|
|||
let garbageCli: string;
|
||||
let sleepCli: string;
|
||||
let failingCli: string;
|
||||
let stdinCli: string;
|
||||
|
||||
function writeFixture(name: string, code: string): string {
|
||||
const file = path.join(fixtureDir, name);
|
||||
|
|
@ -25,6 +26,7 @@ function writeFixture(name: string, code: string): string {
|
|||
beforeAll(() => {
|
||||
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-slack-exec-test-'));
|
||||
jsonCli = writeFixture('json.cjs', `process.stdout.write(JSON.stringify({ args: process.argv.slice(2) }));`);
|
||||
stdinCli = writeFixture('stdin.cjs', `let s = ''; process.stdin.on('data', c => s += c); process.stdin.on('end', () => process.stdout.write(s.trim()));`);
|
||||
garbageCli = writeFixture('garbage.cjs', `process.stdout.write('definitely: not json');`);
|
||||
sleepCli = writeFixture('sleep.cjs', `setTimeout(() => {}, 60_000);`);
|
||||
failingCli = writeFixture('fail.cjs', `process.stderr.write('boom'); process.exit(2);`);
|
||||
|
|
@ -92,6 +94,12 @@ describe('runAgentSlack', () => {
|
|||
if (result.ok) expect(result.stdout).toBe('definitely: not json');
|
||||
});
|
||||
|
||||
it('writes opts.input to the child stdin (parse-curl path)', async () => {
|
||||
const result = await runAgentSlack([], { resolve: via(stdinCli), parseJson: false, input: "curl 'https://team.slack.com'" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.stdout).toBe("curl 'https://team.slack.com'");
|
||||
});
|
||||
|
||||
it('reports not_installed when no binary resolves', async () => {
|
||||
const result = await runAgentSlack(['--version'], {
|
||||
resolve: { bundledCandidates: [missing], globalCandidates: [missing], pathProbe: () => null },
|
||||
|
|
@ -109,9 +117,52 @@ describe('runAgentSlack', () => {
|
|||
expect(result).toMatchObject({ ok: false, kind: 'timeout' });
|
||||
}, 10_000);
|
||||
|
||||
it('reports exec_error with stderr on non-zero exit', async () => {
|
||||
it('classifies stderr on non-zero exit (unrecognized → unknown)', async () => {
|
||||
const result = await runAgentSlack([], { resolve: via(failingCli) });
|
||||
expect(result).toMatchObject({ ok: false, kind: 'exec_error', stderr: 'boom' });
|
||||
expect(result).toMatchObject({ ok: false, kind: 'unknown', stderr: 'boom', message: 'boom' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyAgentSlackStderr', () => {
|
||||
// Fixture corpus: strings marked (captured) are real stderr induced on a
|
||||
// machine with no Slack auth; the rest are taken verbatim from the
|
||||
// agent-slack 0.9.3 / @slack/web-api 7.17 sources.
|
||||
const cases: Array<[string, ReturnType<typeof classifyAgentSlackStderr>]> = [
|
||||
// not_authed — empty credential store (auth auto-import cascade)
|
||||
['Firefox extraction is not supported on win32.', 'not_authed'], // (captured)
|
||||
['Slack Desktop data not found. Checked:\n - C:\\Users\\X\\AppData\\Roaming\\Slack\\Local Storage\\leveldb', 'not_authed'], // (captured)
|
||||
// not_authed — Slack API codes, both client flavors
|
||||
['invalid_auth', 'not_authed'],
|
||||
['token_expired', 'not_authed'],
|
||||
['An API error occurred: invalid_auth', 'not_authed'],
|
||||
['account_inactive', 'not_authed'],
|
||||
// rate_limited
|
||||
['ratelimited', 'rate_limited'],
|
||||
['A rate-limit has been reached, you may retry this request in 30 seconds', 'rate_limited'],
|
||||
['Slack HTTP 429 calling conversations.history', 'rate_limited'],
|
||||
// network
|
||||
['A request error occurred: getaddrinfo ENOTFOUND slack.com', 'network'],
|
||||
['fetch failed', 'network'],
|
||||
['connect ECONNREFUSED 127.0.0.1:443', 'network'],
|
||||
['Slack HTTP 503 calling conversations.list', 'network'],
|
||||
// bad_channel
|
||||
['channel_not_found', 'bad_channel'],
|
||||
['An API error occurred: channel_not_found', 'bad_channel'],
|
||||
['Could not resolve channel name: #nonexistent-channel', 'bad_channel'],
|
||||
['not_in_channel', 'bad_channel'],
|
||||
// unknown
|
||||
['Ambiguous channel name across multiple workspaces. Pass --workspace "<url>"', 'unknown'],
|
||||
['', 'unknown'],
|
||||
];
|
||||
|
||||
it.each(cases)('%j → %s', (stderr, expected) => {
|
||||
expect(classifyAgentSlackStderr(stderr)).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not misread substrings of longer identifiers', () => {
|
||||
// "speedratelimitedness" style false positives guarded by boundaries
|
||||
expect(classifyAgentSlackStderr('field xratelimitedx in payload')).toBe('unknown');
|
||||
expect(classifyAgentSlackStderr('saved to channel_not_found_archive.txt')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -25,12 +25,51 @@ export interface ResolvedAgentSlack {
|
|||
source: AgentSlackSource;
|
||||
}
|
||||
|
||||
export type AgentSlackErrorKind = 'not_installed' | 'timeout' | 'parse_error' | 'exec_error';
|
||||
export type AgentSlackErrorKind =
|
||||
// Structural failures (detected without running / from the spawn itself)
|
||||
| 'not_installed' | 'timeout' | 'parse_error'
|
||||
// CLI failures classified from stderr (exit code is always 1)
|
||||
| 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown';
|
||||
|
||||
// agent-slack prints `err.message` to stderr and exits 1 for every failure, so
|
||||
// stderr text is the only classification signal. Patterns cover both Slack
|
||||
// client flavors the CLI uses: the browser-token client throws bare Slack
|
||||
// error codes ("invalid_auth"), @slack/web-api wraps them ("An API error
|
||||
// occurred: invalid_auth") — plus the CLI's own messages. Word-ish boundaries
|
||||
// match the CLI's own auth-detection regex.
|
||||
const SLACK_CODE = (codes: string) => new RegExp(`(?:^|[^a-z_])(?:${codes})(?:$|[^a-z_])`, 'i');
|
||||
|
||||
const NOT_AUTHED_RE = SLACK_CODE('invalid_auth|token_expired|token_revoked|account_inactive|not_authed');
|
||||
// Empty credential store surfaces as the auth auto-import cascade failing,
|
||||
// e.g. "Slack Desktop data not found." / "Firefox extraction is not supported
|
||||
// on win32." (real stderr captured on Windows with no Slack installed).
|
||||
const AUTH_IMPORT_RE = /Slack Desktop data not found|extraction is not supported/i;
|
||||
const RATE_LIMITED_RE = /(?:^|[^a-z_])ratelimited(?:$|[^a-z_])|A rate-?limit has been reached|Slack HTTP 429/i;
|
||||
const NETWORK_RE = /A request error occurred|fetch failed|socket hang up|ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EPIPE|Slack HTTP 5\d\d/i;
|
||||
const BAD_CHANNEL_RE = new RegExp(
|
||||
`${SLACK_CODE('channel_not_found|not_in_channel|is_archived').source}|Could not resolve channel name`, 'i');
|
||||
|
||||
/** Classify an agent-slack failure from its stderr. Exported for tests. */
|
||||
export function classifyAgentSlackStderr(stderr: string): Exclude<AgentSlackErrorKind, 'not_installed' | 'timeout' | 'parse_error'> {
|
||||
if (RATE_LIMITED_RE.test(stderr)) return 'rate_limited';
|
||||
if (BAD_CHANNEL_RE.test(stderr)) return 'bad_channel';
|
||||
if (NOT_AUTHED_RE.test(stderr) || AUTH_IMPORT_RE.test(stderr)) return 'not_authed';
|
||||
if (NETWORK_RE.test(stderr)) return 'network';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export type AgentSlackResult =
|
||||
| { ok: true; stdout: string; data: unknown }
|
||||
| { ok: false; kind: AgentSlackErrorKind; message: string; stderr: string };
|
||||
|
||||
/** Throwable wrapper for callers with throw-based control flow (sync loop). */
|
||||
export class AgentSlackRunError extends Error {
|
||||
constructor(public readonly kind: AgentSlackErrorKind, message: string) {
|
||||
super(message);
|
||||
this.name = 'AgentSlackRunError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Re-probe even if a previous resolution succeeded. */
|
||||
refresh?: boolean;
|
||||
|
|
@ -45,6 +84,8 @@ export interface RunAgentSlackOptions {
|
|||
maxBuffer?: number;
|
||||
/** Set false for commands with non-JSON output (e.g. --version). */
|
||||
parseJson?: boolean;
|
||||
/** Written to the child's stdin then closed (e.g. `auth parse-curl`). */
|
||||
input?: string;
|
||||
/** Test hook — bypass the default resolver. */
|
||||
resolve?: ResolveOptions;
|
||||
}
|
||||
|
|
@ -161,13 +202,20 @@ export async function runAgentSlack(args: string[], opts: RunAgentSlackOptions =
|
|||
// process.execPath inside Electron's main process is the Electron
|
||||
// binary, not node — ELECTRON_RUN_AS_NODE makes it behave as plain
|
||||
// node (and is ignored when we already run under real node).
|
||||
const result = await execFileAsync(process.execPath, [resolved.entry, ...args], {
|
||||
const promise = execFileAsync(process.execPath, [resolved.entry, ...args], {
|
||||
timeout,
|
||||
maxBuffer: opts.maxBuffer ?? DEFAULT_MAX_BUFFER,
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
});
|
||||
// promisify(execFile) exposes the ChildProcess as `.child`, letting us
|
||||
// feed stdin for commands that read it (e.g. `auth parse-curl`). Close
|
||||
// stdin so those commands stop waiting for more input.
|
||||
if (opts.input != null) {
|
||||
promise.child.stdin?.end(opts.input);
|
||||
}
|
||||
const result = await promise;
|
||||
stdout = result.stdout;
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException & { killed?: boolean; signal?: string; stderr?: string };
|
||||
|
|
@ -178,7 +226,7 @@ export async function runAgentSlack(args: string[], opts: RunAgentSlackOptions =
|
|||
if (err.killed || err.signal === 'SIGTERM') {
|
||||
return { ok: false, kind: 'timeout', message: `agent-slack timed out after ${timeout}ms`, stderr };
|
||||
}
|
||||
return { ok: false, kind: 'exec_error', message: err.message ?? 'agent-slack failed', stderr };
|
||||
return { ok: false, kind: classifyAgentSlackStderr(stderr), message: stderr.trim() || err.message || 'agent-slack failed', stderr };
|
||||
}
|
||||
|
||||
if (opts.parseJson === false) {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ const KnowledgeSourceScopeSchema = z.object({
|
|||
workspaceUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
// Mirrors AgentSlackErrorKind in @x/core/slack/agent-slack-exec. Kept as a
|
||||
// standalone enum so the renderer can branch on failure cause without
|
||||
// importing core.
|
||||
const SlackErrorKindSchema = z.enum([
|
||||
'not_installed', 'timeout', 'parse_error',
|
||||
'not_authed', 'rate_limited', 'network', 'bad_channel', 'unknown',
|
||||
]);
|
||||
|
||||
const KnowledgeSourceConfigSchema = z.object({
|
||||
id: z.string(),
|
||||
provider: z.enum(['gmail', 'meeting', 'voice_memo', 'slack', 'github', 'linear']),
|
||||
|
|
@ -532,6 +540,52 @@ const ipcSchemas = {
|
|||
res: z.object({
|
||||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||||
error: z.string().optional(),
|
||||
errorKind: SlackErrorKindSchema.optional(),
|
||||
}),
|
||||
},
|
||||
'slack:importDesktopAuth': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
ok: z.boolean(),
|
||||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||||
error: z.string().optional(),
|
||||
errorKind: SlackErrorKindSchema.optional(),
|
||||
}),
|
||||
},
|
||||
'slack:quitAndImportDesktop': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
ok: z.boolean(),
|
||||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||||
error: z.string().optional(),
|
||||
errorKind: SlackErrorKindSchema.optional(),
|
||||
}),
|
||||
},
|
||||
'slack:parseCurlAuth': {
|
||||
req: z.object({ curl: z.string() }),
|
||||
res: z.object({
|
||||
ok: z.boolean(),
|
||||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||||
error: z.string().optional(),
|
||||
errorKind: SlackErrorKindSchema.optional(),
|
||||
}),
|
||||
},
|
||||
'slack:knowledgeStatus': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
cli: z.object({
|
||||
available: z.boolean(),
|
||||
version: z.string().optional(),
|
||||
source: z.enum(['bundled', 'global', 'path']).optional(),
|
||||
}),
|
||||
sources: z.array(z.object({
|
||||
id: z.string(),
|
||||
enabled: z.boolean(),
|
||||
lastSyncAt: z.string().optional(),
|
||||
lastStatus: z.enum(['ok', 'error']).optional(),
|
||||
lastError: z.object({ kind: z.string(), message: z.string() }).optional(),
|
||||
nextDueAt: z.string().optional(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
'slack:listChannels': {
|
||||
|
|
@ -566,6 +620,7 @@ const ipcSchemas = {
|
|||
url: z.string().optional(),
|
||||
})),
|
||||
error: z.string().optional(),
|
||||
errorKind: SlackErrorKindSchema.optional(),
|
||||
}),
|
||||
},
|
||||
'knowledgeSources:getConfig': {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue