Skip to content

Commit a81e573

Browse files
authored
Merge pull request #5281 from nexu-io/feat/collab-daemon-resources
Team collaboration (1/3): backend — contracts, presence, comments, sync, resource sharing
2 parents 335dd9f + 5caca3c commit a81e573

42 files changed

Lines changed: 4083 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/daemon/src/cli.ts

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ const CONFIG_STRING_FLAGS = new Set(['daemon-url', 'value', 'value-json']);
195195
const CONFIG_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
196196
const AMR_STRING_FLAGS = new Set(['daemon-url']);
197197
const AMR_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'refresh']);
198+
const COLLAB_STRING_FLAGS = new Set(['daemon-url', 'project', 'member', 'name', 'role', 'design-system']);
199+
const COLLAB_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
198200
const PROJECT_STRING_FLAGS = new Set([
199201
'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json',
200202
'pending-prompt', 'project', 'conversation', 'message', 'prompt',
@@ -311,6 +313,7 @@ const SUBCOMMAND_MAP = {
311313
media: runMedia,
312314
mcp: runMcp,
313315
amr: runAmr,
316+
collab: runCollab,
314317
research: runResearch,
315318
plugin: runPlugin,
316319
ui: runUi,
@@ -689,6 +692,242 @@ Options:
689692
}
690693
}
691694

695+
// ---------------------------------------------------------------------------
696+
// Subcommand: od collab … (team-edition collaboration)
697+
// ---------------------------------------------------------------------------
698+
699+
function printCollabHelp() {
700+
console.log(`Usage:
701+
od collab status <projectId> [--json]
702+
od collab presence <projectId> [--json]
703+
od collab heartbeat <projectId> --member <id> [--name <name>] [--role owner|admin|member] [--json]
704+
od collab leave <projectId> --member <id> [--json]
705+
od collab changed <projectId> [--json]
706+
od collab publish <projectId> [--json]
707+
od collab share <projectId> [--json]
708+
od collab pull <projectId> [--json]
709+
od collab share-resource <design-systems|plugins|skills> <id> [--json]
710+
od collab team-resources <design-systems|plugins|skills> [--json]
711+
od collab share-design-system <designSystemId> [--json]
712+
od collab team-design-systems [--json]
713+
714+
Team-edition collaboration: presence overlay + sync trigger. The
715+
client is authoritative about whether it is in a shared context, so it drives
716+
the trigger; the daemon coalesces author edits and flushes at a run boundary,
717+
advancing the published head version members poll to learn when to pull.
718+
\`share\` is the team-share intent: it requests the project be published so
719+
members can pull it, and reports the sync state (local_only / pending_upload /
720+
synced / sync_failed). \`share-resource <kind> <id>\` promotes a personal design
721+
system, plugin, or skill into the team scope through the resource hub, and
722+
\`team-resources <kind>\` lists the ones already shared (the \`*-design-system\`
723+
forms are kept as aliases).
724+
725+
Options:
726+
--project <id> Project id (alternative to the positional argument).
727+
--design-system <id> Design system id for share-design-system.
728+
--member <id> Member id for the presence heartbeat / leave.
729+
--name <name> Display name attached to a heartbeat.
730+
--role <role> owner | admin | member.
731+
--json Emit raw JSON.
732+
--daemon-url <url> Override daemon URL.
733+
734+
Examples:
735+
od collab presence p1 --json
736+
od collab heartbeat p1 --member m-42 --name "Ma Shu" --role member
737+
od collab publish p1
738+
od collab share-resource plugins my-plugin --json
739+
od collab team-resources skills --json
740+
od collab share-design-system user:palette-x --json
741+
od collab status p1 --json`);
742+
}
743+
744+
async function runCollab(args) {
745+
const sub = args[0];
746+
if (!sub || sub === 'help' || args.includes('--help') || args.includes('-h')) {
747+
printCollabHelp();
748+
process.exit(!sub ? 2 : 0);
749+
}
750+
const rest = args.slice(1);
751+
let flags;
752+
try {
753+
flags = parseFlags(rest, { string: COLLAB_STRING_FLAGS, boolean: COLLAB_BOOLEAN_FLAGS });
754+
} catch (err) {
755+
console.error(err.message);
756+
process.exit(2);
757+
}
758+
// Team resource sharing (design systems / plugins / skills) is workspace-scoped
759+
// — it takes a resource id, not a project id — so it runs before the project-id
760+
// requirement below. `share-resource <kind> <id>` / `team-resources <kind>` are
761+
// the generic forms; the design-system aliases are kept for compatibility.
762+
const RESOURCE_BASE_PATHS = new Set(['design-systems', 'plugins', 'skills']);
763+
if (
764+
sub === 'share-resource' ||
765+
sub === 'team-resources' ||
766+
sub === 'share-design-system' ||
767+
sub === 'team-design-systems'
768+
) {
769+
const base = await cliDaemonBaseUrl(flags);
770+
const emit = (payload, plain) =>
771+
flags.json ? process.stdout.write(JSON.stringify(payload, null, 2) + '\n') : plain();
772+
const wsRequest = async (method, path, body) => {
773+
let resp;
774+
try {
775+
resp = await fetch(`${base}${path}`, {
776+
method,
777+
...(body !== undefined
778+
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
779+
: {}),
780+
});
781+
} catch (err) {
782+
surfaceFetchError(err, base);
783+
process.exit(3);
784+
}
785+
if (!resp.ok) return structuredHttpFailure(resp);
786+
return resp.json();
787+
};
788+
789+
// Resolve the resource kind (URL base path), whether this lists or shares,
790+
// and the target id — from either the aliases or the generic <kind> <id>.
791+
const positionals = positionalArgs(rest, COLLAB_STRING_FLAGS);
792+
let basePath;
793+
let isList;
794+
let resourceId;
795+
if (sub === 'team-design-systems') {
796+
basePath = 'design-systems';
797+
isList = true;
798+
} else if (sub === 'share-design-system') {
799+
basePath = 'design-systems';
800+
isList = false;
801+
resourceId = flags['design-system'] || positionals[0];
802+
} else {
803+
basePath = positionals[0];
804+
if (!RESOURCE_BASE_PATHS.has(basePath)) {
805+
console.error('kind must be one of: design-systems | plugins | skills');
806+
process.exit(2);
807+
}
808+
isList = sub === 'team-resources';
809+
resourceId = positionals[1];
810+
}
811+
812+
if (isList) {
813+
const body = await wsRequest('GET', `/api/workspace/${basePath}/team`);
814+
return emit(body, () => {
815+
const ids = Array.isArray(body?.ids) ? body.ids : [];
816+
if (ids.length === 0) return console.log(`no shared ${basePath}`);
817+
for (const id of ids) console.log(id);
818+
});
819+
}
820+
if (!resourceId) {
821+
console.error('missing <id>');
822+
process.exit(2);
823+
}
824+
const body = await wsRequest(
825+
'POST',
826+
`/api/workspace/${basePath}/${encodeURIComponent(resourceId)}/share`,
827+
);
828+
return emit(body, () =>
829+
console.log(`shared=${body?.shared ?? false}\tversion=${body?.version ?? '-'}`),
830+
);
831+
}
832+
833+
const projectId =
834+
flags.project || positionalArgs(rest, COLLAB_STRING_FLAGS)[0] || process.env.OD_PROJECT_ID;
835+
if (!projectId) {
836+
console.error('missing <projectId> (positional, --project, or OD_PROJECT_ID)');
837+
process.exit(2);
838+
}
839+
const base = await cliDaemonBaseUrl(flags);
840+
const encoded = encodeURIComponent(projectId);
841+
842+
const request = async (method, path, body) => {
843+
let resp;
844+
try {
845+
resp = await fetch(`${base}/api/projects/${encoded}${path}`, {
846+
method,
847+
...(body !== undefined
848+
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
849+
: {}),
850+
});
851+
} catch (err) {
852+
surfaceFetchError(err, base);
853+
process.exit(3);
854+
}
855+
if (!resp.ok) return structuredHttpFailure(resp);
856+
return resp.json();
857+
};
858+
859+
const emit = (payload, plain) => {
860+
if (flags.json) return process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
861+
return plain();
862+
};
863+
864+
switch (sub) {
865+
case 'status': {
866+
const body = await request('GET', '/collab/status');
867+
return emit(body, () => {
868+
console.log(`publishedVersion\t${body?.publishedVersion ?? '-'}`);
869+
console.log(`syncState\t${body?.syncState ?? '-'}`);
870+
});
871+
}
872+
case 'share': {
873+
// Team-share intent: request the project be published so members can pull.
874+
const body = await request('POST', '/collab/sync-intent', {
875+
event: 'project_team_share_requested',
876+
projectId,
877+
});
878+
return emit(body, () => console.log(`ok\tsyncState=${body?.syncState ?? '-'}`));
879+
}
880+
case 'pull': {
881+
// Member pull: fetch the published head (E extracts the bytes behind C's trigger).
882+
const body = await request('POST', '/collab/pull');
883+
return emit(body, () => console.log(`pulled\tversion=${body?.version ?? '-'}`));
884+
}
885+
case 'presence': {
886+
const body = await request('GET', '/presence');
887+
return emit(body, () => {
888+
const present = Array.isArray(body?.present) ? body.present : [];
889+
if (present.length === 0) return console.log('no members present');
890+
for (const m of present) console.log(`${m.memberId}\t${m.name ?? '-'}\t${m.role ?? '-'}`);
891+
});
892+
}
893+
case 'heartbeat': {
894+
if (!flags.member) {
895+
console.error('missing --member <id>');
896+
process.exit(2);
897+
}
898+
const memberBody = {
899+
memberId: flags.member,
900+
...(flags.name ? { name: flags.name } : {}),
901+
...(flags.role ? { role: flags.role } : {}),
902+
};
903+
const body = await request('POST', '/presence/heartbeat', memberBody);
904+
return emit(body, () => {
905+
const present = Array.isArray(body?.present) ? body.present : [];
906+
console.log(`ok\t${present.length} present`);
907+
});
908+
}
909+
case 'leave': {
910+
if (!flags.member) {
911+
console.error('missing --member <id>');
912+
process.exit(2);
913+
}
914+
const body = await request('POST', '/presence/leave', { memberId: flags.member });
915+
return emit(body, () => console.log('left'));
916+
}
917+
case 'changed': {
918+
const body = await request('POST', '/collab/changed');
919+
return emit(body, () => console.log('change queued'));
920+
}
921+
case 'publish': {
922+
const body = await request('POST', '/collab/publish');
923+
return emit(body, () => console.log('publish requested'));
924+
}
925+
default:
926+
console.error(`unknown subcommand: od collab ${sub}`);
927+
process.exit(2);
928+
}
929+
}
930+
692931
// ---------------------------------------------------------------------------
693932
// Subcommand: od research …
694933
// ---------------------------------------------------------------------------
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { WorkspaceCollabContext } from '@open-design/contracts';
2+
import { readVelaControlApiContext } from '../integrations/vela.js';
3+
import { mapVelaWorkspaceContext } from './vela-workspace-context.js';
4+
5+
// Daemon half of the desktop invite hand-off ("桌面唤起和本地恢复", C's lane in
6+
// the B-C invite contract). The desktop app receives an
7+
// `opendesign://workspace/invite/continue?...&nonce=...` deeplink, parses it, and
8+
// forwards the nonce here. The daemon proves identity with the SAME signed-in vela
9+
// session (never a client-supplied one) and consumes the one-time continuation on
10+
// B, which finalizes the membership and returns the current workspace context so
11+
// the client can switch into the team workspace. Any failure degrades to a typed
12+
// outcome the route maps onto HTTP — it never throws into the caller.
13+
14+
const DEFAULT_TIMEOUT_MS = 8_000;
15+
16+
function consumePath(nonce: string): string {
17+
return `/api/v1/workspace-invites/continuations/${encodeURIComponent(nonce)}/consume`;
18+
}
19+
20+
export type InviteContinueOutcome =
21+
| { ok: true; context: WorkspaceCollabContext | null; workspaceMemberId: string }
22+
| { ok: false; status: number; error: string };
23+
24+
export interface ConsumeInviteContinuationOptions {
25+
/** Injectable for tests. */
26+
fetch?: typeof fetch;
27+
/** Injectable for tests; defaults to reading ~/.amr / env. */
28+
readSession?: typeof readVelaControlApiContext;
29+
timeoutMs?: number;
30+
}
31+
32+
/**
33+
* Consume an invite continuation nonce against B using the local vela session,
34+
* returning the mapped workspace context on success. Errors are typed, not
35+
* thrown: `no_session` (401) when the client is not signed in, `continuation_<n>`
36+
* for B's 401/403/409/410 (subject mismatch / already consumed / expired), and
37+
* `continuation_unreachable` (502) on a transport failure.
38+
*/
39+
export async function consumeInviteContinuation(
40+
nonce: string,
41+
options: ConsumeInviteContinuationOptions = {},
42+
): Promise<InviteContinueOutcome> {
43+
const fetchImpl = options.fetch ?? fetch;
44+
const readSession = options.readSession ?? readVelaControlApiContext;
45+
const trimmed = nonce.trim();
46+
if (!trimmed) return { ok: false, status: 400, error: 'missing_nonce' };
47+
48+
const session = readSession();
49+
if (!session || !session.controlKey || !session.apiUrl) {
50+
return { ok: false, status: 401, error: 'no_session' };
51+
}
52+
53+
const controller = new AbortController();
54+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
55+
try {
56+
const response = await fetchImpl(new URL(consumePath(trimmed), session.apiUrl), {
57+
method: 'POST',
58+
headers: { authorization: `Bearer ${session.controlKey}` },
59+
signal: controller.signal,
60+
});
61+
if (!response.ok) {
62+
return { ok: false, status: response.status, error: `continuation_${response.status}` };
63+
}
64+
const body = (await response.json()) as {
65+
workspaceMemberId?: unknown;
66+
currentWorkspaceContext?: unknown;
67+
};
68+
return {
69+
ok: true,
70+
context: mapVelaWorkspaceContext(body.currentWorkspaceContext),
71+
workspaceMemberId: typeof body.workspaceMemberId === 'string' ? body.workspaceMemberId : '',
72+
};
73+
} catch {
74+
return { ok: false, status: 502, error: 'continuation_unreachable' };
75+
} finally {
76+
clearTimeout(timeout);
77+
}
78+
}

0 commit comments

Comments
 (0)