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
7 changes: 2 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,9 @@ jobs:
# Renderer typecheck (tsconfig.web.json) is clean today — BLOCKING so
# renderer type regressions can't land.
- run: npm run typecheck:web
# Main+preload typecheck (tsconfig.node.json) surfaces ~95 pre-existing
# type errors (see PR "CI: enforce typecheck and run the full test suite"
# for the full list). NON-BLOCKING until those are fixed; then delete the
# flag (and this comment) so it becomes a hard gate too.
# Main+preload typecheck (tsconfig.node.json) is clean today — BLOCKING so
# main/preload type regressions can't land.
- run: npm run typecheck:node
continue-on-error: true
- run: npm test
# Full vitest suite (node + dom projects). Previously CI only ran the single
# src/main/broker.test.ts file; this now gates the entire suite (19 files / 263 tests).
Expand Down
8 changes: 6 additions & 2 deletions src/main/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,8 +591,12 @@ describe('getAccessToken (refresh flow)', () => {
apiUrl: 'https://cloud.example',
expiresAt: new Date(Date.now() - 1_000).toISOString()
})
let resolveFetch: ((value: unknown) => void) | null = null
mock.fetchMock.mockImplementationOnce(() => new Promise((resolve) => {
// Promise<unknown> is explicit: the executor never calls resolve() itself
// (the test does, below), so TS would otherwise infer the resolve param as
// never. The definite-assignment `!` keeps resolveFetch callable since the
// mock implementation assigns it synchronously when fetch is first invoked.
let resolveFetch!: (value: unknown) => void
mock.fetchMock.mockImplementationOnce(() => new Promise<unknown>((resolve) => {
resolveFetch = resolve
}))

Expand Down
4 changes: 2 additions & 2 deletions src/main/broker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ const mock = vi.hoisted(() => {
}

class HarnessDriverClient {
static spawn = vi.fn(async () => {
static spawn = vi.fn(async (_options: unknown) => {
const client = createMockClient(state.nextLocalAgents.splice(0))
state.spawnedClients.push(client)
return client
Expand Down Expand Up @@ -224,7 +224,7 @@ function restoreProcessResourcesPath(): void {
if (originalResourcesPathDescriptor) {
Object.defineProperty(process, 'resourcesPath', originalResourcesPathDescriptor)
} else {
delete (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
delete (process as { resourcesPath?: string }).resourcesPath
}
}

Expand Down
18 changes: 14 additions & 4 deletions src/main/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,8 +649,10 @@ function isDeliveryEventForMessage(

function deliveryFailureMessage(event: BrokerEvent): string {
if (!isRecord(event)) return 'Broker delivery failed'
const reason = typeof event.reason === 'string' ? event.reason : undefined
const lastError = typeof event.lastError === 'string' ? event.lastError : undefined
// reason/lastError are not declared on the base BrokerEvent union; read them
// through the same dynamic accessor used for other optional broker fields.
const reason = brokerEventString(event, 'reason')
const lastError = brokerEventString(event, 'lastError')
return reason || lastError || 'Broker delivery failed'
}

Expand Down Expand Up @@ -841,8 +843,16 @@ function directMessageTargetFromRelayMessage(
.filter(Boolean)
const target = message.target

if (target?.kind === 'agent' && target.agentName) return target.agentName
if (target?.kind === 'channel' && target.channelName) return normalizeChatChannelTarget(target.channelName)
// RelayMessageTarget's union includes a loose `{ kind: string; [k]: unknown }`
// catch-all member, so a `kind` check alone leaves agentName/channelName typed
// as `unknown`; the typeof guard narrows them to string (and keeps the
// existing truthy/non-empty behavior).
if (target?.kind === 'agent' && typeof target.agentName === 'string' && target.agentName) {
return target.agentName
}
if (target?.kind === 'channel' && typeof target.channelName === 'string' && target.channelName) {
return normalizeChatChannelTarget(target.channelName)
}

if (normalizedParticipants.length > 0) {
const otherParticipants = normalizedParticipants.filter((participant) =>
Expand Down
2 changes: 1 addition & 1 deletion src/main/cloud-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const mock = vi.hoisted(() => {
onBrokerEvent: vi.fn(),
attachCloudSandbox: vi.fn(async () => undefined),
detachCloudSandbox: vi.fn(async () => undefined),
workspaceKeyForProject: vi.fn(async () => undefined)
workspaceKeyForProject: vi.fn(async (): Promise<string | undefined> => undefined)
},
fetch: vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const normalizedUrl = String(url)
Expand Down
2 changes: 1 addition & 1 deletion src/main/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const gitEnvKeys = [
const originalGitEnv = new Map<string, string | undefined>()

function gitEnv(): NodeJS.ProcessEnv {
const env = {
const env: NodeJS.ProcessEnv = {
...process.env,
GIT_CONFIG_NOSYSTEM: '1',
GIT_TERMINAL_PROMPT: '0',
Expand Down
2 changes: 1 addition & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ if (!gotSingleInstanceLock) {
})

if (process.platform === 'darwin' && appIcon) {
app.dock.setIcon(appIcon)
app.dock?.setIcon(appIcon)
}

registerAvatarCacheProtocol()
Expand Down
57 changes: 43 additions & 14 deletions src/main/integration-event-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,29 @@ import {
RelayFileClient,
RelayFileSync,
RelayfileSetup,
type ChangeEvent,
type ChangeEvent as SdkChangeEvent,
type Expansion,
type ExpansionLevel,
type FileReadResponse,
type FilesystemEvent,
type FilesystemEventType,
type RelayFileSyncOptions,
type Subscription
} from '@relayfile/sdk'

// Module-internal view of a relayfile change event as it actually flows through
// this bridge. The @relayfile SDK narrows `ChangeEvent['type']` to the single
// literal 'relayfile.changed' and types `expand` as a generic method, but in
// practice the live change stream delivers the underlying filesystem event type
// (e.g. 'writeback.succeeded', 'file.deleted') and this bridge additionally
// synthesizes 'relayfile.changed.summary' rollup events (see
// dispatchSummaryEvent). Widening `type` and relaxing `expand` here keeps every
// synthesized event and runtime discriminant check honest without per-site
// casts. SDK-delivered events (the narrow type) remain assignable to this.
type ChangeEvent = Omit<SdkChangeEvent, 'type' | 'expand'> & {
type: SdkChangeEvent['type'] | FilesystemEventType | 'relayfile.changed.summary'
expand: (level?: ExpansionLevel) => Promise<Expansion>
}
import type { ConnectedIntegration } from './integrations'
// @ts-expect-error Node's strip-types test runner requires the explicit .ts extension.
import { isSlackWritebackCommandRoot, slackWritebackCommandMountPathFor } from './slack-writeback-command-roots.ts'
Expand Down Expand Up @@ -409,6 +426,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}

// View any value as a string-keyed record for dynamic field access. Non-record
// inputs collapse to an empty record so callers can read optional keys off
// ChangeEvent/resource/summary without tripping over the SDK's narrow types.
function asRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {}
}

function stringList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
Expand Down Expand Up @@ -1398,9 +1422,9 @@ function injectionDeduplicationKey(projectId: string, event: ChangeEvent, matche
}

function eventRecordValue(event: ChangeEvent, key: string): unknown {
const resource = isRecord(event.resource) ? event.resource : {}
const summary = isRecord(event.summary) ? event.summary : {}
return (event as Record<string, unknown>)[key] ?? resource[key] ?? summary[key]
const resource = asRecord(event.resource)
const summary = asRecord(event.summary)
return asRecord(event)[key] ?? resource[key] ?? summary[key]
}

function eventOrigin(event: ChangeEvent): string | null {
Expand Down Expand Up @@ -1733,8 +1757,8 @@ function eventSummaryValue(value: unknown): string | undefined {
}

function integrationEventMetadata(event: ChangeEvent): Record<string, unknown> {
const summary = isRecord(event.summary) ? event.summary : {}
const resource = isRecord(event.resource) ? event.resource : {}
const summary = asRecord(event.summary)
const resource = asRecord(event.resource)
const actor = isRecord(summary.actor)
? eventSummaryValue(summary.actor.displayName) || eventSummaryValue(summary.actor.id)
: undefined
Expand Down Expand Up @@ -2080,7 +2104,7 @@ function formatSlackIntegrationEventMessage(
contextPreview?: EventContextPreview,
resolvedPath?: string
): string | null {
const resource = isRecord(event.resource) ? event.resource : {}
const resource = asRecord(event.resource)
const provider = eventSummaryValue(resource.provider) || eventProvider(event)
const relayfilePath = eventSummaryValue(resource.path)
if (provider !== 'slack' || !relayfilePath || !slackEventContextPath(relayfilePath)) return null
Expand Down Expand Up @@ -2119,8 +2143,8 @@ function formatIntegrationEventMessage(
const slackMessage = formatSlackIntegrationEventMessage(event, contextPreview, resolvedPath)
if (slackMessage) return slackMessage

const summary = isRecord(event.summary) ? event.summary : {}
const resource = isRecord(event.resource) ? event.resource : {}
const summary = asRecord(event.summary)
const resource = asRecord(event.resource)
const provider = eventSummaryValue(resource.provider) || 'integration'
const relayfilePath = eventSummaryValue(resource.path)
const displayPath = resolvedPath || relayfilePath
Expand Down Expand Up @@ -2714,7 +2738,7 @@ export class IntegrationEventBridge {

try {
const expanded = await event.expand('full')
const expandedRecord = isRecord(expanded) ? expanded : {}
const expandedRecord = asRecord(expanded)
return eventContextPreviewFromData(
typeof expandedRecord.path === 'string' ? expandedRecord.path : path,
expandedRecord.data
Expand Down Expand Up @@ -3390,12 +3414,17 @@ export class IntegrationEventBridge {

private async getWorkspaceHandle(): Promise<RelayfileWorkspaceHandle> {
if (this.deps.getWorkspaceHandle) return this.deps.getWorkspaceHandle()
// @ts-expect-error Node's strip-types test runner requires the explicit .ts extension.
const { accountWorkspaceReadyRetryOptions, getAccountWorkspaceId, refreshCloudAuth, resolveCloudAuth } = await import('./auth.ts')
let auth = await resolveCloudAuth()
if (!auth) {
const initialAuth = await resolveCloudAuth()
if (!initialAuth) {
accountIntegrationEventHandle = null
throw new Error('cloud-auth-required')
}
// Non-null from here on: reassigned only to a refreshed CloudAuth below.
// Initializing from the narrowed `initialAuth` keeps the type non-null
// inside the joinWorkspace closure (a plain `let` would widen back to null).
let auth = initialAuth

const accountWorkspaceId = await getAccountWorkspaceId(accountWorkspaceReadyRetryOptions())
if (
Expand All @@ -3408,9 +3437,9 @@ export class IntegrationEventBridge {
}

const joinWorkspace = async () => {
const tokenProvider = async (): Promise<string | undefined> => {
const tokenProvider = async (): Promise<string> => {
const fresh = await resolveCloudAuth()
return fresh?.accessToken ?? auth?.accessToken
return fresh?.accessToken ?? auth.accessToken
}
const setup = new RelayfileSetup({
cloudApiUrl: auth.apiUrl,
Expand Down
2 changes: 1 addition & 1 deletion src/main/integration-mounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const mock = vi.hoisted(() => {
startMount,
mkdir: vi.fn(async () => undefined),
chmod: vi.fn(async () => undefined),
readFile: vi.fn(async () => '{}'),
readFile: vi.fn(async (_path: string) => '{}'),
rm: vi.fn(async () => undefined),
RelayfileSetup,
get currentAuth() {
Expand Down
10 changes: 5 additions & 5 deletions src/main/integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const mock = vi.hoisted(() => {
const workspaceHandle = {
workspaceId: 'account-workspace-id',
client: vi.fn(() => relayClient),
requestJson: vi.fn(async (_request: { path: string }) => {
requestJson: vi.fn(async (_request: { path: string }): Promise<unknown> => {
throw new Error('unexpected workspace request')
}),
refreshToken: vi.fn(async () => undefined)
Expand Down Expand Up @@ -177,8 +177,8 @@ const mock = vi.hoisted(() => {
saveStore: vi.fn(() => undefined),
integrationMountManager: {
ensureMounted: vi.fn(() => mountReconcilePromise),
currentWorkspaceId: vi.fn(() => null),
localPathsFor: vi.fn(() => []),
currentWorkspaceId: vi.fn((): string | null => null),
localPathsFor: vi.fn((): string[] => []),
setHealthObserver: vi.fn(),
stop: vi.fn(async () => undefined)
},
Expand All @@ -195,8 +195,8 @@ const mock = vi.hoisted(() => {
relayWorkspaceManager,
workspaceHandle,
brokerManager: {
listAgents: vi.fn(async () => []),
sendMessage: vi.fn(async () => undefined),
listAgents: vi.fn(async (): Promise<Array<{ name: string; projectId: string }>> => []),
sendMessage: vi.fn(async (_projectId: string | undefined, _input: unknown) => undefined),
sendMessageAndWaitForDelivery: vi.fn(async () => undefined)
},
ensureProjectIntegrationsLink: vi.fn(async () => undefined),
Expand Down
6 changes: 6 additions & 0 deletions src/main/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,12 @@ export class IntegrationsManager {
this.setAuthRecoveryState(alert.reason, undefined, alert.message)
return
}
// Only auth-stall alerts carry pendingWriteback/message and map to a
// mount-auth-stall event. reconcile-stalled alerts have neither field, so
// emitting one here previously produced a malformed event with undefined
// pendingWriteback/message; ignore them rather than mislabel a reconcile
// stall as an auth stall.
if (alert.type !== 'auth-stall') return
this.emit({
type: 'mount-auth-stall',
remotePath: alert.remotePath,
Expand Down
9 changes: 6 additions & 3 deletions src/main/relayfile-mount-launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ describe('createPearMountLauncher', () => {
env: {
RELAYFILE_TOKEN: 'relay_pa_test-token',
RELAYFILE_LOCAL_DIR: localDir
}
},
readyTimeoutMs: 5_000
})

const credsPath = join(localDir, '.relay', 'creds.json')
Expand All @@ -78,12 +79,14 @@ describe('createPearMountLauncher', () => {
await launcher.start({
env: {
RELAYFILE_LOCAL_DIR: join(tempDir, 'missing-token')
}
},
readyTimeoutMs: 5_000
})
await launcher.start({
env: {
RELAYFILE_TOKEN: 'relay_pa_test-token'
}
},
readyTimeoutMs: 5_000
})

expect(mock.start).toHaveBeenCalledTimes(2)
Expand Down
55 changes: 52 additions & 3 deletions src/main/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,27 @@ import {
const ProjectSchema = makeProjectSchema(ProjectRootSchema)
const StoreSchema = StoreDataSchema(ProjectSchema)

export type CloudAgentWorkspaceMode = 'git-overlay' | 'git' | 'relayfile'

export type ProjectRoot = z.infer<typeof ProjectRootSchema>
export type ProjectIntegration = z.infer<typeof ProjectIntegrationSchema>
export type Project = z.infer<typeof ProjectSchema>
type StoreData = z.infer<typeof StoreSchema>

// The persisted project schema parses with `.passthrough()`, so wave-additive
// fields (cloud-agent / proactive-agent / workspace mode) survive a load/save
// round-trip on disk even though the base zod schema does not name them. Widen
// the static type to match what is actually persisted and read back, so the
// main-process consumers (cloud-agent.ts, proactive-agent.ts) see real types
// instead of property-not-found errors. No runtime behavior changes.
export type Project = z.infer<typeof ProjectSchema> & {
cloudAgent?: ProjectCloudAgent
cloudAgentWorkspaceMode?: CloudAgentWorkspaceMode
proactiveAgents?: ProactiveAgentBinding[]
}

type StoreData = Omit<z.infer<typeof StoreSchema>, 'projects'> & {
projects: Project[]
relayWorkspace?: RelayWorkspaceRecord
}

const defaultData: StoreData = { projects: [], activeProjectId: null }

Expand Down Expand Up @@ -66,6 +83,35 @@ function defaultRootName(path: string): string {
return basename(path) || path
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

// Normalize an untrusted relay-workspace record read from disk or supplied by a
// caller into a clean RelayWorkspaceRecord, or undefined when it lacks a usable
// id. Restores behavior lost in the wave-scaffolding merge (the call sites in
// setRelayWorkspace/setRelayWorkspaceRecord survived without the definition).
function normalizeRelayWorkspace(value: unknown): RelayWorkspaceRecord | undefined {
if (!isRecord(value)) return undefined

const id = typeof value.id === 'string' ? value.id.trim() : ''
if (!id) return undefined

const createdAt = typeof value.createdAt === 'string' ? value.createdAt.trim() : ''
const createdAtTime = createdAt ? Date.parse(createdAt) : Number.NaN
const apiUrl = typeof value.apiUrl === 'string' ? value.apiUrl.trim().replace(/\/+$/, '') : ''
const authKey = typeof value.authKey === 'string' ? value.authKey.trim() : ''

return {
id,
createdAt: Number.isNaN(createdAtTime)
? new Date(0).toISOString()
: new Date(createdAtTime).toISOString(),
...(apiUrl ? { apiUrl } : {}),
...(authKey ? { authKey } : {})
}
}

function loadStoreFromDisk(): StoreData {
try {
const raw = readFileSync(getStorePath(), 'utf-8')
Expand Down Expand Up @@ -123,9 +169,12 @@ export function setRelayWorkspaceRecord(record: RelayWorkspaceRecord | null): vo

export function addProject(name: string, rootPath: string): Project {
const data = loadStore()
const id = crypto.randomUUID()
const project: Project = {
id: crypto.randomUUID(),
id,
name,
// Mirrors the schema default (relayWorkspaceId falls back to the project id).
relayWorkspaceId: id,
rootPath,
roots: [{ id: crypto.randomUUID(), name: defaultRootName(rootPath), path: rootPath }],
channels: ['general'],
Expand Down
Loading