mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
refactor(connector): the desktop takes Discord and Linear conversations as well as Slack (#33927)
The desktop's connector accepts conversations from Slack, Discord and Linear and lists all three sources in its hello; per-source details (the Sent from line) come from one table. Nothing changes until those adapters are configured; behind the experimentalConnectors setting.
This commit is contained in:
@@ -2,7 +2,7 @@ import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
const CONNECTOR_NOTES = [
|
||||
'Development only: drives the connector seam as a `dev` conversation, without Slack. The host refuses it unless its experimentalConnectors setting is on.',
|
||||
'Development only: drives the connector seam as a `dev` conversation, without Orca Cloud. The host refuses it unless its experimentalConnectors setting is on.',
|
||||
'A retry with the same --delivery-id and payload returns the first answer instead of delivering again.'
|
||||
]
|
||||
|
||||
@@ -90,7 +90,7 @@ export const CONNECTOR_COMMAND_SPECS: CommandSpec[] = [
|
||||
usage: 'orca connector reply --text <text> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'text'],
|
||||
notes: [
|
||||
'Run it from an agent a connector message started (Slack today). Orca posts the text to that thread through Orca Cloud; this machine needs no token for the service.',
|
||||
'Run it from an agent a Slack, Discord or Linear message started. Orca posts the text to that thread through Orca Cloud; this machine needs no token for the service.',
|
||||
'Experimental: needs the experimentalConnectors setting and a desktop signed in to Orca.'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function dispatchDelivery(
|
||||
let done: Promise<ConnectorDeliveryResult>
|
||||
switch (delivery.kind) {
|
||||
case 'start': {
|
||||
const target = placeLaunchTarget(delivery.placeId, deliveryId)
|
||||
const target = placeLaunchTarget(delivery.placeId, deliveryId, key.source)
|
||||
if (!target) {
|
||||
return refuse(deliveryId, { outcome: 'unsupported' })
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { jsonBytes } from './connector-snapshot'
|
||||
|
||||
const FEATURES = ['answer', 'cancel', 'reply']
|
||||
const PROMPT_KINDS = ['approval', 'question']
|
||||
// Only Slack conversations reach this machine's seam so far.
|
||||
const SOURCES = ['slack']
|
||||
// Listed by hand: a source joins only once this build's seam handles its conversations.
|
||||
const SOURCES = ['slack', 'discord', 'linear']
|
||||
|
||||
export type ConnectorHelloSaid = { agents: string[]; projects: ConnectorProject[] }
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('connector places', () => {
|
||||
])
|
||||
expect(JSON.stringify(projects)).not.toContain('/src/')
|
||||
const [local, remote] = projects
|
||||
expect(placeLaunchTarget(local!.placeId, 'T1:C9:1')).toEqual({
|
||||
expect(placeLaunchTarget(local!.placeId, 'T1:C9:1', 'slack')).toEqual({
|
||||
kind: 'create-worktree',
|
||||
create: {
|
||||
repo: 'id:orca',
|
||||
@@ -64,7 +64,7 @@ describe('connector places', () => {
|
||||
nameWasGenerated: true
|
||||
}
|
||||
})
|
||||
expect(placeLaunchTarget(remote!.placeId, 'T1:C9:1')).toMatchObject({
|
||||
expect(placeLaunchTarget(remote!.placeId, 'T1:C9:1', 'slack')).toMatchObject({
|
||||
create: { repo: 'id:orca', repoHostId: 'ssh:box' }
|
||||
})
|
||||
})
|
||||
@@ -80,7 +80,7 @@ describe('connector places', () => {
|
||||
)
|
||||
)
|
||||
expect(folder).toMatchObject({ name: 'Docs', kind: 'folder', where: { kind: 'local' } })
|
||||
expect(placeLaunchTarget(folder!.placeId, 'd1')).toEqual({
|
||||
expect(placeLaunchTarget(folder!.placeId, 'd1', 'slack')).toEqual({
|
||||
kind: 'create-folder-workspace',
|
||||
create: { projectGroupId: 'g1', name: expect.stringMatching(/^slack-[0-9a-f]{8}$/) }
|
||||
})
|
||||
@@ -88,13 +88,17 @@ describe('connector places', () => {
|
||||
|
||||
it('derives the same worktree name for a resent start, and refuses a place it cannot read', () => {
|
||||
const [project] = connectorProjects(store([repo({ id: 'orca' })]))
|
||||
expect(placeLaunchTarget(project!.placeId, 'd1')).toEqual(
|
||||
placeLaunchTarget(project!.placeId, 'd1')
|
||||
expect(placeLaunchTarget(project!.placeId, 'd1', 'slack')).toEqual(
|
||||
placeLaunchTarget(project!.placeId, 'd1', 'slack')
|
||||
)
|
||||
expect(placeLaunchTarget(project!.placeId, 'd1')).not.toEqual(
|
||||
placeLaunchTarget(project!.placeId, 'd2')
|
||||
expect(placeLaunchTarget(project!.placeId, 'd1', 'slack')).not.toEqual(
|
||||
placeLaunchTarget(project!.placeId, 'd2', 'slack')
|
||||
)
|
||||
expect(placeLaunchTarget('not-a-place', 'd1')).toBeNull()
|
||||
expect(placeLaunchTarget('not-a-place', 'd1', 'slack')).toBeNull()
|
||||
// The workspace is named for where the conversation came from.
|
||||
expect(placeLaunchTarget(project!.placeId, 'd1', 'linear')).toMatchObject({
|
||||
create: { name: expect.stringMatching(/^linear-[0-9a-f]{8}$/) }
|
||||
})
|
||||
})
|
||||
|
||||
it('lists the most recently used projects first, at most maxHelloProjects, with short names', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// A place is where a Slack start may run; its id rebuilds the launch target with no lookup, so twins on other hosts stay apart.
|
||||
// A place is where a connector start may run; its id rebuilds the launch target with no lookup, so twins on other hosts stay apart.
|
||||
import { createHash } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
CONNECTOR_CLIENT_LIMITS,
|
||||
type ConnectorConversation,
|
||||
type ConnectorProject
|
||||
} from '../../shared/connector-cloud-contract'
|
||||
import {
|
||||
@@ -55,13 +56,14 @@ function decodePlace(placeId: string): Place | null {
|
||||
/** A pure function of the delivery, so a resent start hashes to the same receipt. */
|
||||
export function placeLaunchTarget(
|
||||
placeId: string,
|
||||
deliveryId: string
|
||||
deliveryId: string,
|
||||
source: ConnectorConversation['source']
|
||||
): AgentLaunchParams['target'] | null {
|
||||
const place = decodePlace(placeId)
|
||||
if (!place) {
|
||||
return null
|
||||
}
|
||||
const name = `slack-${createHash('sha256').update(deliveryId).digest('hex').slice(0, 8)}`
|
||||
const name = `${source}-${createHash('sha256').update(deliveryId).digest('hex').slice(0, 8)}`
|
||||
if (place[0] === 'folder') {
|
||||
return { kind: 'create-folder-workspace', create: { projectGroupId: place[1], name } }
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('ConnectorPollClient', () => {
|
||||
agents: ['claude', 'codex'],
|
||||
promptKinds: ['approval', 'question'],
|
||||
features: ['answer', 'cancel', 'reply'],
|
||||
sources: ['slack'],
|
||||
sources: ['slack', 'discord', 'linear'],
|
||||
projects: [{ placeId: project!.placeId, name: 'orca', kind: 'git' }]
|
||||
})
|
||||
expect(fake.events).toEqual([{ kind: 'snapshot', conversations: [] }])
|
||||
|
||||
@@ -87,4 +87,15 @@ describe('connectorSnapshot', () => {
|
||||
)
|
||||
expect(snapshot.conversations[0]?.paneKey).toBe(`0${escaped}`)
|
||||
})
|
||||
|
||||
it('lists every cloud conversation, Slack, Discord or Linear, and never a dev one', () => {
|
||||
const conversations = (['slack', 'discord', 'linear', 'dev'] as const).map((source) =>
|
||||
entry(source, NOW, { conversation: { source, externalId: `${source}-1` } })
|
||||
)
|
||||
expect(
|
||||
connectorSnapshot({ kind: 'snapshot', conversations }, NOW).snapshot.conversations.map(
|
||||
(item) => item.conversation.source
|
||||
)
|
||||
).toEqual(['slack', 'discord', 'linear'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// What a hello's snapshot tells the cloud: recently active Slack conversations, within the contract's caps.
|
||||
// What a hello's snapshot tells the cloud: recently active cloud conversations, within the contract's caps.
|
||||
import type { PendingPrompt } from '../../shared/agent-pending-prompt'
|
||||
import {
|
||||
cloudConversation,
|
||||
CONNECTOR_CLIENT_LIMITS,
|
||||
type ConnectorConversation,
|
||||
type ConnectorEvent
|
||||
} from '../../shared/connector-cloud-contract'
|
||||
import type { ConversationSnapshot } from '../external-conversations/external-conversation-events'
|
||||
import type { ConversationKey } from '../external-conversations/external-conversation-types'
|
||||
|
||||
type Snapshot = Extract<ConnectorEvent, { kind: 'snapshot' }>
|
||||
|
||||
@@ -18,10 +17,6 @@ export const unsupported = (prompt: PendingPrompt): PendingPrompt => ({
|
||||
promptId: prompt.promptId
|
||||
})
|
||||
|
||||
export function slackConversation(key: ConversationKey): ConnectorConversation | null {
|
||||
return key.source === 'slack' ? { source: 'slack', externalId: key.externalId } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked conversations active within the window, newest first, at most maxSnapshotConversations. Working
|
||||
* or prompting counts as active now. Past the event cap, prompts go unsupported, then the least recent go.
|
||||
@@ -34,7 +29,7 @@ export function connectorSnapshot(
|
||||
const since = now - CONNECTOR_CLIENT_LIMITS.snapshotActiveWindowMs
|
||||
const recent = event.conversations
|
||||
.flatMap((entry) => {
|
||||
const conversation = slackConversation(entry.conversation)
|
||||
const conversation = cloudConversation(entry.conversation)
|
||||
const activeAt = entry.working || entry.prompt ? now : entry.activeAt
|
||||
return conversation && activeAt >= since ? [{ ...entry, conversation, activeAt }] : []
|
||||
})
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import { readFileSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { CONNECTOR_CLIENT_LIMITS, type ConnectorEvent } from '../../shared/connector-cloud-contract'
|
||||
import {
|
||||
cloudConversation,
|
||||
CONNECTOR_CLIENT_LIMITS,
|
||||
ConnectorConversation,
|
||||
type ConnectorEvent
|
||||
} from '../../shared/connector-cloud-contract'
|
||||
import { writeSecureJsonFile } from '../../shared/secure-file'
|
||||
import type {
|
||||
ConversationEvent,
|
||||
@@ -10,7 +15,7 @@ import type {
|
||||
} from '../external-conversations/external-conversation-events'
|
||||
import { isMissingFileError } from '../runtime/device-registry-corruption'
|
||||
import { ConnectorOutbox, type ConnectorOutboxHead, type ConnectorStamp } from './connector-outbox'
|
||||
import { connectorSnapshot, jsonBytes, slackConversation, unsupported } from './connector-snapshot'
|
||||
import { connectorSnapshot, jsonBytes, unsupported } from './connector-snapshot'
|
||||
|
||||
const TURN_OUTBOX_FILE = 'connector-turn-outbox.json'
|
||||
const MAX_TURN_ENDS = 200
|
||||
@@ -27,7 +32,7 @@ const StoredOutbox = z.object({
|
||||
turnEnds: z.array(
|
||||
z.object({
|
||||
kind: z.literal('turn-ended'),
|
||||
conversation: z.object({ source: z.literal('slack'), externalId: z.string().min(1) }),
|
||||
conversation: ConnectorConversation,
|
||||
paneKey: z.string().min(1),
|
||||
turnId: z.number(),
|
||||
at: z.number(),
|
||||
@@ -75,7 +80,7 @@ function promptEvent(event: Extract<ConnectorEvent, { kind: 'prompt' }>): Connec
|
||||
}
|
||||
|
||||
export function toConnectorEvent(event: ConversationEvent): ConnectorEvent | null {
|
||||
const conversation = slackConversation(event.conversation)
|
||||
const conversation = cloudConversation(event.conversation)
|
||||
if (!conversation) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Gate every connector.* conversation handler on this host's experimentalConnectors setting, regardless of caller. */
|
||||
|
||||
import { delayUnlessAborted } from '../../../../shared/abort-aware-delay'
|
||||
import { cloudConversation } from '../../../../shared/connector-cloud-contract'
|
||||
import {
|
||||
ConnectorAnswer,
|
||||
ConnectorCancel,
|
||||
@@ -94,20 +95,20 @@ export const CONNECTOR_METHODS = [
|
||||
deliveryId
|
||||
)
|
||||
}),
|
||||
// `orca connector reply`: the calling pane names its conversation and turn; Orca Cloud posts it.
|
||||
// `orca connector reply`: the calling pane names its cloud conversation and turn; Orca Cloud posts it.
|
||||
defineMethod({
|
||||
name: 'connector.reply',
|
||||
permission: 'workspace',
|
||||
params: ConnectorReply,
|
||||
handler: async ({ paneKey, replyId, text }, context) => {
|
||||
const target = requireSeam(context.runtime).replyTarget(paneKey)
|
||||
if (target?.key.source !== 'slack') {
|
||||
const conversation = target && cloudConversation(target.key)
|
||||
if (!target || !conversation) {
|
||||
throw new Error('not_a_cloud_conversation')
|
||||
}
|
||||
if (target.turnId === null) {
|
||||
throw new Error('no_current_turn')
|
||||
}
|
||||
const conversation = { source: 'slack' as const, externalId: target.key.externalId }
|
||||
const sent = await context.runtime
|
||||
.getConnectorCloud()
|
||||
?.reply({ replyId, conversation, turnId: target.turnId, text })
|
||||
|
||||
@@ -37,12 +37,24 @@ export const ConnectorErrorResponse = z.object({ error: z.string().min(1) })
|
||||
// Each hello gets a revision above every earlier one for this machine; polls, acks and events carry it.
|
||||
export const ConnectorHelloResponse = z.object({ helloRev: z.number().int().positive() })
|
||||
|
||||
const ConnectorConversation = z.object({
|
||||
source: z.literal('slack'),
|
||||
/** Where an Orca Cloud conversation comes from. */
|
||||
export const CONNECTOR_CLOUD_SOURCES = ['slack', 'discord', 'linear'] as const
|
||||
|
||||
export const ConnectorConversation = z.object({
|
||||
source: z.enum(CONNECTOR_CLOUD_SOURCES),
|
||||
externalId: z.string().min(1)
|
||||
})
|
||||
export type ConnectorConversation = z.infer<typeof ConnectorConversation>
|
||||
|
||||
/** The conversation as Orca Cloud names it; null for one that never came through the cloud (e.g. `dev`). */
|
||||
export function cloudConversation(key: {
|
||||
source: string
|
||||
externalId: string
|
||||
}): ConnectorConversation | null {
|
||||
const parsed = ConnectorConversation.safeParse(key)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
const DeliveryBase = {
|
||||
deliveryId: z.string().min(1),
|
||||
expiresAt: z.number().int().positive(),
|
||||
|
||||
Reference in New Issue
Block a user