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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
"scripts": {
"postinstall": "node scripts/install-relayfile-mount.mjs --optional",
"relayfile-mount:install": "node scripts/install-relayfile-mount.mjs",
"relayfile-mount:install:release": "node scripts/install-relayfile-mount.mjs --release",
"generate:mcp-resources": "node scripts/generate-mcp-extraResources.mjs",
"verify:mcp-resources-drift": "node scripts/generate-mcp-extraResources.mjs --check",
"dev": "electron-vite dev",
"build": "npm run generate:mcp-resources && node scripts/install-relayfile-mount.mjs --optional && electron-vite build",
"preview": "electron-vite preview",
"dist:mac": "npm run relayfile-mount:install && npm run build && electron-builder --mac --publish never",
"dist:mac": "npm run relayfile-mount:install:release && RELAYFILE_MOUNT_INSTALL_SOURCE=release npm run build && electron-builder --mac --publish never",
"verify:mcp-spawn": "node scripts/verify-mcp-spawn.mjs",
"release:mac": "npm run relayfile-mount:install && npm run build && electron-builder --mac --publish always",
"release:mac": "npm run relayfile-mount:install:release && RELAYFILE_MOUNT_INSTALL_SOURCE=release npm run build && electron-builder --mac --publish always",
"test": "node --experimental-strip-types --no-warnings --test 'src/main/__tests__/*.test.ts'",
"personas:refresh": "npx --yes agentworkforce install @agentworkforce/persona-autonomous-actor --overwrite"
},
Expand Down
50 changes: 40 additions & 10 deletions scripts/install-relayfile-mount.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
// app (see extraResources in electron-builder.yml) and found at runtime by
// src/main/relayfile-mount-launcher.ts.
//
// Resolution order:
// Default development resolution order:
// 1. RELAYFILE_MOUNT_BIN — explicit path to a prebuilt binary
// 2. RELAYFILE_DIST_DIR / sibling — local relayfile source checkout (../relayfile/dist)
// 3. GitHub release download — relayfile-mount-<platform>-<arch> from the
// AgentWorkforce/relayfile release matching the
// installed @relayfile/sdk version
//
// Release mode (`--release` or RELAYFILE_MOUNT_INSTALL_SOURCE=release) ignores
// local binaries and installs from the matching GitHub release unless that
// release binary is already present.
import { access, chmod, copyFile, mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises'
import { constants, createWriteStream } from 'node:fs'
import { dirname, join, resolve, sep } from 'node:path'
Expand All @@ -17,6 +21,7 @@ import { pipeline } from 'node:stream/promises'
import { createRequire } from 'node:module'

const optional = process.argv.includes('--optional')
const releaseMode = process.argv.includes('--release') || process.env.RELAYFILE_MOUNT_INSTALL_SOURCE === 'release'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const pearRoot = resolve(scriptDir, '..')
const require = createRequire(join(pearRoot, 'package.json'))
Expand Down Expand Up @@ -68,15 +73,17 @@ function fail(message) {
process.exit(1)
}

async function installFromFile(source) {
async function installFromFile(source, marker = `local:${source}`) {
await mkdir(dirname(target), { recursive: true })
if ((await realpath(source).catch(() => null)) === (await realpath(target).catch(() => null))) {
await chmod(target, 0o755)
await writeFile(versionMarker, `${marker}\n`)
console.log(`[relayfile-mount] already installed at ${target}`)
return
}
await copyFile(source, target)
await chmod(target, 0o755)
await writeFile(versionMarker, `${marker}\n`)
console.log(`[relayfile-mount] installed ${source} -> ${target}`)
}

Expand All @@ -102,6 +109,35 @@ async function downloadRelease(version) {
console.log(`[relayfile-mount] installed v${version} -> ${target}`)
}

async function installedMarker() {
return (await canRead(target)) && (await canRead(versionMarker))
? (await readFile(versionMarker, 'utf8')).trim()
: null
}

function requireSdkVersion() {
const version = sdkVersion()
if (!version) {
fail('could not determine @relayfile/sdk version (is it installed?)')
}
return version
}

if (releaseMode) {
const version = requireSdkVersion()
const installedVersion = await installedMarker()
if (installedVersion === version) {
console.log(`[relayfile-mount] v${version} already installed at ${target}`)
process.exit(0)
}
try {
await downloadRelease(version)
} catch (error) {
fail(error instanceof Error ? error.message : String(error))
}
process.exit(0)
}

// 1. Explicit binary path
if (process.env.RELAYFILE_MOUNT_BIN) {
const source = resolve(process.env.RELAYFILE_MOUNT_BIN)
Expand All @@ -123,14 +159,8 @@ if (await canRead(localDist)) {
}

// 3. GitHub release matching the installed @relayfile/sdk version
const version = sdkVersion()
if (!version) {
fail('could not determine @relayfile/sdk version (is it installed?)')
}

const installedVersion = (await canRead(target)) && (await canRead(versionMarker))
? (await readFile(versionMarker, 'utf8')).trim()
: null
const version = requireSdkVersion()
const installedVersion = await installedMarker()
if (installedVersion === version) {
console.log(`[relayfile-mount] v${version} already installed at ${target}`)
process.exit(0)
Expand Down
44 changes: 42 additions & 2 deletions src/main/__tests__/integration-event-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ function makeHarness(
sent: SentMessage[]
listAgentsCalls: string[]
deliveryConfirmationCalls: SentMessage[]
unsubscribedCount: () => number
emit(event: ChangeEvent): Promise<void>
} {
const subscribeCalls: SubscribeCall[] = []
Expand All @@ -147,6 +148,7 @@ function makeHarness(
const listAgentsCalls: string[] = []
const deliveryConfirmationCalls: SentMessage[] = []
const subscriptions: Subscription[] = []
let unsubscribedCount = 0
let activeSends = 0

const bridge = new IntegrationEventBridge({
Expand All @@ -156,7 +158,7 @@ function makeHarness(
client: () => ({
subscribe(globs, onChange, options) {
subscribeCalls.push({ globs: [...globs], onChange, options })
const subscription = { unsubscribe: async () => undefined }
const subscription = { unsubscribe: async () => { unsubscribedCount += 1 } }
subscriptions.push(subscription)
return subscription
},
Expand Down Expand Up @@ -205,7 +207,16 @@ function makeHarness(
await waitForDispatcherTick()
}

return { bridge, subscribeCalls, readFileCalls, sent, listAgentsCalls, deliveryConfirmationCalls, emit }
return {
bridge,
subscribeCalls,
readFileCalls,
sent,
listAgentsCalls,
deliveryConfirmationCalls,
unsubscribedCount: () => unsubscribedCount,
emit
}
}

beforeEach(() => {
Expand Down Expand Up @@ -258,6 +269,35 @@ test('integration events route only to the targets for the matching integration
assert.deepEqual(harness.sent.map((message) => message.input.to), ['alice', 'bob'])
})

test('can close stale project subscriptions while keeping the active project stream', async () => {
const harness = makeHarness()

await harness.bridge.reconcile('stale-project', [
integration({
provider: 'slack',
integrationId: 'slack-1',
mountPaths: ['/slack/channels/C123'],
scope: { notifyAgents: ['alice'] }
})
])
await harness.bridge.reconcile('active-project', [
integration({
provider: 'slack',
integrationId: 'slack-1',
mountPaths: ['/slack/channels/C123'],
scope: { notifyAgents: ['alice'] }
})
])

assert.equal(harness.subscribeCalls.length, 2)

await harness.bridge.closeAllExcept('active-project')
assert.equal(harness.unsubscribedCount(), 1)

await harness.bridge.close('active-project')
assert.equal(harness.unsubscribedCount(), 2)
})

test('channel notification targets do not fall back to all project agents', async () => {
const harness = makeHarness()

Expand Down
47 changes: 47 additions & 0 deletions src/main/__tests__/relayfile-mount-launcher-import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
import assert from 'node:assert/strict'

const __dirname = dirname(fileURLToPath(import.meta.url))
const launcherPath = join(__dirname, '..', 'relayfile-mount-launcher.ts')
const packageJsonPath = join(__dirname, '..', '..', '..', 'package.json')
const installScriptPath = join(__dirname, '..', '..', '..', 'scripts', 'install-relayfile-mount.mjs')

test('relayfile mount launcher imports the launcher from the SDK launcher entrypoint', async () => {
const source = await readFile(launcherPath, 'utf8')

assert.match(
source,
/from ['"]@relayfile\/sdk\/mount-launcher['"]/,
'createDefaultMountLauncher must come from @relayfile/sdk/mount-launcher'
)
assert.doesNotMatch(
source,
/createDefaultMountLauncher[\s\S]*from ['"]@relayfile\/sdk['"]/,
'the root @relayfile/sdk export does not expose createDefaultMountLauncher at runtime'
)
})

test('mac release scripts install relayfile-mount from release assets only', async () => {
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
scripts: Record<string, string>
}
const installScript = await readFile(installScriptPath, 'utf8')

assert.equal(
packageJson.scripts['relayfile-mount:install:release'],
'node scripts/install-relayfile-mount.mjs --release'
)
for (const scriptName of ['dist:mac', 'release:mac']) {
const script = packageJson.scripts[scriptName]
assert.match(script, /relayfile-mount:install:release/)
assert.match(script, /RELAYFILE_MOUNT_INSTALL_SOURCE=release npm run build/)
}
assert.match(installScript, /const releaseMode = /)
assert.match(
installScript,
/if \(releaseMode\) \{[\s\S]*?await downloadRelease\(version\)[\s\S]*?process\.exit\(0\)/
)
})
4 changes: 2 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,8 @@ function scheduleIntegrationAgentRefresh(projectId: string): void {

const timer = setTimeout(() => {
integrationAgentRefreshTimers.delete(projectId)
void integrationsManager.notifyAgentState(projectId).catch((error) => {
console.warn('[integrations] Failed to notify newly ready agent:', error instanceof Error ? error.message : String(error))
void integrationsManager.refreshAgentState(projectId).catch((error) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Switching post-ready handling from notify to refresh suppresses integration system messages for newly ready agents, which leaves persona spawns without integration context.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/main/index.ts, line 271:

<comment>Switching post-ready handling from notify to refresh suppresses integration system messages for newly ready agents, which leaves persona spawns without integration context.</comment>

<file context>
@@ -268,8 +268,8 @@ function scheduleIntegrationAgentRefresh(projectId: string): void {
     integrationAgentRefreshTimers.delete(projectId)
-    void integrationsManager.notifyAgentState(projectId).catch((error) => {
-      console.warn('[integrations] Failed to notify newly ready agent:', error instanceof Error ? error.message : String(error))
+    void integrationsManager.refreshAgentState(projectId).catch((error) => {
+      console.warn('[integrations] Failed to refresh newly ready agent integration state:', error instanceof Error ? error.message : String(error))
     })
</file context>
Suggested change
void integrationsManager.refreshAgentState(projectId).catch((error) => {
void integrationsManager.notifyAgentState(projectId).catch((error) => {

console.warn('[integrations] Failed to refresh newly ready agent integration state:', error instanceof Error ? error.message : String(error))
})
}, 2_000)
integrationAgentRefreshTimers.set(projectId, timer)
Expand Down
8 changes: 8 additions & 0 deletions src/main/integration-event-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1857,6 +1857,14 @@ export class IntegrationEventBridge {
await Promise.all(Array.from(this.subscriptions.keys()).map((projectId) => this.close(projectId)))
}

async closeAllExcept(projectIdToKeep: string): Promise<void> {
await Promise.all(
Array.from(this.subscriptions.keys())
.filter((projectId) => projectId !== projectIdToKeep)
.map((projectId) => this.close(projectId))
)
}

private async readEventContextPreview(projectId: string, event: ChangeEvent): Promise<EventContextPreview | undefined> {
if (event.type === 'file.deleted' || event.type === 'relayfile.changed.summary') return undefined
const path = eventSummaryValue(event.resource.path)
Expand Down
Loading
Loading