Skip to content
Open
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
3 changes: 2 additions & 1 deletion apps/daemon/src/collab/resource-hub-publish-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type ResourceHubClient,
type ResourceHubPrincipal,
} from '../integrations/resource-hub.js';
import { projectResourceIdFor } from '../integrations/vela-team-projects.js';
import { materializeRef, packTree, pushTree } from '../resource-drive.js';
import type { ResourcePublishAdapter } from './publish-scheduler.js';

Expand Down Expand Up @@ -56,7 +57,7 @@ export function createResourceHubPublishAdapter(
): ResourcePublishAdapter {
const { client, getPrincipal, resolveProjectDir } = options;
const resolvePullDir = options.resolvePullDir ?? resolveProjectDir;
const resourceIdFor = options.resourceIdFor ?? ((projectId: string) => `project-${projectId}`);
const resourceIdFor = options.resourceIdFor ?? projectResourceIdFor;
const kind = options.kind ?? PROJECT_KIND;

// The resource must exist before a version is published. Get-or-create keeps
Expand Down
36 changes: 36 additions & 0 deletions apps/daemon/src/collab/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import {
contextToResourceHubPrincipal,
createResourceHubPublishAdapterFromEnv,
} from './resource-hub-publish-adapter.js';
import {
projectResourceIdFor,
type VelaTeamProjectCatalogClient,
} from '../integrations/vela-team-projects.js';
import {
contextHasTeamIdentity,
createVelaCliResourceAdapter,
Expand Down Expand Up @@ -74,6 +78,12 @@ export interface CreateCollabRuntimeOptions {
workspaceContext?: WorkspaceContextProvider;
/** Team-resource state provider. Defaults to a dev provider until wired to the hub. */
teamResources?: TeamResourceStateProvider;
/**
* Vela-owned team-project catalog. The resource hub stores bytes/versions; the
* catalog is the member-discovery index for projects shared from another
* daemon. Missing config degrades through the client as a no-op.
*/
teamProjectCatalog?: VelaTeamProjectCatalogClient;
/** Fired after a project is published so the caller can notify online members. */
onPublished?: (result: { projectId: string; version: number; reason: string }) => void;
/** Fired when a project's presence set changes (join/leave). */
Expand Down Expand Up @@ -121,6 +131,29 @@ export function createCollabRuntime(options: CreateCollabRuntimeOptions = {}): C
// projectId → the member who shared it (the single writer). Members compare
// this to their own id to know whether they view the project read-only.
const owners = new Map<string, string>();
async function markTeamProject(
projectId: string,
syncState: 'pending_upload' | 'synced' | 'failed',
) {
const principal = contextToResourceHubPrincipal(await workspaceContext.current({}));
if (!principal) return;
await options.teamProjectCatalog?.upsert(
{
projectId,
resourceId: projectResourceIdFor(projectId),
syncState,
},
principal,
);
}
function markTeamProjectSoon(
projectId: string,
syncState: 'pending_upload' | 'synced' | 'failed',
) {
void markTeamProject(projectId, syncState).catch((error) => {
options.onError?.({ projectId, error });
});
}
// Always track the published head + sync state so members can poll them; also
// forward to any caller-supplied callback. (exactOptionalPropertyTypes forbids
// assigning an explicit `undefined` to an optional property, hence we always
Expand All @@ -130,12 +163,14 @@ export function createCollabRuntime(options: CreateCollabRuntimeOptions = {}): C
onPublished: (result) => {
published.set(result.projectId, result.version);
syncStates.set(result.projectId, 'synced');
markTeamProjectSoon(result.projectId, 'synced');
options.onPublished?.(result);
},
onError: (result) => {
// A failed publish leaves the prior head standing; surface it as a
// recoverable sync state rather than wedging the project.
syncStates.set(result.projectId, 'sync_failed');
markTeamProjectSoon(result.projectId, 'failed');
options.onError?.(result);
},
};
Expand All @@ -159,6 +194,7 @@ export function createCollabRuntime(options: CreateCollabRuntimeOptions = {}): C
// Pending until the publish confirms (onPublished → 'synced' / onError →
// 'sync_failed'). Flushing at a run boundary publishes the stable state.
syncStates.set(projectId, 'pending_upload');
markTeamProjectSoon(projectId, 'pending_upload');
scheduler.notifyChanged(projectId, 'share');
scheduler.runBoundary(projectId);
},
Expand Down
45 changes: 23 additions & 22 deletions apps/daemon/src/integrations/resource-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,28 @@ export function readResourceHubPrincipal(
};
}

export function buildResourceHubAuthHeaders(
principal: ResourceHubPrincipal,
config: ResourceHubConfig,
): Record<string, string> {
// Seam: the final scheme (services/api-issued scoped token vs internal-token
// forwarding) is undecided. For now forward the principal under the header
// contract the hub's auth seam consumes, gated by the internal token.
const headers: Record<string, string> = {
'content-type': 'application/json',
'x-workspace-member-id': principal.memberId,
'x-workspace-team-id': principal.teamId,
'x-workspace-role': principal.role,
};
if (principal.lifecycleState) {
headers['x-workspace-lifecycle-state'] = principal.lifecycleState;
}
if (config.internalToken) {
headers['x-internal-token'] = config.internalToken;
}
return headers;
}

interface ResourceHubClientOptions {
config?: ResourceHubConfig;
fetch?: FetchLike;
Expand All @@ -169,27 +191,6 @@ export function createResourceHubClient(options: ResourceHubClientOptions = {})
const fetchImpl = options.fetch ?? fetch;
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;

function buildAuthHeaders(
principal: ResourceHubPrincipal,
): Record<string, string> {
// Seam: the final scheme (services/api-issued scoped token vs internal-token
// forwarding) is undecided. For now forward the principal under the header
// contract the hub's auth seam consumes, gated by the internal token.
const headers: Record<string, string> = {
'content-type': 'application/json',
'x-workspace-member-id': principal.memberId,
'x-workspace-team-id': principal.teamId,
'x-workspace-role': principal.role,
};
if (principal.lifecycleState) {
headers['x-workspace-lifecycle-state'] = principal.lifecycleState;
}
if (config.internalToken) {
headers['x-internal-token'] = config.internalToken;
}
return headers;
}

async function request<T>(
principal: ResourceHubPrincipal,
method: string,
Expand All @@ -201,7 +202,7 @@ export function createResourceHubClient(options: ResourceHubClientOptions = {})
try {
const response = await fetchImpl(new URL(path, config.baseUrl), {
method,
headers: buildAuthHeaders(principal),
headers: buildResourceHubAuthHeaders(principal, config),
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: controller.signal,
});
Expand Down
154 changes: 154 additions & 0 deletions apps/daemon/src/integrations/vela-team-projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { ProjectSyncState } from '@open-design/contracts';
import {
buildResourceHubAuthHeaders,
hasExplicitResourceHubConfig,
readResourceHubConfig,
readResourceHubPrincipal,
ResourceHubError,
type ResourceHubConfig,
type ResourceHubPrincipal,
} from './resource-hub.js';

const DEFAULT_FETCH_TIMEOUT_MS = 10_000;

type FetchLike = typeof fetch;

export type VelaTeamProjectSyncState =
| 'pending_upload'
| 'syncing'
| 'synced'
| 'failed';

export interface VelaTeamProjectRecord {
id: string;
workspaceId: string;
projectId: string;
resourceId: string;
ownerMemberId: string;
displayName: string | null;
syncState: VelaTeamProjectSyncState;
lastSyncedVersionId: string | null;
createdAt: string;
updatedAt: string;
access: {
canView: boolean;
canComment: boolean;
canEdit: boolean;
frozen: boolean;
};
}

export interface UpsertVelaTeamProjectInput {
projectId: string;
resourceId: string;
displayName?: string | null;
syncState?: VelaTeamProjectSyncState;
lastSyncedVersionId?: string | null;
}

export interface VelaTeamProjectCatalogClient {
list(principal?: ResourceHubPrincipal | null): Promise<VelaTeamProjectRecord[]>;
upsert(
input: UpsertVelaTeamProjectInput,
principal?: ResourceHubPrincipal | null,
): Promise<VelaTeamProjectRecord | null>;
}

interface VelaTeamProjectCatalogClientOptions {
config?: ResourceHubConfig;
env?: NodeJS.ProcessEnv;
fetch?: FetchLike;
timeoutMs?: number;
}

export function projectResourceIdFor(projectId: string): string {
return `project-${projectId}`;
}

export function projectSyncStateToVela(state: ProjectSyncState): VelaTeamProjectSyncState {
if (state === 'synced') return 'synced';
if (state === 'sync_failed') return 'failed';
if (state === 'pending_upload') return 'pending_upload';
return 'pending_upload';
}

export function velaProjectSyncStateToProject(state: VelaTeamProjectSyncState): ProjectSyncState {
if (state === 'synced') return 'synced';
if (state === 'failed') return 'sync_failed';
return 'pending_upload';
}

export function createVelaTeamProjectCatalogClient(
options: VelaTeamProjectCatalogClientOptions = {},
): VelaTeamProjectCatalogClient {
const env = options.env ?? process.env;
const explicitConfig = hasExplicitResourceHubConfig(env);
const config = options.config ?? readResourceHubConfig(env);
const fetchImpl = options.fetch ?? fetch;
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;

async function request<T>(
principal: ResourceHubPrincipal,
method: string,
path: string,
body?: unknown,
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(new URL(path, config.baseUrl), {
method,
headers: buildResourceHubAuthHeaders(principal, config),
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: controller.signal,
});
const text = await response.text();
const payload = text ? JSON.parse(text) : {};
if (!response.ok) {
const code =
typeof payload?.error === 'string' ? payload.error : 'unknown';
throw new ResourceHubError(response.status, code, payload?.message);
}
return payload as T;
} finally {
clearTimeout(timeout);
}
}

function resolvePrincipal(principal?: ResourceHubPrincipal | null): ResourceHubPrincipal | null {
return principal ?? readResourceHubPrincipal(env);
}

return {
async list(principal) {
if (!explicitConfig) return [];
const resolved = resolvePrincipal(principal);
if (!resolved) return [];
const body = await request<{ projects: VelaTeamProjectRecord[] }>(
resolved,
'GET',
'/api/v1/team-projects',
);
return body.projects ?? [];
},

async upsert(input, principal) {
if (!explicitConfig) return null;
const resolved = resolvePrincipal(principal);
if (!resolved) return null;
return request<VelaTeamProjectRecord>(
resolved,
'PUT',
`/api/v1/team-projects/${encodeURIComponent(input.projectId)}`,
{
resourceId: input.resourceId,
displayName: input.displayName,
syncState: input.syncState,
lastSyncedVersionId: input.lastSyncedVersionId,
},
);
},
};
}

export const velaTeamProjectCatalogClient = createVelaTeamProjectCatalogClient();
Loading
Loading