@netnodeag/kraftwerk 0.75.1 → 0.77.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-control.js +4 -2
- package/dist/cli/daemon.js +9 -0
- package/dist/cli/doctor.d.ts +3 -1
- package/dist/cli/doctor.js +51 -9
- package/dist/cli/init.js +7 -4
- package/dist/cli/kraftwerk.js +13 -5
- package/dist/cli/routines.d.ts +2 -1
- package/dist/cli/routines.js +40 -4
- package/dist/cli/whoami.d.ts +9 -0
- package/dist/cli/whoami.js +48 -0
- package/dist/client/routes.d.ts +7 -0
- package/dist/client/routes.js +7 -0
- package/dist/config.d.ts +35 -28
- package/dist/config.js +43 -22
- package/dist/core/attention.d.ts +8 -1
- package/dist/core/attention.js +38 -2
- package/dist/core/cloud.d.ts +115 -0
- package/dist/core/cloud.js +94 -0
- package/dist/core/identity.d.ts +46 -0
- package/dist/core/identity.js +106 -0
- package/dist/core/routines.d.ts +81 -0
- package/dist/core/routines.js +174 -33
- package/dist/server/api/agents.d.ts +5 -1
- package/dist/server/api/agents.js +19 -2
- package/dist/server/api/cloud.d.ts +72 -0
- package/dist/server/api/cloud.js +38 -0
- package/dist/server/api/devices.d.ts +8 -8
- package/dist/server/api/index.d.ts +102 -13
- package/dist/server/api/index.js +2 -0
- package/dist/server/api/runs.d.ts +3 -1
- package/dist/server/api/runs.js +9 -1
- package/dist/server/api/workspace.d.ts +22 -2
- package/dist/server/api/workspace.js +7 -1
- package/dist/server/cloud.d.ts +65 -33
- package/dist/server/cloud.js +207 -188
- package/dist/server/hub-context.d.ts +1 -1
- package/dist/server/hub-context.js +3 -3
- package/dist/server/push.js +1 -1
- package/dist/server/relay.d.ts +13 -0
- package/dist/server/relay.js +29 -1
- package/dist/server/server.d.ts +3 -2
- package/dist/server/server.js +6 -6
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +9 -4
- package/web/dist/assets/{dist-CezfArgg.js → dist-BGHweZKi.js} +1 -1
- package/web/dist/assets/{dist-Y9aHncjU.js → dist-BVPxOqcx.js} +1 -1
- package/web/dist/assets/{dist-CLZjnhZZ.js → dist-CCNyRSAA.js} +1 -1
- package/web/dist/assets/{dist-QqFmuPda.js → dist-CXoAMU1b.js} +1 -1
- package/web/dist/assets/{dist-BI4umsk6.js → dist-DCFREMXk.js} +1 -1
- package/web/dist/assets/{dist-YNCNGGgK.js → dist-DT2Tjvwx.js} +1 -1
- package/web/dist/assets/{dist-CzM6tn0f.js → dist-DdA5PoVJ.js} +1 -1
- package/web/dist/assets/{dist-CYicby0c.js → dist-DoD3qX1P.js} +1 -1
- package/web/dist/assets/{dist-CODxhtAK.js → dist-FFA4-S6d.js} +1 -1
- package/web/dist/assets/{dist-DaGnyH_l.js → dist-yGx0qYbw.js} +1 -1
- package/web/dist/assets/dist-zjUUP5a9.js +1 -0
- package/web/dist/assets/{editor-DEb8z5-Q.js → editor-CyWapLQr.js} +3 -3
- package/web/dist/assets/index-CNXcNoKJ.js +231 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/dist-DtktO5hc.js +0 -1
- package/web/dist/assets/index-kmyK4tFm.js +0 -231
package/dist/server/cloud.js
CHANGED
|
@@ -1,232 +1,251 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { promises as fs } from "node:fs";
|
|
3
2
|
import os from "node:os";
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/** The client's state, one per workspace: each served workspace registers and heartbeats on its own. */
|
|
15
|
-
const client = perWorkspace(() => ({
|
|
16
|
-
status: { url: "", state: "off" },
|
|
17
|
-
identity: null,
|
|
3
|
+
import { resolveWorkspace } from "../config.js";
|
|
4
|
+
import { claimUrlFor, machineCloud, machineGoodbye, machineHeartbeat, machineRegister, machineUnlink, retiredCloudDir, unknownMachine, } from "../core/cloud.js";
|
|
5
|
+
import { identityString, readIdentity } from "../core/identity.js";
|
|
6
|
+
import { machineCredentials } from "./relay.js";
|
|
7
|
+
const DEFAULT_INTERVAL = 60;
|
|
8
|
+
const STANDALONE = { url: "", state: "off", reason: "only the daemon registers this machine with the cloud (`kraftwerk daemon start`)" };
|
|
9
|
+
const m = {
|
|
10
|
+
status: { ...STANDALONE },
|
|
11
|
+
creds: null,
|
|
12
|
+
registered: false,
|
|
18
13
|
timer: null,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
14
|
+
started: false,
|
|
15
|
+
owner: null,
|
|
16
|
+
source: (() => []),
|
|
17
|
+
version: "",
|
|
18
|
+
/** The workspace list the cloud last acknowledged, as JSON: sent again only when it differs. */
|
|
19
|
+
reported: "",
|
|
20
|
+
/** One round at a time: a timer tick, a sync from the CLI and a workspace change never overlap. */
|
|
21
|
+
lock: Promise.resolve(),
|
|
22
|
+
warned: new Set(),
|
|
23
|
+
};
|
|
24
|
+
function serial(fn) {
|
|
25
|
+
const run = m.lock.then(fn, fn);
|
|
26
|
+
m.lock = run.catch(() => { });
|
|
27
|
+
return run;
|
|
37
28
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
29
|
+
export const cloudStatus = () => m.status;
|
|
30
|
+
/** The open workspaces as the cloud lists them: vetoed ones left out, roots only once claimed. */
|
|
31
|
+
async function entries(withRoots) {
|
|
32
|
+
const list = [];
|
|
33
|
+
const ymlUrls = [];
|
|
34
|
+
for (const o of m.source()) {
|
|
35
|
+
const ws = await resolveWorkspace(o.root).catch(() => null);
|
|
36
|
+
const c = ws?.config.cloud;
|
|
37
|
+
if (c?.enabled === false)
|
|
38
|
+
continue;
|
|
39
|
+
if (c?.url)
|
|
40
|
+
ymlUrls.push(c.url.replace(/\/+$/, ""));
|
|
41
|
+
list.push({ ...(ws?.config.id ? { id: ws.config.id } : {}), name: o.name, slug: o.slug, ...(withRoots ? { root: o.root } : {}) });
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
/** The agents and channels of this workspace, as the cloud lists them. */
|
|
46
|
-
async function roster() {
|
|
47
|
-
const [agents, channels] = await Promise.all([
|
|
48
|
-
listAgents().then((l) => l.map(toSummary)).catch(() => []),
|
|
49
|
-
listChannels().then((l) => l.map(toChannelSummary)).catch(() => []),
|
|
50
|
-
]);
|
|
51
|
-
return { agents, channels };
|
|
43
|
+
return { list, ymlUrls };
|
|
52
44
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
data = text ? JSON.parse(text) : {};
|
|
45
|
+
const claimed = () => typeof m.status.account === "string" && m.status.account.length > 0;
|
|
46
|
+
function warnYml(cloud, ymlUrls) {
|
|
47
|
+
if (!ymlUrls.length)
|
|
48
|
+
return;
|
|
49
|
+
const key = `${cloud.source}:${ymlUrls[0]}`;
|
|
50
|
+
if (m.warned.has(key))
|
|
51
|
+
return;
|
|
52
|
+
m.warned.add(key);
|
|
53
|
+
if (cloud.source === "kraftwerk.yml") {
|
|
54
|
+
console.warn(`⚠ kraftwerk.yml cloud.url is deprecated — the cloud is the machine's now: \`kraftwerk cloud on --url ${cloud.url}\` (using ${cloud.url} for now)`);
|
|
65
55
|
}
|
|
66
|
-
|
|
67
|
-
|
|
56
|
+
else {
|
|
57
|
+
console.warn(`⚠ kraftwerk.yml cloud.url (${ymlUrls[0]}) is ignored: the machine's cloud is ${cloud.url} (${cloud.source}); remove it from kraftwerk.yml`);
|
|
68
58
|
}
|
|
69
|
-
if (!r.ok)
|
|
70
|
-
throw new Error(data.error ?? `${r.status} from ${url}`);
|
|
71
|
-
return data;
|
|
72
59
|
}
|
|
73
|
-
/** Register (or re-register) this instance. Returns false when the cloud refused or is unreachable. */
|
|
74
60
|
async function register() {
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
if (!
|
|
61
|
+
const { list, ymlUrls } = await entries(false);
|
|
62
|
+
const cloud = await machineCloud(ymlUrls);
|
|
63
|
+
warnYml(cloud, ymlUrls);
|
|
64
|
+
if (!cloud.enabled) {
|
|
65
|
+
m.status = { url: cloud.url, state: "off", source: cloud.source, ...(cloud.reason ? { reason: cloud.reason } : {}) };
|
|
79
66
|
return false;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const { agents, channels } = await roster();
|
|
83
|
-
c.lastRoster = JSON.stringify({ agents, channels });
|
|
84
|
-
const body = {
|
|
85
|
-
...(c.identity ?? {}),
|
|
86
|
-
name: project.config.name ?? path.basename(project.root),
|
|
87
|
-
icon: project.config.icon ?? "",
|
|
88
|
-
color: project.config.color ?? "",
|
|
89
|
-
root: project.root,
|
|
90
|
-
rootLabel: tildify(project.root),
|
|
91
|
-
hostname: os.hostname(),
|
|
92
|
-
platform: `${process.platform}-${process.arch}`,
|
|
93
|
-
version: c.selfVersion,
|
|
94
|
-
url: c.selfUrl,
|
|
95
|
-
// Part of the cloud's register contract; always "" since the Cloudflare Tunnel was removed (0.65).
|
|
96
|
-
publicUrl: "",
|
|
97
|
-
agents,
|
|
98
|
-
channels,
|
|
99
|
-
interval: cloud.interval,
|
|
100
|
-
startedAt: c.startedAt,
|
|
101
|
-
...(cloud.token ? { accountToken: cloud.token } : {}),
|
|
102
|
-
};
|
|
67
|
+
}
|
|
68
|
+
const identity = await readIdentity();
|
|
103
69
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
70
|
+
m.creds ??= await machineCredentials();
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
m.status = { url: cloud.url, source: cloud.source, state: "error", error: err.message };
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const base = { url: cloud.url, source: cloud.source, id: m.creds.id, machine: identityString(identity) };
|
|
77
|
+
if (m.status.state === "off" || m.status.url !== cloud.url)
|
|
78
|
+
m.status = { ...base, state: "connecting" };
|
|
79
|
+
try {
|
|
80
|
+
const r = await machineRegister(cloud.url, m.creds, {
|
|
81
|
+
name: identity.machine,
|
|
82
|
+
handle: identity.handle,
|
|
83
|
+
hostname: os.hostname(),
|
|
84
|
+
platform: `${process.platform}-${process.arch}`,
|
|
85
|
+
version: m.version,
|
|
86
|
+
}, list);
|
|
87
|
+
m.registered = true;
|
|
88
|
+
// An unclaimed register carries no roots: the list counts as reported only when that is all the cloud should know.
|
|
89
|
+
m.reported = r.account ? "" : JSON.stringify(list);
|
|
90
|
+
m.status = {
|
|
91
|
+
...base, state: "connected", account: r.account ?? null, interval: r.interval || DEFAULT_INTERVAL, lastSeen: new Date().toISOString(),
|
|
92
|
+
claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(cloud.url, r.claimCode), workspaces: list.length,
|
|
112
93
|
};
|
|
94
|
+
// Already claimed: the roots follow at once.
|
|
95
|
+
if (claimed())
|
|
96
|
+
await heartbeat();
|
|
113
97
|
return true;
|
|
114
98
|
}
|
|
115
99
|
catch (err) {
|
|
116
|
-
|
|
100
|
+
m.registered = false;
|
|
101
|
+
m.status = { ...m.status, ...base, state: "error", error: err.message };
|
|
117
102
|
return false;
|
|
118
103
|
}
|
|
119
104
|
}
|
|
120
105
|
async function heartbeat() {
|
|
121
|
-
|
|
122
|
-
if (!c.identity || c.status.state === "off")
|
|
106
|
+
if (!m.creds || !m.registered)
|
|
123
107
|
return;
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
...c.identity,
|
|
128
|
-
version: c.selfVersion,
|
|
129
|
-
...(rosterNow !== c.lastRoster ? { agents, channels } : {}),
|
|
130
|
-
};
|
|
108
|
+
const wasClaimed = claimed();
|
|
109
|
+
const { list } = await entries(wasClaimed);
|
|
110
|
+
const now = JSON.stringify(list);
|
|
131
111
|
try {
|
|
132
|
-
const r = await
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
...
|
|
112
|
+
const r = await machineHeartbeat(m.status.url, m.creds, { version: m.version, ...(now !== m.reported ? { workspaces: list } : {}) });
|
|
113
|
+
m.reported = now;
|
|
114
|
+
m.status = {
|
|
115
|
+
...m.status, state: "connected", error: undefined, interval: r.interval || m.status.interval || DEFAULT_INTERVAL, lastSeen: new Date().toISOString(),
|
|
136
116
|
...(r.account !== undefined ? { account: r.account } : {}),
|
|
137
|
-
...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(
|
|
117
|
+
...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(m.status.url, r.claimCode) } : {}),
|
|
118
|
+
workspaces: list.length,
|
|
138
119
|
};
|
|
120
|
+
// Just claimed: the roots go out now, not a whole interval later.
|
|
121
|
+
if (!wasClaimed && claimed())
|
|
122
|
+
await heartbeat();
|
|
139
123
|
}
|
|
140
124
|
catch (err) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
c.identity = null;
|
|
146
|
-
// Not after a goodbye: a stopped client registers nothing again.
|
|
147
|
-
if (!c.stopped && workspaceOpen())
|
|
125
|
+
m.status = { ...m.status, state: "error", error: err.message };
|
|
126
|
+
if (unknownMachine(err)) {
|
|
127
|
+
m.registered = false;
|
|
128
|
+
if (m.started)
|
|
148
129
|
await register();
|
|
149
130
|
}
|
|
150
131
|
}
|
|
151
132
|
}
|
|
152
|
-
/**
|
|
133
|
+
/** One round: register when not registered, else heartbeat. */
|
|
134
|
+
const round = () => serial(async () => {
|
|
135
|
+
if (!m.started)
|
|
136
|
+
return;
|
|
137
|
+
if (m.registered)
|
|
138
|
+
await heartbeat();
|
|
139
|
+
else
|
|
140
|
+
await register();
|
|
141
|
+
});
|
|
153
142
|
function schedule() {
|
|
154
|
-
|
|
155
|
-
|
|
143
|
+
if (m.timer)
|
|
144
|
+
clearTimeout(m.timer);
|
|
145
|
+
m.timer = null;
|
|
146
|
+
if (!m.started || m.status.state === "off")
|
|
156
147
|
return;
|
|
157
|
-
const seconds =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (c.status.state === "connected" || c.status.state === "error") {
|
|
163
|
-
if (c.identity)
|
|
164
|
-
await heartbeat();
|
|
165
|
-
else
|
|
166
|
-
await register();
|
|
167
|
-
}
|
|
168
|
-
if (!c.stopped && workspaceOpen())
|
|
169
|
-
schedule();
|
|
148
|
+
const seconds = m.status.interval && m.status.interval > 0 ? m.status.interval : DEFAULT_INTERVAL;
|
|
149
|
+
m.timer = setTimeout(async () => {
|
|
150
|
+
m.timer = null;
|
|
151
|
+
await round();
|
|
152
|
+
schedule();
|
|
170
153
|
}, seconds * 1000);
|
|
171
|
-
|
|
154
|
+
m.timer.unref();
|
|
172
155
|
}
|
|
173
156
|
/**
|
|
174
|
-
* Start the client
|
|
175
|
-
*
|
|
176
|
-
* own afterwards.
|
|
157
|
+
* Start the machine client (the daemon, once its workspaces are open).
|
|
158
|
+
* Resolves after the first registration attempt; the heartbeat runs on its
|
|
159
|
+
* own afterwards. A second start in the same process is ignored.
|
|
177
160
|
*/
|
|
178
|
-
export async function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return;
|
|
192
|
-
c.status = { url: cloud.url, state: "connecting", interval: cloud.interval };
|
|
193
|
-
await register();
|
|
194
|
-
if (c.stopped || !workspaceOpen())
|
|
195
|
-
return;
|
|
196
|
-
// Until the cloud answers, retry at the configured cadence; once it does, at what it asked for.
|
|
197
|
-
if (c.status.state !== "connected")
|
|
198
|
-
c.status.interval = cloud.interval;
|
|
161
|
+
export async function startMachineCloud(opts) {
|
|
162
|
+
if (m.started)
|
|
163
|
+
return m.status;
|
|
164
|
+
m.started = true;
|
|
165
|
+
m.owner = opts.owner ?? null;
|
|
166
|
+
m.source = opts.workspaces;
|
|
167
|
+
m.version = opts.version;
|
|
168
|
+
m.status = { url: "", state: "off" };
|
|
169
|
+
// The per-workspace identities of before (one record per workspace) are retired with the machine record.
|
|
170
|
+
await fs.rm(retiredCloudDir(), { recursive: true, force: true }).catch(() => { });
|
|
171
|
+
// The machine's id exists from the daemon's first start on, with the relay still off.
|
|
172
|
+
m.creds = await machineCredentials().catch(() => null);
|
|
173
|
+
await serial(register);
|
|
199
174
|
schedule();
|
|
200
|
-
|
|
201
|
-
/** Stop the heartbeat loop (server close, tests). */
|
|
202
|
-
export function stopCloudSync() {
|
|
203
|
-
const c = client();
|
|
204
|
-
c.stopped = true;
|
|
205
|
-
if (c.timer)
|
|
206
|
-
clearTimeout(c.timer);
|
|
207
|
-
c.timer = null;
|
|
175
|
+
return m.status;
|
|
208
176
|
}
|
|
209
177
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
178
|
+
* Settings changed (`kraftwerk cloud on|off`, a URL): turn the client on or
|
|
179
|
+
* off to match; off says goodbye first. Without a started client (a
|
|
180
|
+
* standalone server) nothing happens.
|
|
213
181
|
*/
|
|
214
|
-
export async function
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
182
|
+
export async function applyMachineCloud() {
|
|
183
|
+
if (!m.started)
|
|
184
|
+
return m.status;
|
|
185
|
+
const cloud = await machineCloud((await entries(false)).ymlUrls);
|
|
186
|
+
if (!cloud.enabled || (m.registered && cloud.url !== m.status.url)) {
|
|
187
|
+
await serial(async () => {
|
|
188
|
+
if (m.registered && m.creds)
|
|
189
|
+
await machineGoodbye(m.status.url, m.creds, m.version).catch(() => { });
|
|
190
|
+
m.registered = false;
|
|
191
|
+
m.reported = "";
|
|
192
|
+
});
|
|
221
193
|
}
|
|
222
|
-
|
|
194
|
+
if (!cloud.enabled) {
|
|
195
|
+
if (m.timer)
|
|
196
|
+
clearTimeout(m.timer);
|
|
197
|
+
m.timer = null;
|
|
198
|
+
m.status = { url: cloud.url, state: "off", source: cloud.source, ...(cloud.reason ? { reason: cloud.reason } : {}) };
|
|
199
|
+
return m.status;
|
|
200
|
+
}
|
|
201
|
+
if (!m.registered)
|
|
202
|
+
await serial(register);
|
|
203
|
+
schedule();
|
|
204
|
+
return m.status;
|
|
205
|
+
}
|
|
206
|
+
/** A round now (the CLI's `login` waits for a claim): registers when needed, else heartbeats. */
|
|
207
|
+
export async function syncMachineCloud() {
|
|
208
|
+
if (!m.started)
|
|
209
|
+
return m.status;
|
|
210
|
+
if (m.status.state === "off")
|
|
211
|
+
return applyMachineCloud();
|
|
212
|
+
await round();
|
|
213
|
+
schedule();
|
|
214
|
+
return m.status;
|
|
223
215
|
}
|
|
224
|
-
/**
|
|
225
|
-
export function
|
|
226
|
-
|
|
227
|
-
|
|
216
|
+
/** Release the claim (`kraftwerk logout`): the cloud hands out a fresh claim code. */
|
|
217
|
+
export async function unlinkMachineCloud() {
|
|
218
|
+
if (!m.started || m.status.state === "off")
|
|
219
|
+
return m.status;
|
|
220
|
+
return serial(async () => {
|
|
221
|
+
if (!m.registered)
|
|
222
|
+
await register();
|
|
223
|
+
if (!m.creds || !m.registered)
|
|
224
|
+
throw new Error(m.status.error ?? "not registered with the cloud");
|
|
225
|
+
const r = await machineUnlink(m.status.url, m.creds, m.version);
|
|
226
|
+
// The next heartbeat sends the list again — without roots now.
|
|
227
|
+
m.reported = "";
|
|
228
|
+
m.status = { ...m.status, account: null, claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(m.status.url, r.claimCode) };
|
|
229
|
+
return m.status;
|
|
230
|
+
});
|
|
228
231
|
}
|
|
229
|
-
/**
|
|
230
|
-
|
|
231
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Stop the client and tell the cloud the machine is going away (bounded:
|
|
234
|
+
* an exit handler waits for this). `owner`: only the client that owner
|
|
235
|
+
* started (a hub stopping in a test leaves another's alone).
|
|
236
|
+
*/
|
|
237
|
+
export async function stopMachineCloud(owner) {
|
|
238
|
+
if (!m.started || (owner !== undefined && owner !== m.owner))
|
|
239
|
+
return;
|
|
240
|
+
m.started = false;
|
|
241
|
+
if (m.timer)
|
|
242
|
+
clearTimeout(m.timer);
|
|
243
|
+
m.timer = null;
|
|
244
|
+
const { creds, registered } = m;
|
|
245
|
+
const url = m.status.url;
|
|
246
|
+
m.registered = false;
|
|
247
|
+
m.reported = "";
|
|
248
|
+
m.status = { ...STANDALONE };
|
|
249
|
+
if (registered && creds && url)
|
|
250
|
+
await machineGoodbye(url, creds, m.version).catch(() => { });
|
|
232
251
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Hub } from "./server.js";
|
|
2
2
|
export declare const runInHub: <T>(hub: Hub, fn: () => T) => T;
|
|
3
3
|
export declare function currentHub(): Hub;
|
|
4
|
-
/** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
|
|
4
|
+
/** API paths that need no workspace: the machine's own (the hub, devices and pairing, the cloud record, the protocol, the socket). */
|
|
5
5
|
export declare const MACHINE_PATHS: RegExp;
|
|
6
6
|
/** Route names callable over the socket without a workspace. */
|
|
7
7
|
export declare const MACHINE_ROUTES: RegExp;
|
|
@@ -12,7 +12,7 @@ export function currentHub() {
|
|
|
12
12
|
throw new Error("no hub here");
|
|
13
13
|
return hub;
|
|
14
14
|
}
|
|
15
|
-
/** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
|
|
16
|
-
export const MACHINE_PATHS = /^\/api\/(hub(\/|$)|pair$|devices(\/|$)|protocol$|ws$)/;
|
|
15
|
+
/** API paths that need no workspace: the machine's own (the hub, devices and pairing, the cloud record, the protocol, the socket). */
|
|
16
|
+
export const MACHINE_PATHS = /^\/api\/(hub(\/|$)|pair$|devices(\/|$)|cloud(\/|$)|protocol$|ws$)/;
|
|
17
17
|
/** Route names callable over the socket without a workspace. */
|
|
18
|
-
export const MACHINE_ROUTES = /^(hub|devices|protocol)\./;
|
|
18
|
+
export const MACHINE_ROUTES = /^(hub|devices|cloud|protocol)\./;
|
package/dist/server/push.js
CHANGED
|
@@ -11,7 +11,7 @@ export function sealPush(keyBase64, content) {
|
|
|
11
11
|
const ct = Buffer.concat([cipher.update(JSON.stringify(content), "utf8"), cipher.final()]);
|
|
12
12
|
return Buffer.concat([nonce, ct, cipher.getAuthTag()]).toString("base64");
|
|
13
13
|
}
|
|
14
|
-
const LABEL = { approval: "Approval", question: "Question", failed: "Failed" };
|
|
14
|
+
const LABEL = { approval: "Approval", question: "Question", failed: "Failed", runner: "Routines" };
|
|
15
15
|
export function contentOf(item, workspace) {
|
|
16
16
|
return {
|
|
17
17
|
title: `${LABEL[item.kind] ?? "Needs you"} · ${workspace.name}`,
|
package/dist/server/relay.d.ts
CHANGED
|
@@ -44,6 +44,19 @@ export interface RelayStatus {
|
|
|
44
44
|
export declare function readRelaySettings(): Promise<RelaySettings | null>;
|
|
45
45
|
/** Turn the relay on or off (creating the machine's key on first use); `url` switches relays. */
|
|
46
46
|
export declare function setRelay(enabled: boolean, url?: string): Promise<RelaySettings>;
|
|
47
|
+
/**
|
|
48
|
+
* The machine's key pair and secret, created (with the relay staying off)
|
|
49
|
+
* when relay.json is missing: the daemon's first start, `kraftwerk login`.
|
|
50
|
+
* The cloud knows the machine by the same id and secret the relay does; a
|
|
51
|
+
* damaged file is refused, never overwritten (it holds the id).
|
|
52
|
+
*/
|
|
53
|
+
export declare function ensureRelayKeys(): Promise<RelaySettings>;
|
|
54
|
+
/** The machine's id (its relay id), public key and secret: what the cloud knows it by. */
|
|
55
|
+
export declare function machineCredentials(): Promise<{
|
|
56
|
+
id: string;
|
|
57
|
+
key: string;
|
|
58
|
+
secret: string;
|
|
59
|
+
}>;
|
|
47
60
|
/** wss://host/api/relay/<role> for a cloud URL. */
|
|
48
61
|
export declare const relayEndpoint: (cloudUrl: string, role: "server" | "client") => string;
|
|
49
62
|
/**
|
package/dist/server/relay.js
CHANGED
|
@@ -3,7 +3,7 @@ import { promises as fs } from "node:fs";
|
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { daemonHandshake, exportKeyPair, generateKeyPair, importKeyPair, relayId, toBase64, fromBase64, } from "../client/relay.js";
|
|
6
|
+
import { daemonHandshake, exportKeyPair, generateKeyPair, importKeyPair, relayId, toBase64, fromBase64, fromBase64Url, } from "../client/relay.js";
|
|
7
7
|
import { CLOUD_DEFAULT_URL } from "../config.js";
|
|
8
8
|
import { dropPushToken } from "../core/devices.js";
|
|
9
9
|
const settingsFile = () => path.join(os.homedir(), ".kraftwerk", "relay.json");
|
|
@@ -36,6 +36,34 @@ export async function setRelay(enabled, url) {
|
|
|
36
36
|
await writeRelaySettings(s);
|
|
37
37
|
return s;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* The machine's key pair and secret, created (with the relay staying off)
|
|
41
|
+
* when relay.json is missing: the daemon's first start, `kraftwerk login`.
|
|
42
|
+
* The cloud knows the machine by the same id and secret the relay does; a
|
|
43
|
+
* damaged file is refused, never overwritten (it holds the id).
|
|
44
|
+
*/
|
|
45
|
+
export async function ensureRelayKeys() {
|
|
46
|
+
const s = await readRelaySettings();
|
|
47
|
+
if (s)
|
|
48
|
+
return s;
|
|
49
|
+
try {
|
|
50
|
+
await fs.access(settingsFile());
|
|
51
|
+
throw new Error(`${settingsFile()} is damaged — fix it or remove it (removing gives this machine a new id)`);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (err.code !== "ENOENT")
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
const pair = await exportKeyPair(await generateKeyPair());
|
|
58
|
+
const fresh = { enabled: false, url: CLOUD_DEFAULT_URL, ...pair, secret: randomBytes(32).toString("base64url") };
|
|
59
|
+
await writeRelaySettings(fresh);
|
|
60
|
+
return fresh;
|
|
61
|
+
}
|
|
62
|
+
/** The machine's id (its relay id), public key and secret: what the cloud knows it by. */
|
|
63
|
+
export async function machineCredentials() {
|
|
64
|
+
const s = await ensureRelayKeys();
|
|
65
|
+
return { id: await relayId(fromBase64Url(s.publicKey)), key: s.publicKey, secret: s.secret };
|
|
66
|
+
}
|
|
39
67
|
/** wss://host/api/relay/<role> for a cloud URL. */
|
|
40
68
|
export const relayEndpoint = (cloudUrl, role) => `${cloudUrl.replace(/\/+$/, "").replace(/^http/, "ws")}/api/relay/${role}`;
|
|
41
69
|
/**
|
package/dist/server/server.d.ts
CHANGED
|
@@ -34,8 +34,9 @@ export interface OpenWorkspace {
|
|
|
34
34
|
* a standalone `kraftwerk ui` exactly one — its default, also reached
|
|
35
35
|
* without a slug, as before. Each open workspace runs in its own Workspace
|
|
36
36
|
* (core/workspace.ts) with its own .env, background jobs (routines, git
|
|
37
|
-
* sync
|
|
38
|
-
* and leaves the others running.
|
|
37
|
+
* sync), registry entry and live state; closing one ends all of that
|
|
38
|
+
* and leaves the others running. The daemon's hub also carries the
|
|
39
|
+
* machine's cloud record (server/cloud.ts), which lists the open workspaces.
|
|
39
40
|
*/
|
|
40
41
|
export interface Hub {
|
|
41
42
|
readonly server: http.Server;
|
package/dist/server/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import http from "node:http";
|
|
|
3
3
|
import net from "node:net";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { setDefaultWorkspace, Workspace } from "../core/workspace.js";
|
|
6
|
-
import {
|
|
6
|
+
import { stopMachineCloud } from "./cloud.js";
|
|
7
7
|
import { disposeAllBackends, disposeWorkspaceBackends } from "../core/chat/sessions.js";
|
|
8
8
|
import { markWorkspaceStopped, registerInstance, registerWorkspace, unregisterInstance } from "../core/instances.js";
|
|
9
9
|
import { startGitSync, stopGitSync } from "../core/git.js";
|
|
@@ -156,8 +156,8 @@ function installExitHandlers() {
|
|
|
156
156
|
// Stamp first: unregistering forgets which workspaces this process served.
|
|
157
157
|
markWorkspaceStopped();
|
|
158
158
|
unregisterInstance();
|
|
159
|
-
// The cloud goodbye is bounded and a no-op
|
|
160
|
-
void
|
|
159
|
+
// The machine's cloud goodbye is bounded and a no-op outside the daemon, so the exit stays prompt.
|
|
160
|
+
void stopMachineCloud().finally(() => process.exit(sig === "SIGINT" ? 130 : 143));
|
|
161
161
|
});
|
|
162
162
|
}
|
|
163
163
|
process.once("exit", (code) => {
|
|
@@ -345,6 +345,9 @@ export async function startHub(opts) {
|
|
|
345
345
|
await hub.close(o.root);
|
|
346
346
|
opts.onChange = notify;
|
|
347
347
|
hubs.delete(hub);
|
|
348
|
+
// The daemon's machine client (started by `kraftwerk daemon`) says goodbye with the hub it reports.
|
|
349
|
+
if (opts.daemon)
|
|
350
|
+
await stopMachineCloud(hub);
|
|
348
351
|
// A connected browser tab (its socket, a keep-alive connection) must not hold the stop open.
|
|
349
352
|
for (const s of [...sockets])
|
|
350
353
|
s.close();
|
|
@@ -408,7 +411,6 @@ export async function startHub(opts) {
|
|
|
408
411
|
// Best-effort, in the background: none of them may hold up (or, failing, break) the open.
|
|
409
412
|
entry.registered = registerInstance(entry.aliasPort ?? port, abs, opts.daemon ? { hub: true, slug, url: entry.url } : {}).catch(() => { });
|
|
410
413
|
void registerWorkspace(abs).catch(() => { });
|
|
411
|
-
void startCloudSync(entry.url, version).catch(() => { });
|
|
412
414
|
}));
|
|
413
415
|
await noteChange();
|
|
414
416
|
return entry;
|
|
@@ -434,8 +436,6 @@ export async function startHub(opts) {
|
|
|
434
436
|
closeWatches();
|
|
435
437
|
disposeWorkspaceBackends();
|
|
436
438
|
disposeWorkspaceDevs();
|
|
437
|
-
await cloudGoodbye();
|
|
438
|
-
stopCloudSync();
|
|
439
439
|
}));
|
|
440
440
|
await entry.registered;
|
|
441
441
|
unregisterInstance(abs);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netnodeag/kraftwerk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"description": "Open-source agentic workspace for teams — where people and AI agents get work done together: agents (Claude Code, Codex, Pi), skills, shared knowledge, repeatable workflows, and deterministic verification",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|