@netnodeag/kraftwerk 0.76.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -27
- package/dist/cli/cloud.d.ts +2 -0
- package/dist/cli/cloud.js +262 -0
- package/dist/cli/daemon.js +9 -0
- package/dist/cli/doctor.js +16 -8
- package/dist/cli/init.js +2 -2
- package/dist/cli/invite.d.ts +2 -0
- package/dist/cli/invite.js +48 -0
- package/dist/cli/kraftwerk.js +7 -0
- package/dist/client/relay.d.ts +40 -0
- package/dist/client/relay.js +35 -0
- package/dist/client/routes.d.ts +5 -0
- package/dist/client/routes.js +5 -0
- package/dist/config.d.ts +15 -28
- package/dist/config.js +5 -19
- package/dist/core/chat/channel-turns.d.ts +2 -2
- package/dist/core/chat/channel-turns.js +2 -2
- package/dist/core/chat/sessions.d.ts +20 -3
- package/dist/core/chat/sessions.js +22 -7
- package/dist/core/chat/types.d.ts +21 -0
- package/dist/core/cloud.d.ts +152 -0
- package/dist/core/cloud.js +98 -0
- package/dist/core/routines.d.ts +22 -2
- package/dist/core/routines.js +26 -0
- package/dist/server/api/chats.js +45 -8
- package/dist/server/api/cloud.d.ts +86 -0
- package/dist/server/api/cloud.js +51 -0
- package/dist/server/api/devices.d.ts +15 -9
- package/dist/server/api/devices.js +3 -2
- package/dist/server/api/index.d.ts +108 -11
- package/dist/server/api/index.js +2 -0
- package/dist/server/api/router.d.ts +3 -2
- package/dist/server/api/router.js +12 -0
- package/dist/server/api/workspace.d.ts +16 -1
- package/dist/server/api/workspace.js +2 -1
- package/dist/server/cloud.d.ts +104 -33
- package/dist/server/cloud.js +279 -185
- package/dist/server/hub-context.d.ts +1 -1
- package/dist/server/hub-context.js +3 -3
- package/dist/server/members.d.ts +87 -0
- package/dist/server/members.js +117 -0
- package/dist/server/relay.d.ts +21 -1
- package/dist/server/relay.js +74 -3
- package/dist/server/server.d.ts +3 -2
- package/dist/server/server.js +13 -6
- package/dist/server/trust.d.ts +16 -0
- package/dist/server/trust.js +12 -1
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +4 -4
- package/web/dist/assets/{dist-T1sNLC62.js → dist-B-yVL5lk.js} +1 -1
- package/web/dist/assets/{dist-DFXmaDc5.js → dist-B9EMB1Ie.js} +1 -1
- package/web/dist/assets/{dist-BQpWYkxm.js → dist-BTc878j7.js} +1 -1
- package/web/dist/assets/{dist-fgB7OEJy.js → dist-C90kUzps.js} +1 -1
- package/web/dist/assets/{dist-l7foWlFx.js → dist-CpLdAqFQ.js} +1 -1
- package/web/dist/assets/{dist-flnLXEzU.js → dist-Crnb8VVO.js} +1 -1
- package/web/dist/assets/{dist-vuciRCSz.js → dist-D0rjnN8S.js} +1 -1
- package/web/dist/assets/{dist-BTVP-6On.js → dist-D4SDXoGw.js} +1 -1
- package/web/dist/assets/{dist-BwIca_Uj.js → dist-DHRhdODt.js} +1 -1
- package/web/dist/assets/{dist-BYkcY-Vl.js → dist-DQbifCX0.js} +1 -1
- package/web/dist/assets/dist-lSJ-O7kT.js +1 -0
- package/web/dist/assets/{editor-C6Ok8NPo.js → editor-DHAdW1g_.js} +3 -3
- package/web/dist/assets/{index-z4tpCCzk.js → index-D7JVAjll.js} +5 -5
- package/web/dist/index.html +1 -1
- package/web/dist-remote/sw.js +1 -1
- package/web/dist/assets/dist-BURf1l-A.js +0 -1
package/dist/client/routes.d.ts
CHANGED
|
@@ -45,6 +45,11 @@ export declare const ROUTES: {
|
|
|
45
45
|
readonly "chats.setVibeable": readonly ["POST", "/api/chats/:id/vibeable"];
|
|
46
46
|
readonly "chats.steer": readonly ["POST", "/api/chats/:id/steer"];
|
|
47
47
|
readonly "chats.stopTask": readonly ["POST", "/api/chats/:id/task-stop"];
|
|
48
|
+
readonly "cloud.invite": readonly ["POST", "/api/cloud/invite"];
|
|
49
|
+
readonly "cloud.set": readonly ["POST", "/api/cloud"];
|
|
50
|
+
readonly "cloud.status": readonly ["GET", "/api/cloud"];
|
|
51
|
+
readonly "cloud.sync": readonly ["POST", "/api/cloud/sync"];
|
|
52
|
+
readonly "cloud.unlink": readonly ["POST", "/api/cloud/unlink"];
|
|
48
53
|
readonly "devices.code": readonly ["POST", "/api/devices/code"];
|
|
49
54
|
readonly "devices.dropPush": readonly ["DELETE", "/api/devices/push"];
|
|
50
55
|
readonly "devices.list": readonly ["GET", "/api/devices"];
|
package/dist/client/routes.js
CHANGED
|
@@ -46,6 +46,11 @@ export const ROUTES = {
|
|
|
46
46
|
"chats.setVibeable": ["POST", "/api/chats/:id/vibeable"],
|
|
47
47
|
"chats.steer": ["POST", "/api/chats/:id/steer"],
|
|
48
48
|
"chats.stopTask": ["POST", "/api/chats/:id/task-stop"],
|
|
49
|
+
"cloud.invite": ["POST", "/api/cloud/invite"],
|
|
50
|
+
"cloud.set": ["POST", "/api/cloud"],
|
|
51
|
+
"cloud.status": ["GET", "/api/cloud"],
|
|
52
|
+
"cloud.sync": ["POST", "/api/cloud/sync"],
|
|
53
|
+
"cloud.unlink": ["POST", "/api/cloud/unlink"],
|
|
49
54
|
"devices.code": ["POST", "/api/devices/code"],
|
|
50
55
|
"devices.dropPush": ["DELETE", "/api/devices/push"],
|
|
51
56
|
"devices.list": ["GET", "/api/devices"],
|
package/dist/config.d.ts
CHANGED
|
@@ -34,11 +34,10 @@
|
|
|
34
34
|
* root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
|
|
35
35
|
* projects: # goal-scoped folders (brief, systems of record, links) that chats work in (absent = off, bare key = on)
|
|
36
36
|
* root: kraftwerk-data/projects # one folder per project, part of the workspace. Default: kraftwerk-data/projects
|
|
37
|
-
* cloud: # kraftwerk cloud
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* #
|
|
41
|
-
* # a server that should register under an account unattended puts KRAFTWERK_CLOUD_TOKEN into .env.
|
|
37
|
+
* cloud: # the machine's kraftwerk cloud record lists this workspace — `enabled: false` leaves it out
|
|
38
|
+
* enabled: false # a per-workspace veto; the machine's switch is `kraftwerk cloud on|off` (~/.kraftwerk/cloud.json)
|
|
39
|
+
* # No key here: the daemon registers the MACHINE (its relay id) and `kraftwerk login` claims it.
|
|
40
|
+
* # `url` and `interval` are deprecated: the cloud's origin is the machine's (cloud.json / KRAFTWERK_CLOUD_URL).
|
|
42
41
|
*/
|
|
43
42
|
/** Stable, versionless URL of the workflow JSON schema (editor validation). */
|
|
44
43
|
export declare const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
|
|
@@ -123,35 +122,23 @@ export declare const PROJECTS_DEFAULT_ROOT = "kraftwerk-data/projects";
|
|
|
123
122
|
/** Absolute projects root when the feature is on, undefined otherwise. */
|
|
124
123
|
export declare function projectsRootFor(project: ResolvedWorkspace): string | undefined;
|
|
125
124
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* the tests and CI set). No credential lives in kraftwerk.yml — the
|
|
134
|
-
* identity comes from the cloud, and an account token for unattended
|
|
135
|
-
* servers is KRAFTWERK_CLOUD_TOKEN in .env.
|
|
125
|
+
* kraftwerk.yml `cloud:` — since the cloud knows machines (core/cloud.ts,
|
|
126
|
+
* server/cloud.ts), this block only vetoes: `enabled: false` leaves the
|
|
127
|
+
* workspace out of the machine's reports. The machine's switch and the
|
|
128
|
+
* cloud's origin are ~/.kraftwerk/cloud.json (`kraftwerk cloud on|off`) and
|
|
129
|
+
* KRAFTWERK_CLOUD_URL. `url` is still honored for one release (with a
|
|
130
|
+
* warning) when the machine names none; `interval` is ignored — the cloud
|
|
131
|
+
* sets the cadence.
|
|
136
132
|
*/
|
|
137
133
|
export interface CloudConfig {
|
|
138
|
-
/** false
|
|
134
|
+
/** false leaves this workspace out of the machine's cloud record. Default: true. */
|
|
139
135
|
enabled?: boolean;
|
|
140
|
-
/**
|
|
136
|
+
/** @deprecated The machine's (cloud.json / KRAFTWERK_CLOUD_URL); used only when the machine names none. */
|
|
141
137
|
url?: string;
|
|
142
|
-
/**
|
|
138
|
+
/** @deprecated Ignored: the cloud sets the heartbeat interval. */
|
|
143
139
|
interval?: number;
|
|
144
140
|
}
|
|
145
141
|
export declare const CLOUD_DEFAULT_URL = "https://srv.kraftwerk-cloud.netnode.cloud";
|
|
146
|
-
export declare const CLOUD_DEFAULT_INTERVAL = 60;
|
|
147
|
-
export interface CloudSettings {
|
|
148
|
-
url: string;
|
|
149
|
-
interval: number;
|
|
150
|
-
/** KRAFTWERK_CLOUD_TOKEN from the environment (.env), when set. */
|
|
151
|
-
token?: string;
|
|
152
|
-
}
|
|
153
|
-
/** The effective cloud settings when the feature is on; undefined when kraftwerk.yml or the environment turned it off. */
|
|
154
|
-
export declare function cloudFor(project: ResolvedWorkspace): CloudSettings | undefined;
|
|
155
142
|
/** A directory as a .gitignore entry: relative, forward slashes, no trailing slash; undefined outside the root. */
|
|
156
143
|
export declare function ignoreEntryFor(projectRoot: string, dir: string): string | undefined;
|
|
157
144
|
/** True when .gitignore text already covers the entry (with or without a leading or trailing slash). */
|
|
@@ -206,7 +193,7 @@ export interface WorkspaceConfig {
|
|
|
206
193
|
vibeables?: VibeablesConfig;
|
|
207
194
|
/** Projects: goal-scoped folders the chats work in. Absent = off. */
|
|
208
195
|
projects?: ProjectsConfig;
|
|
209
|
-
/**
|
|
196
|
+
/** The machine's cloud record lists this workspace. Absent = listed; `enabled: false` leaves it out. */
|
|
210
197
|
cloud?: CloudConfig;
|
|
211
198
|
}
|
|
212
199
|
export interface ResolvedWorkspace {
|
package/dist/config.js
CHANGED
|
@@ -39,11 +39,10 @@ import { parse } from "yaml";
|
|
|
39
39
|
* root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
|
|
40
40
|
* projects: # goal-scoped folders (brief, systems of record, links) that chats work in (absent = off, bare key = on)
|
|
41
41
|
* root: kraftwerk-data/projects # one folder per project, part of the workspace. Default: kraftwerk-data/projects
|
|
42
|
-
* cloud: # kraftwerk cloud
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* #
|
|
46
|
-
* # a server that should register under an account unattended puts KRAFTWERK_CLOUD_TOKEN into .env.
|
|
42
|
+
* cloud: # the machine's kraftwerk cloud record lists this workspace — `enabled: false` leaves it out
|
|
43
|
+
* enabled: false # a per-workspace veto; the machine's switch is `kraftwerk cloud on|off` (~/.kraftwerk/cloud.json)
|
|
44
|
+
* # No key here: the daemon registers the MACHINE (its relay id) and `kraftwerk login` claims it.
|
|
45
|
+
* # `url` and `interval` are deprecated: the cloud's origin is the machine's (cloud.json / KRAFTWERK_CLOUD_URL).
|
|
47
46
|
*/
|
|
48
47
|
/** Stable, versionless URL of the workflow JSON schema (editor validation). */
|
|
49
48
|
export const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
|
|
@@ -98,19 +97,6 @@ export function projectsRootFor(project) {
|
|
|
98
97
|
return path.resolve(project.root, p.root ?? PROJECTS_DEFAULT_ROOT);
|
|
99
98
|
}
|
|
100
99
|
export const CLOUD_DEFAULT_URL = "https://srv.kraftwerk-cloud.netnode.cloud";
|
|
101
|
-
export const CLOUD_DEFAULT_INTERVAL = 60;
|
|
102
|
-
/** The effective cloud settings when the feature is on; undefined when kraftwerk.yml or the environment turned it off. */
|
|
103
|
-
export function cloudFor(project) {
|
|
104
|
-
const c = project.config.cloud ?? {};
|
|
105
|
-
const envUrl = process.env.KRAFTWERK_CLOUD_URL?.trim();
|
|
106
|
-
if (c.enabled === false || envUrl?.toLowerCase() === "off")
|
|
107
|
-
return undefined;
|
|
108
|
-
return {
|
|
109
|
-
url: (envUrl || c.url || CLOUD_DEFAULT_URL).replace(/\/+$/, ""),
|
|
110
|
-
interval: c.interval ?? CLOUD_DEFAULT_INTERVAL,
|
|
111
|
-
token: process.env.KRAFTWERK_CLOUD_TOKEN?.trim() || undefined,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
100
|
/** A directory as a .gitignore entry: relative, forward slashes, no trailing slash; undefined outside the root. */
|
|
115
101
|
export function ignoreEntryFor(projectRoot, dir) {
|
|
116
102
|
const rel = path.relative(projectRoot, path.resolve(projectRoot, dir)).split(path.sep).join("/");
|
|
@@ -337,7 +323,7 @@ function validateCloud(configPath, value) {
|
|
|
337
323
|
const c = value;
|
|
338
324
|
for (const key of Object.keys(c)) {
|
|
339
325
|
if (key === "token") {
|
|
340
|
-
throw new Error(`${file}: cloud.token does not belong in kraftwerk.yml —
|
|
326
|
+
throw new Error(`${file}: cloud.token does not belong in kraftwerk.yml — the cloud knows the machine, and \`kraftwerk login\` claims it`);
|
|
341
327
|
}
|
|
342
328
|
if (!["enabled", "url", "interval"].includes(key)) {
|
|
343
329
|
throw new Error(`${file}: cloud.${key} is unknown (allowed: enabled, url, interval)`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Attachment } from "./types.js";
|
|
1
|
+
import type { Attachment, ChatPerson } from "./types.js";
|
|
2
2
|
import { type ChatState } from "./state.js";
|
|
3
3
|
/**
|
|
4
4
|
* Channels: how a post wakes the agents it mentions, and how each agent
|
|
@@ -10,6 +10,6 @@ import { type ChatState } from "./state.js";
|
|
|
10
10
|
* parallel, never blocking the humans, who can keep posting. An agent that
|
|
11
11
|
* is still busy is woken again when its turn ends, so nothing is lost.
|
|
12
12
|
*/
|
|
13
|
-
export declare function postChannelMessage(state: ChatState, slug: string, text: string, from?: string, attachments?: Attachment[]): Promise<{
|
|
13
|
+
export declare function postChannelMessage(state: ChatState, slug: string, text: string, from?: string, attachments?: Attachment[], by?: ChatPerson): Promise<{
|
|
14
14
|
error?: string;
|
|
15
15
|
}>;
|
|
@@ -15,12 +15,12 @@ import { dropSeat, emit, forgetFailedResume, newSeat } from "./state.js";
|
|
|
15
15
|
* parallel, never blocking the humans, who can keep posting. An agent that
|
|
16
16
|
* is still busy is woken again when its turn ends, so nothing is lost.
|
|
17
17
|
*/
|
|
18
|
-
export async function postChannelMessage(state, slug, text, from, attachments) {
|
|
18
|
+
export async function postChannelMessage(state, slug, text, from, attachments, by) {
|
|
19
19
|
const channel = await getChannel(slug).catch(() => null);
|
|
20
20
|
if (!channel)
|
|
21
21
|
return { error: `channel "${slug}" has no definition under channels/` };
|
|
22
22
|
const name = (from ?? "").replace(/\s+/g, " ").trim().slice(0, 40) || "you";
|
|
23
|
-
emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}) }, { kind: "human", name });
|
|
23
|
+
emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}), ...(by ? { by } : {}) }, { kind: "human", name });
|
|
24
24
|
void writeMeta(state.meta).catch(() => { });
|
|
25
25
|
for (const target of mentionTargets(text, channel))
|
|
26
26
|
void wakeSeat(state, channel, target, 0);
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import type { Attachment, ChatAgentId, ChatMeta, ChatScope, ElicitationAnswer, StoredChatEvent } from "./types.js";
|
|
1
|
+
import type { Attachment, ChatAgentId, ChatMeta, ChatPerson, ChatScope, ElicitationAnswer, StoredChatEvent } from "./types.js";
|
|
2
2
|
import { type Channel } from "../channels.js";
|
|
3
3
|
export { agentFilesContext, agentJournalContext, agentVibeablesContext, vibeableContext } from "./context.js";
|
|
4
4
|
export { disposeAllBackends, disposeWorkspaceBackends } from "./state.js";
|
|
5
|
+
/**
|
|
6
|
+
* Chats: what the API does with them — create, post, steer, answer, fork,
|
|
7
|
+
* rename, delete, subscribe. The pieces live beside it: the live state and
|
|
8
|
+
* its events (state.ts), the context an agent is given (context.ts), its
|
|
9
|
+
* process (seats.ts) and channel turns (channel-turns.ts).
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* This machine's person: its identity handle (identity.ts). What a chat or
|
|
13
|
+
* message is signed with when nobody else is named — local use, a paired
|
|
14
|
+
* device, a routine's run. A member session names its member instead.
|
|
15
|
+
*/
|
|
16
|
+
export declare function localPerson(): Promise<ChatPerson>;
|
|
5
17
|
/**
|
|
6
18
|
* The folder the agent is actually spawned in. A vibeable that was removed
|
|
7
19
|
* leaves meta.cwd pointing nowhere; spawning there fails, so fall back to
|
|
@@ -18,6 +30,10 @@ export declare function createChat(opts: {
|
|
|
18
30
|
resume?: string;
|
|
19
31
|
/** The project an agent's chat works in (meta.project); the caller checks that it exists. */
|
|
20
32
|
project?: string;
|
|
33
|
+
/** Who starts it; default this machine's person. */
|
|
34
|
+
author?: ChatPerson;
|
|
35
|
+
/** A private routine's run: hidden from remote callers (meta.private). */
|
|
36
|
+
private?: boolean;
|
|
21
37
|
}): Promise<ChatMeta>;
|
|
22
38
|
/** A permission request is waiting for a human in this chat (live state only — a restart drops it with the backend). */
|
|
23
39
|
export declare function chatAwaitingApproval(id: string): boolean;
|
|
@@ -61,6 +77,7 @@ export declare function getChat(id: string): Promise<{
|
|
|
61
77
|
export declare function postMessage(id: string, text: string, opts?: {
|
|
62
78
|
from?: string;
|
|
63
79
|
attachments?: Attachment[];
|
|
80
|
+
by?: ChatPerson;
|
|
64
81
|
}): Promise<{
|
|
65
82
|
error?: string;
|
|
66
83
|
}>;
|
|
@@ -101,7 +118,7 @@ export declare function setChatVibeable(id: string, slug: string | null): Promis
|
|
|
101
118
|
* once. The source's process is (re)spawned if needed; the fork's own
|
|
102
119
|
* process starts with its first message and resumes the forked id.
|
|
103
120
|
*/
|
|
104
|
-
export declare function forkChat(id: string): Promise<{
|
|
121
|
+
export declare function forkChat(id: string, by?: ChatPerson): Promise<{
|
|
105
122
|
error?: string;
|
|
106
123
|
status?: number;
|
|
107
124
|
meta?: ChatMeta;
|
|
@@ -125,7 +142,7 @@ export declare function answerElicitation(id: string, requestId: string, answer:
|
|
|
125
142
|
* for it to end. Shown in the thread like any message. When no turn is
|
|
126
143
|
* running (or the agent cannot take steering) it goes the ordinary way.
|
|
127
144
|
*/
|
|
128
|
-
export declare function steerChat(id: string, text: string, attachments?: Attachment[]): Promise<{
|
|
145
|
+
export declare function steerChat(id: string, text: string, attachments?: Attachment[], by?: ChatPerson): Promise<{
|
|
129
146
|
error?: string;
|
|
130
147
|
}>;
|
|
131
148
|
export declare function stopChatTask(id: string, taskId: string): Promise<{
|
|
@@ -5,6 +5,7 @@ import { getAgent } from "../agents.js";
|
|
|
5
5
|
import { resolveVibeable } from "../vibeables.js";
|
|
6
6
|
import { deleteAgentSession, listAgentSessions } from "./acp.js";
|
|
7
7
|
import { chatHref, pushNotification } from "../notifications.js";
|
|
8
|
+
import { readIdentity } from "../identity.js";
|
|
8
9
|
import { moveToTrash } from "../trash.js";
|
|
9
10
|
import { attachmentPath, copyChatFiles, listChatMetas, newChatId, readMetaAt, safeChatDir, writeMeta } from "./store.js";
|
|
10
11
|
import { postChannelMessage } from "./channel-turns.js";
|
|
@@ -20,6 +21,14 @@ export { disposeAllBackends, disposeWorkspaceBackends } from "./state.js";
|
|
|
20
21
|
* process (seats.ts) and channel turns (channel-turns.ts).
|
|
21
22
|
*/
|
|
22
23
|
/* ---------- public API (used by server.ts) ---------- */
|
|
24
|
+
/**
|
|
25
|
+
* This machine's person: its identity handle (identity.ts). What a chat or
|
|
26
|
+
* message is signed with when nobody else is named — local use, a paired
|
|
27
|
+
* device, a routine's run. A member session names its member instead.
|
|
28
|
+
*/
|
|
29
|
+
export async function localPerson() {
|
|
30
|
+
return { handle: (await readIdentity()).handle };
|
|
31
|
+
}
|
|
23
32
|
/** Where a chat's agent runs when no vibeable is open: the run folder for run chats, else the project root. */
|
|
24
33
|
const defaultCwd = (scope) => (scope.kind === "run" ? safeRunDir(scope.runId) : workspaceRoot());
|
|
25
34
|
/**
|
|
@@ -37,6 +46,7 @@ export async function effectiveCwd(meta) {
|
|
|
37
46
|
export async function createChat(opts) {
|
|
38
47
|
const cwd = defaultCwd(opts.scope);
|
|
39
48
|
const now = new Date().toISOString();
|
|
49
|
+
const author = opts.author ?? (await localPerson());
|
|
40
50
|
const meta = {
|
|
41
51
|
id: newChatId(),
|
|
42
52
|
agent: opts.agent,
|
|
@@ -45,6 +55,8 @@ export async function createChat(opts) {
|
|
|
45
55
|
scope: opts.scope,
|
|
46
56
|
...(opts.project && opts.scope.kind === "agent" ? { project: opts.project } : {}),
|
|
47
57
|
...(opts.resume && opts.agent !== "pi" ? { sessions: { [MAIN]: opts.resume } } : {}),
|
|
58
|
+
author,
|
|
59
|
+
...(opts.private ? { private: true } : {}),
|
|
48
60
|
createdAt: now,
|
|
49
61
|
updatedAt: now,
|
|
50
62
|
};
|
|
@@ -121,8 +133,9 @@ export async function postMessage(id, text, opts = {}) {
|
|
|
121
133
|
if (!state)
|
|
122
134
|
return { error: "not found" };
|
|
123
135
|
const attachments = opts.attachments?.length ? opts.attachments : undefined;
|
|
136
|
+
const by = opts.by ?? (await localPerson());
|
|
124
137
|
if (state.meta.scope.kind === "channel")
|
|
125
|
-
return postChannelMessage(state, state.meta.scope.slug, text, opts.from, attachments);
|
|
138
|
+
return postChannelMessage(state, state.meta.scope.slug, text, opts.from, attachments, by);
|
|
126
139
|
const seat = mainSeat(state);
|
|
127
140
|
if (seat.busy)
|
|
128
141
|
return { error: "agent is still working — wait for the turn to finish" };
|
|
@@ -133,7 +146,7 @@ export async function postMessage(id, text, opts = {}) {
|
|
|
133
146
|
state.meta.title = text.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
134
147
|
void writeMeta(state.meta).catch(() => { });
|
|
135
148
|
}
|
|
136
|
-
const turnStart = emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}) }).seq;
|
|
149
|
+
const turnStart = emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}), by }).seq;
|
|
137
150
|
// The turn runs in the background; the HTTP request returns immediately
|
|
138
151
|
// and the browser follows along on the SSE stream.
|
|
139
152
|
void (async () => {
|
|
@@ -319,7 +332,7 @@ export async function setChatVibeable(id, slug) {
|
|
|
319
332
|
* once. The source's process is (re)spawned if needed; the fork's own
|
|
320
333
|
* process starts with its first message and resumes the forked id.
|
|
321
334
|
*/
|
|
322
|
-
export async function forkChat(id) {
|
|
335
|
+
export async function forkChat(id, by) {
|
|
323
336
|
const state = await loadState(id);
|
|
324
337
|
if (!state)
|
|
325
338
|
return { error: "not found", status: 404 };
|
|
@@ -351,6 +364,7 @@ export async function forkChat(id) {
|
|
|
351
364
|
id: newChatId(),
|
|
352
365
|
title: meta.title ? `${meta.title} (fork)` : "",
|
|
353
366
|
sessions: { [MAIN]: forked },
|
|
367
|
+
author: by ?? (await localPerson()),
|
|
354
368
|
createdAt: now,
|
|
355
369
|
updatedAt: now,
|
|
356
370
|
};
|
|
@@ -394,7 +408,7 @@ export async function answerElicitation(id, requestId, answer) {
|
|
|
394
408
|
* for it to end. Shown in the thread like any message. When no turn is
|
|
395
409
|
* running (or the agent cannot take steering) it goes the ordinary way.
|
|
396
410
|
*/
|
|
397
|
-
export async function steerChat(id, text, attachments) {
|
|
411
|
+
export async function steerChat(id, text, attachments, by) {
|
|
398
412
|
const state = await loadState(id);
|
|
399
413
|
if (!state)
|
|
400
414
|
return { error: "not found" };
|
|
@@ -402,17 +416,18 @@ export async function steerChat(id, text, attachments) {
|
|
|
402
416
|
return { error: "channels take messages, not steering — @mention the agent" };
|
|
403
417
|
const seat = mainSeat(state);
|
|
404
418
|
const files = attachments?.length ? attachments : undefined;
|
|
419
|
+
const person = by ?? (await localPerson());
|
|
405
420
|
if (!seat.busy || !seat.backend?.steer)
|
|
406
|
-
return postMessage(id, text, { attachments: files });
|
|
421
|
+
return postMessage(id, text, { attachments: files, by: person });
|
|
407
422
|
try {
|
|
408
423
|
const outcome = await seat.backend.steer(text, promptFiles(id, files));
|
|
409
424
|
if (outcome === "promptRequired")
|
|
410
|
-
return postMessage(id, text, { attachments: files });
|
|
425
|
+
return postMessage(id, text, { attachments: files, by: person });
|
|
411
426
|
}
|
|
412
427
|
catch (err) {
|
|
413
428
|
return { error: err.message };
|
|
414
429
|
}
|
|
415
|
-
emit(state, { type: "user_message", text, steered: true, ...(files ? { attachments: files } : {}) });
|
|
430
|
+
emit(state, { type: "user_message", text, steered: true, ...(files ? { attachments: files } : {}), by: person });
|
|
416
431
|
void writeMeta(state.meta).catch(() => { });
|
|
417
432
|
return {};
|
|
418
433
|
}
|
|
@@ -31,6 +31,17 @@ export type ChatScope = {
|
|
|
31
31
|
kind: "channel";
|
|
32
32
|
slug: string;
|
|
33
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* The person behind a chat or a message: this machine's identity handle
|
|
36
|
+
* (local use, a paired device) or a remote member of the workspace (the
|
|
37
|
+
* cloud vouched for their handle and email; see server/members.ts).
|
|
38
|
+
* Absent on chats and messages from before authors were recorded.
|
|
39
|
+
*/
|
|
40
|
+
export interface ChatPerson {
|
|
41
|
+
handle: string;
|
|
42
|
+
/** A remote member's account email; absent for this machine's own person. */
|
|
43
|
+
email?: string;
|
|
44
|
+
}
|
|
34
45
|
/** Who produced an event. Absent on single-agent chats (the one human, the one agent). */
|
|
35
46
|
export type Author = {
|
|
36
47
|
kind: "human";
|
|
@@ -60,6 +71,14 @@ export interface ChatMeta {
|
|
|
60
71
|
* over with the transcript as a summary.
|
|
61
72
|
*/
|
|
62
73
|
sessions?: Record<string, string>;
|
|
74
|
+
/** Who started the chat (a routine's run: this machine's person). */
|
|
75
|
+
author?: ChatPerson;
|
|
76
|
+
/**
|
|
77
|
+
* A run of a `private: true` routine: left out of what remote callers see
|
|
78
|
+
* (member sessions, paired devices over the relay); this machine and the
|
|
79
|
+
* LAN still see it.
|
|
80
|
+
*/
|
|
81
|
+
private?: boolean;
|
|
63
82
|
createdAt: string;
|
|
64
83
|
updatedAt: string;
|
|
65
84
|
}
|
|
@@ -166,11 +185,13 @@ export type ElicitationAnswer = {
|
|
|
166
185
|
*/
|
|
167
186
|
export type ChatEvent =
|
|
168
187
|
/** `steered`: handed into the running turn instead of starting the next one. */
|
|
188
|
+
/** `by`: the person who sent it (see ChatPerson). */
|
|
169
189
|
{
|
|
170
190
|
type: "user_message";
|
|
171
191
|
text: string;
|
|
172
192
|
steered?: boolean;
|
|
173
193
|
attachments?: Attachment[];
|
|
194
|
+
by?: ChatPerson;
|
|
174
195
|
} | {
|
|
175
196
|
type: "text";
|
|
176
197
|
text: string;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kraftwerk cloud, machine side: the settings and the wire contract the
|
|
3
|
+
* daemon's machine client (server/cloud.ts) and the CLI (`kraftwerk cloud`,
|
|
4
|
+
* `login`, `logout`) share.
|
|
5
|
+
*
|
|
6
|
+
* The daemon registers this MACHINE once; the workspaces it serves ride
|
|
7
|
+
* along as children of that record. The machine's id is its relay id (the
|
|
8
|
+
* hash of the X25519 key in ~/.kraftwerk/relay.json) and the proof is the
|
|
9
|
+
* relay secret — the key cannot sign. Nothing here opens the relay link.
|
|
10
|
+
*
|
|
11
|
+
* ~/.kraftwerk/cloud.json — the machine's switch and the cloud's origin:
|
|
12
|
+
*
|
|
13
|
+
* { "enabled": false, "url": "https://cloud.example.com" }
|
|
14
|
+
*
|
|
15
|
+
* `enabled` absent = on. The origin: KRAFTWERK_CLOUD_URL (env) wins, then
|
|
16
|
+
* cloud.json `url`, then a kraftwerk.yml `cloud.url` of an open workspace
|
|
17
|
+
* (deprecated, one release), then the default. KRAFTWERK_CLOUD_URL=off
|
|
18
|
+
* turns the cloud off for the process (what tests and CI set). kraftwerk.yml
|
|
19
|
+
* `cloud.enabled: false` is a per-workspace veto: that workspace is left out
|
|
20
|
+
* of the machine's reports.
|
|
21
|
+
*
|
|
22
|
+
* The contract (all POST, JSON):
|
|
23
|
+
*
|
|
24
|
+
* /api/machines/register {id, key, secret, name, handle, hostname?, platform?, version, interval?, workspaces}
|
|
25
|
+
* → {ok, interval, claimCode|null, account|null} (403: wrong secret)
|
|
26
|
+
* /api/machines/heartbeat {id, secret, version?, workspaces?, routines?} → {ok, interval, account, claimCode}
|
|
27
|
+
* /api/machines/goodbye {id, secret}
|
|
28
|
+
* /api/machines/unlink {id, secret} → {ok, claimCode}
|
|
29
|
+
*
|
|
30
|
+
* workspaces: [{id?: "ws_…", name, slug, root?}] — `root` only once the
|
|
31
|
+
* machine is claimed (account set): an unclaimed machine tells the cloud no
|
|
32
|
+
* paths.
|
|
33
|
+
*
|
|
34
|
+
* routines: [{workspaceId, routineId: "<agent>/<id>", name, lastRunAt|null, ok}]
|
|
35
|
+
* — the routines that ran here, private ones left out, at most 500; only
|
|
36
|
+
* while claimed, and only when it changed since the cloud last took it.
|
|
37
|
+
*
|
|
38
|
+
* Membership (remote members, no clone — they reach a workspace through the
|
|
39
|
+
* relay with a workspace-scoped member token, see server/members.ts):
|
|
40
|
+
*
|
|
41
|
+
* /api/members/verify {id, secret, token, workspaceId} → {email, handle, role: "owner"|"member"}
|
|
42
|
+
* (404: unknown or revoked token; 403: this machine does not host the workspace)
|
|
43
|
+
* /api/workspaces/:id/invites {id, secret, role?} → {code, url, role, expiresAt}
|
|
44
|
+
* (a claimed machine reporting the workspace; `kraftwerk invite`)
|
|
45
|
+
*/
|
|
46
|
+
export interface CloudFile {
|
|
47
|
+
enabled?: boolean;
|
|
48
|
+
url?: string;
|
|
49
|
+
}
|
|
50
|
+
/** Where the effective origin came from. */
|
|
51
|
+
export type CloudUrlSource = "env" | "cloud.json" | "kraftwerk.yml" | "default";
|
|
52
|
+
export interface MachineCloud {
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
url: string;
|
|
55
|
+
source: CloudUrlSource;
|
|
56
|
+
/** Why it is off, when it is. */
|
|
57
|
+
reason?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface MachineWorkspace {
|
|
60
|
+
id?: string;
|
|
61
|
+
name: string;
|
|
62
|
+
slug: string;
|
|
63
|
+
root?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface Credentials {
|
|
66
|
+
id: string;
|
|
67
|
+
key: string;
|
|
68
|
+
secret: string;
|
|
69
|
+
}
|
|
70
|
+
export interface RegisterResponse {
|
|
71
|
+
ok: boolean;
|
|
72
|
+
interval: number;
|
|
73
|
+
claimCode: string | null;
|
|
74
|
+
account: string | null;
|
|
75
|
+
}
|
|
76
|
+
export interface HeartbeatResponse {
|
|
77
|
+
ok: boolean;
|
|
78
|
+
interval: number;
|
|
79
|
+
account: string | null;
|
|
80
|
+
claimCode: string | null;
|
|
81
|
+
}
|
|
82
|
+
/** What the cloud vouches for about a member token in one workspace. */
|
|
83
|
+
export interface MemberVerdict {
|
|
84
|
+
email: string;
|
|
85
|
+
handle: string;
|
|
86
|
+
role: "owner" | "member";
|
|
87
|
+
}
|
|
88
|
+
export interface InviteResponse {
|
|
89
|
+
code: string;
|
|
90
|
+
url: string;
|
|
91
|
+
role: "owner" | "member";
|
|
92
|
+
expiresAt: string;
|
|
93
|
+
}
|
|
94
|
+
/** One routine that ran here, in the heartbeat (claimed machines only). */
|
|
95
|
+
export interface RoutineReportEntry {
|
|
96
|
+
workspaceId: string;
|
|
97
|
+
routineId: string;
|
|
98
|
+
name: string;
|
|
99
|
+
lastRunAt: string | null;
|
|
100
|
+
ok: boolean;
|
|
101
|
+
}
|
|
102
|
+
/** The most routines one heartbeat carries. */
|
|
103
|
+
export declare const MAX_REPORTED_ROUTINES = 500;
|
|
104
|
+
export interface UnlinkResponse {
|
|
105
|
+
ok: boolean;
|
|
106
|
+
claimCode: string | null;
|
|
107
|
+
}
|
|
108
|
+
export declare const cloudFilePath: () => string;
|
|
109
|
+
/** The retired per-workspace identities (before machines): deleted at the daemon's start. */
|
|
110
|
+
export declare const retiredCloudDir: () => string;
|
|
111
|
+
export declare function readCloudFile(): Promise<CloudFile>;
|
|
112
|
+
/** Merge a change into cloud.json (a `url: undefined` leaves the URL as it is). */
|
|
113
|
+
export declare function writeCloudFile(change: CloudFile): Promise<CloudFile>;
|
|
114
|
+
/** Whether the environment turned the cloud off for this process. */
|
|
115
|
+
export declare const envCloudOff: () => boolean;
|
|
116
|
+
/**
|
|
117
|
+
* The machine's effective cloud. `ymlUrls`: `cloud.url` of open workspaces
|
|
118
|
+
* (deprecated) — used only when neither the environment nor cloud.json names
|
|
119
|
+
* an origin.
|
|
120
|
+
*/
|
|
121
|
+
export declare function machineCloud(ymlUrls?: string[]): Promise<MachineCloud>;
|
|
122
|
+
export declare const claimUrlFor: (url: string, code: string | null | undefined) => string | null;
|
|
123
|
+
export declare const GOODBYE_TIMEOUT_MS = 800;
|
|
124
|
+
/** A refused or failed call; `status` is the HTTP status (0: no answer). */
|
|
125
|
+
export declare class CloudError extends Error {
|
|
126
|
+
readonly status: number;
|
|
127
|
+
constructor(message: string, status: number);
|
|
128
|
+
}
|
|
129
|
+
/** The cloud no longer knows this machine (its data was reset): registering again fixes it. */
|
|
130
|
+
export declare const unknownMachine: (err: unknown) => boolean;
|
|
131
|
+
export declare function cloudPost<T>(base: string, route: string, body: unknown, opts?: {
|
|
132
|
+
version?: string;
|
|
133
|
+
timeout?: number;
|
|
134
|
+
}): Promise<T>;
|
|
135
|
+
/** What register says about the machine besides its credentials. */
|
|
136
|
+
export interface MachineHello {
|
|
137
|
+
name: string;
|
|
138
|
+
handle: string;
|
|
139
|
+
hostname: string;
|
|
140
|
+
platform: string;
|
|
141
|
+
version: string;
|
|
142
|
+
}
|
|
143
|
+
export declare const machineRegister: (base: string, creds: Credentials, hello: MachineHello, workspaces: MachineWorkspace[]) => Promise<RegisterResponse>;
|
|
144
|
+
export declare const machineHeartbeat: (base: string, creds: Credentials, extra: {
|
|
145
|
+
version?: string;
|
|
146
|
+
workspaces?: MachineWorkspace[];
|
|
147
|
+
routines?: RoutineReportEntry[];
|
|
148
|
+
}) => Promise<HeartbeatResponse>;
|
|
149
|
+
export declare const machineGoodbye: (base: string, creds: Credentials, version?: string) => Promise<unknown>;
|
|
150
|
+
export declare const machineUnlink: (base: string, creds: Credentials, version?: string) => Promise<UnlinkResponse>;
|
|
151
|
+
export declare const memberVerify: (base: string, creds: Credentials, token: string, workspaceId: string, version?: string) => Promise<MemberVerdict>;
|
|
152
|
+
export declare const workspaceInvite: (base: string, creds: Credentials, workspaceId: string, role?: "owner" | "member", version?: string) => Promise<InviteResponse>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CLOUD_DEFAULT_URL } from "../config.js";
|
|
6
|
+
/** The most routines one heartbeat carries. */
|
|
7
|
+
export const MAX_REPORTED_ROUTINES = 500;
|
|
8
|
+
// Resolved on every use, not at import: a test swaps HOME after some module already imported this one.
|
|
9
|
+
const kwHome = () => path.join(os.homedir(), ".kraftwerk");
|
|
10
|
+
export const cloudFilePath = () => path.join(kwHome(), "cloud.json");
|
|
11
|
+
/** The retired per-workspace identities (before machines): deleted at the daemon's start. */
|
|
12
|
+
export const retiredCloudDir = () => path.join(kwHome(), "cloud");
|
|
13
|
+
export async function readCloudFile() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = JSON.parse(await fs.readFile(cloudFilePath(), "utf8"));
|
|
16
|
+
return {
|
|
17
|
+
...(typeof raw.enabled === "boolean" ? { enabled: raw.enabled } : {}),
|
|
18
|
+
...(typeof raw.url === "string" && raw.url ? { url: raw.url.replace(/\/+$/, "") } : {}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Merge a change into cloud.json (a `url: undefined` leaves the URL as it is). */
|
|
26
|
+
export async function writeCloudFile(change) {
|
|
27
|
+
const cur = await readCloudFile();
|
|
28
|
+
const next = { ...cur, ...(change.enabled !== undefined ? { enabled: change.enabled } : {}), ...(change.url ? { url: change.url.replace(/\/+$/, "") } : {}) };
|
|
29
|
+
const file = cloudFilePath();
|
|
30
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
31
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
32
|
+
await fs.writeFile(tmp, JSON.stringify(next, null, 2) + "\n");
|
|
33
|
+
await fs.rename(tmp, file);
|
|
34
|
+
return next;
|
|
35
|
+
}
|
|
36
|
+
/** Whether the environment turned the cloud off for this process. */
|
|
37
|
+
export const envCloudOff = () => process.env.KRAFTWERK_CLOUD_URL?.trim().toLowerCase() === "off";
|
|
38
|
+
/**
|
|
39
|
+
* The machine's effective cloud. `ymlUrls`: `cloud.url` of open workspaces
|
|
40
|
+
* (deprecated) — used only when neither the environment nor cloud.json names
|
|
41
|
+
* an origin.
|
|
42
|
+
*/
|
|
43
|
+
export async function machineCloud(ymlUrls = []) {
|
|
44
|
+
const file = await readCloudFile();
|
|
45
|
+
const env = process.env.KRAFTWERK_CLOUD_URL?.trim();
|
|
46
|
+
const [url, source] = env && !envCloudOff() ? [env, "env"] : file.url ? [file.url, "cloud.json"] : ymlUrls[0] ? [ymlUrls[0], "kraftwerk.yml"] : [CLOUD_DEFAULT_URL, "default"];
|
|
47
|
+
const base = { url: url.replace(/\/+$/, ""), source };
|
|
48
|
+
if (envCloudOff())
|
|
49
|
+
return { ...base, enabled: false, reason: "KRAFTWERK_CLOUD_URL=off" };
|
|
50
|
+
if (file.enabled === false)
|
|
51
|
+
return { ...base, enabled: false, reason: "turned off for this machine (`kraftwerk cloud on` turns it on)" };
|
|
52
|
+
return { ...base, enabled: true };
|
|
53
|
+
}
|
|
54
|
+
export const claimUrlFor = (url, code) => (code ? `${url}/claim/${encodeURIComponent(code)}` : null);
|
|
55
|
+
const REQUEST_TIMEOUT_MS = 8_000;
|
|
56
|
+
export const GOODBYE_TIMEOUT_MS = 800;
|
|
57
|
+
/** A refused or failed call; `status` is the HTTP status (0: no answer). */
|
|
58
|
+
export class CloudError extends Error {
|
|
59
|
+
status;
|
|
60
|
+
constructor(message, status) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.status = status;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The cloud no longer knows this machine (its data was reset): registering again fixes it. */
|
|
66
|
+
export const unknownMachine = (err) => err instanceof CloudError && (err.status === 404 || /unknown machine|not registered/i.test(err.message));
|
|
67
|
+
export async function cloudPost(base, route, body, opts = {}) {
|
|
68
|
+
const url = `${base}${route}`;
|
|
69
|
+
let r;
|
|
70
|
+
try {
|
|
71
|
+
r = await fetch(url, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "content-type": "application/json", "user-agent": `kraftwerk/${opts.version ?? "dev"}` },
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
signal: AbortSignal.timeout(opts.timeout ?? REQUEST_TIMEOUT_MS),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
throw new CloudError(err.message, 0);
|
|
80
|
+
}
|
|
81
|
+
const text = await r.text();
|
|
82
|
+
let data;
|
|
83
|
+
try {
|
|
84
|
+
data = text ? JSON.parse(text) : {};
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new CloudError(`${r.status} from ${url}: not JSON`, r.status);
|
|
88
|
+
}
|
|
89
|
+
if (!r.ok)
|
|
90
|
+
throw new CloudError(data.error ?? `${r.status} from ${url}`, r.status);
|
|
91
|
+
return data;
|
|
92
|
+
}
|
|
93
|
+
export const machineRegister = (base, creds, hello, workspaces) => cloudPost(base, "/api/machines/register", { ...creds, ...hello, workspaces }, { version: hello.version });
|
|
94
|
+
export const machineHeartbeat = (base, creds, extra) => cloudPost(base, "/api/machines/heartbeat", { id: creds.id, secret: creds.secret, ...extra }, { version: extra.version });
|
|
95
|
+
export const machineGoodbye = (base, creds, version) => cloudPost(base, "/api/machines/goodbye", { id: creds.id, secret: creds.secret }, { version, timeout: GOODBYE_TIMEOUT_MS });
|
|
96
|
+
export const machineUnlink = (base, creds, version) => cloudPost(base, "/api/machines/unlink", { id: creds.id, secret: creds.secret }, { version });
|
|
97
|
+
export const memberVerify = (base, creds, token, workspaceId, version) => cloudPost(base, "/api/members/verify", { id: creds.id, secret: creds.secret, token, workspaceId }, { version });
|
|
98
|
+
export const workspaceInvite = (base, creds, workspaceId, role, version) => cloudPost(base, `/api/workspaces/${encodeURIComponent(workspaceId)}/invites`, { id: creds.id, secret: creds.secret, ...(role ? { role } : {}) }, { version });
|