@netnodeag/kraftwerk 0.65.0 → 0.67.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.
Files changed (77) hide show
  1. package/README.md +112 -27
  2. package/dist/acp.js +3 -1
  3. package/dist/cli/daemon.d.ts +14 -0
  4. package/dist/cli/daemon.js +60 -0
  5. package/dist/cli/doctor.js +9 -1
  6. package/dist/cli/init.js +1 -0
  7. package/dist/cli/kraftwerk.js +4 -0
  8. package/dist/cli/ui.d.ts +8 -0
  9. package/dist/cli/ui.js +35 -8
  10. package/dist/cli/workspaces.js +5 -3
  11. package/dist/client/index.d.ts +8 -0
  12. package/dist/client/index.js +2 -1
  13. package/dist/client/live.d.ts +1 -2
  14. package/dist/client/live.js +2 -1
  15. package/dist/client/routes.d.ts +3 -0
  16. package/dist/client/routes.js +3 -0
  17. package/dist/config.d.ts +16 -0
  18. package/dist/config.js +23 -1
  19. package/dist/core/chat/pi.js +2 -1
  20. package/dist/core/chat/sessions.d.ts +1 -1
  21. package/dist/core/chat/sessions.js +1 -1
  22. package/dist/core/chat/state.d.ts +2 -0
  23. package/dist/core/chat/state.js +5 -0
  24. package/dist/core/daemon.d.ts +27 -0
  25. package/dist/core/daemon.js +61 -0
  26. package/dist/core/env.d.ts +16 -0
  27. package/dist/core/env.js +45 -0
  28. package/dist/core/git.d.ts +2 -0
  29. package/dist/core/git.js +20 -4
  30. package/dist/core/instances.d.ts +34 -12
  31. package/dist/core/instances.js +142 -66
  32. package/dist/core/routines.d.ts +6 -0
  33. package/dist/core/routines.js +11 -0
  34. package/dist/core/runner.js +7 -3
  35. package/dist/core/search.d.ts +2 -0
  36. package/dist/core/search.js +1 -0
  37. package/dist/core/vibeables.d.ts +2 -0
  38. package/dist/core/vibeables.js +17 -5
  39. package/dist/core/workspace.d.ts +13 -0
  40. package/dist/core/workspace.js +25 -0
  41. package/dist/server/api/hub.d.ts +25 -0
  42. package/dist/server/api/hub.js +32 -0
  43. package/dist/server/api/index.d.ts +35 -3
  44. package/dist/server/api/index.js +2 -0
  45. package/dist/server/api/socket.d.ts +9 -1
  46. package/dist/server/api/socket.js +27 -7
  47. package/dist/server/api/workspace.d.ts +16 -2
  48. package/dist/server/api/workspace.js +44 -4
  49. package/dist/server/cloud.d.ts +5 -1
  50. package/dist/server/cloud.js +86 -54
  51. package/dist/server/hub-context.d.ts +7 -0
  52. package/dist/server/hub-context.js +18 -0
  53. package/dist/server/live.d.ts +2 -0
  54. package/dist/server/live.js +9 -0
  55. package/dist/server/preview.js +2 -1
  56. package/dist/server/server.d.ts +52 -1
  57. package/dist/server/server.js +283 -52
  58. package/dist/server/target.d.ts +20 -0
  59. package/dist/server/target.js +18 -0
  60. package/package.json +1 -1
  61. package/schema/kraftwerk.schema.json +5 -0
  62. package/web/dist/assets/{dist-10uPVFvu.js → dist-BMZBs9W4.js} +1 -1
  63. package/web/dist/assets/{dist-v6-49xpW.js → dist-BkZKygT6.js} +1 -1
  64. package/web/dist/assets/{dist-Bnxg6V5L.js → dist-CZjwFXw-.js} +1 -1
  65. package/web/dist/assets/{dist-DS1R2xKJ.js → dist-D7BYSDIr.js} +1 -1
  66. package/web/dist/assets/dist-DZLlNafq.js +1 -0
  67. package/web/dist/assets/{dist-BCfXUgtf.js → dist-D_nqqdyh.js} +1 -1
  68. package/web/dist/assets/{dist-nA7q2-h7.js → dist-IO8GeoYh.js} +1 -1
  69. package/web/dist/assets/{dist-hevbMnoj.js → dist-LUKHNpC7.js} +1 -1
  70. package/web/dist/assets/{dist-DnMgiPT1.js → dist-Y0KLvK_g.js} +1 -1
  71. package/web/dist/assets/{dist-C-CclD1E.js → dist-aMqhK50K.js} +1 -1
  72. package/web/dist/assets/{dist-CAWmDGJC.js → dist-jyZ9dxfk.js} +1 -1
  73. package/web/dist/assets/{editor-C8yy8YS9.js → editor-DQouCIUq.js} +3 -3
  74. package/web/dist/assets/index-D57IrV_V.js +229 -0
  75. package/web/dist/index.html +1 -1
  76. package/web/dist/assets/dist-yoh47aAL.js +0 -1
  77. package/web/dist/assets/index-DWS1u71Y.js +0 -229
@@ -20,22 +20,42 @@ import path from "node:path";
20
20
  export class Workspace {
21
21
  root;
22
22
  outputDir;
23
+ /** Its address on this machine (kraftwerk.yml `slug`, else the folder name), when known. */
24
+ slug;
25
+ /** Its `.env` (parsed, not applied — see env.ts); {} when none was given. */
26
+ env;
23
27
  slots = new Map();
24
28
  /** `root` defaults to the parent of the output dir (wrong for nested output dirs — pass it when known). */
25
29
  constructor(opts) {
26
30
  this.outputDir = path.resolve(opts.outputDir);
27
31
  this.root = opts.root ? path.resolve(opts.root) : path.dirname(this.outputDir);
32
+ this.slug = opts.slug;
33
+ this.env = opts.env ?? {};
28
34
  known.add(this);
29
35
  }
30
36
  /** Run `fn` (and everything it starts) in this workspace. */
31
37
  run(fn) {
32
38
  return storage.run(this, fn);
33
39
  }
40
+ /** Closed (no longer served): its state is gone and may not come back — see close(). */
41
+ get closed() {
42
+ return this.isClosed;
43
+ }
44
+ isClosed = false;
34
45
  slot(key, init) {
46
+ // Something still running for a closed workspace (a fetch in flight, a heartbeat) must not bring its state back.
47
+ if (this.isClosed)
48
+ throw new Error("workspace closed");
35
49
  if (!this.slots.has(key))
36
50
  this.slots.set(key, init());
37
51
  return this.slots.get(key);
38
52
  }
53
+ /** No longer served: sweeps over all workspaces (shutdown, the idle reaper) skip it. Its slots are dropped. */
54
+ close() {
55
+ this.isClosed = true;
56
+ known.delete(this);
57
+ this.slots.clear();
58
+ }
39
59
  peek(key) {
40
60
  return this.slots.get(key);
41
61
  }
@@ -48,6 +68,11 @@ export function setDefaultWorkspace(ws) {
48
68
  fallback = ws;
49
69
  return ws;
50
70
  }
71
+ /** Whether the caller's workspace is still served (a background loop checks before its next round); true outside any. */
72
+ export function workspaceOpen() {
73
+ const ws = storage.getStore() ?? fallback;
74
+ return !ws?.closed;
75
+ }
51
76
  export function currentWorkspace() {
52
77
  const ws = storage.getStore() ?? fallback;
53
78
  if (!ws)
@@ -0,0 +1,25 @@
1
+ import * as z from "zod";
2
+ /** What a hub serves (server.ts Hub): the machine's open workspaces, and opening or closing one. Need no workspace. */
3
+ export declare const hubRoutes: (import("./router.js").Route<"hub.close", {
4
+ ok: boolean;
5
+ }, "/api/hub/close", z.ZodObject<{
6
+ root: z.ZodString;
7
+ }, z.core.$strip>> | import("./router.js").Route<"hub.list", {
8
+ daemon: boolean;
9
+ port: number;
10
+ workspaces: {
11
+ slug: string;
12
+ name: string;
13
+ url: string;
14
+ aliasPort?: number | undefined;
15
+ root?: string | undefined;
16
+ rootLabel?: string | undefined;
17
+ }[];
18
+ }, "/api/hub", undefined> | import("./router.js").Route<"hub.open", {
19
+ slug: string;
20
+ name: string;
21
+ url: string;
22
+ aliasPort?: number | undefined;
23
+ }, "/api/hub/open", z.ZodObject<{
24
+ root: z.ZodString;
25
+ }, z.core.$strip>>)[];
@@ -0,0 +1,32 @@
1
+ import * as z from "zod";
2
+ import { tildify } from "../../core/instances.js";
3
+ import { currentHub } from "../hub-context.js";
4
+ import { fail, route } from "./router.js";
5
+ const Root = z.object({ root: z.string().min(1, "root is required") });
6
+ /** What a hub serves (server.ts Hub): the machine's open workspaces, and opening or closing one. Need no workspace. */
7
+ export const hubRoutes = [
8
+ // The folder is this machine's business; a paired device sees names and addresses.
9
+ route({ name: "hub.list", method: "GET", path: "/api/hub", summary: "the workspaces this kraftwerk serves: slug, name, address" }, async (c) => {
10
+ const hub = currentHub();
11
+ return {
12
+ daemon: hub.daemon,
13
+ port: hub.port,
14
+ workspaces: hub.list().map((o) => ({
15
+ slug: o.slug,
16
+ name: o.name,
17
+ url: o.url,
18
+ ...(o.aliasPort ? { aliasPort: o.aliasPort } : {}),
19
+ ...(c.trust.kind === "local" ? { root: o.root, rootLabel: tildify(o.root) } : {}),
20
+ })),
21
+ };
22
+ }),
23
+ route({ name: "hub.open", method: "POST", path: "/api/hub/open", summary: "serve a workspace {root} next to the others", access: "local", body: Root, errors: 400 }, async (c) => {
24
+ const { root } = await c.body();
25
+ const o = await currentHub().open(root);
26
+ return { slug: o.slug, name: o.name, url: o.url, ...(o.aliasPort ? { aliasPort: o.aliasPort } : {}) };
27
+ }),
28
+ route({ name: "hub.close", method: "POST", path: "/api/hub/close", summary: "stop serving a workspace {root}; the others keep running", access: "local", body: Root }, async (c) => {
29
+ const { root } = await c.body();
30
+ return (await currentHub().close(root)) ? { ok: true } : fail(404, "not open here");
31
+ }),
32
+ ];
@@ -337,7 +337,29 @@ export declare const routes: (Route<"agents.addJournal", {
337
337
  }>, "/api/git/pull", undefined> | Route<"git.push", import("./router.js").Reply<{
338
338
  ok: boolean;
339
339
  error?: string;
340
- }>, "/api/git/push", undefined> | Route<"git.status", import("../../core/git.js").GitStatus, "/api/git", undefined> | Route<"knowledge.concept", import("../../okf.js").ConceptDetail, "/api/knowledge/:bundle/concept", undefined> | Route<"knowledge.create", import("../../okf.js").BundleInfo, "/api/knowledge", z.ZodObject<{
340
+ }>, "/api/git/push", undefined> | Route<"git.status", import("../../core/git.js").GitStatus, "/api/git", undefined> | Route<"hub.close", {
341
+ ok: boolean;
342
+ }, "/api/hub/close", z.ZodObject<{
343
+ root: z.ZodString;
344
+ }, z.core.$strip>> | Route<"hub.list", {
345
+ daemon: boolean;
346
+ port: number;
347
+ workspaces: {
348
+ slug: string;
349
+ name: string;
350
+ url: string;
351
+ aliasPort?: number | undefined;
352
+ root?: string | undefined;
353
+ rootLabel?: string | undefined;
354
+ }[];
355
+ }, "/api/hub", undefined> | Route<"hub.open", {
356
+ slug: string;
357
+ name: string;
358
+ url: string;
359
+ aliasPort?: number | undefined;
360
+ }, "/api/hub/open", z.ZodObject<{
361
+ root: z.ZodString;
362
+ }, z.core.$strip>> | Route<"knowledge.concept", import("../../okf.js").ConceptDetail, "/api/knowledge/:bundle/concept", undefined> | Route<"knowledge.create", import("../../okf.js").BundleInfo, "/api/knowledge", z.ZodObject<{
341
363
  name: z.ZodDefault<z.ZodString>;
342
364
  }, z.core.$strip>> | Route<"knowledge.delete", {
343
365
  ok: boolean;
@@ -348,6 +370,7 @@ export declare const routes: (Route<"agents.addJournal", {
348
370
  id: z.ZodDefault<z.ZodString>;
349
371
  }, z.core.$strip>> | Route<"meta.get", {
350
372
  workspaceName: string;
373
+ workspaceSlug: string;
351
374
  workspaceIcon: string;
352
375
  workspaceColor: string;
353
376
  workspaceNamed: boolean;
@@ -458,7 +481,11 @@ export declare const routes: (Route<"agents.addJournal", {
458
481
  runs: import("../../core/runs.js").RunListItem[];
459
482
  }, "/api/runs", undefined> | Route<"runs.stop", {
460
483
  stopped: boolean;
461
- }, "/api/runs/:id/stop", undefined> | Route<"search.agents", import("../../core/search.js").AgentSearch, "/api/search/agents", undefined> | Route<"server.restart", {
484
+ }, "/api/runs/:id/stop", undefined> | Route<"search.agents", {
485
+ workspaces: (import("../../core/search.js").WorkspaceAgents & {
486
+ here?: true;
487
+ })[];
488
+ }, "/api/search/agents", undefined> | Route<"server.restart", {
462
489
  ok: boolean;
463
490
  }, "/api/restart", undefined> | Route<"settings.get", import("../../core/settings.js").SettingsView, "/api/settings", undefined> | Route<"settings.save", import("../../core/settings.js").SettingsView, "/api/settings", z.ZodObject<{
464
491
  name: z.ZodOptional<z.ZodString>;
@@ -554,7 +581,12 @@ export declare const routes: (Route<"agents.addJournal", {
554
581
  ok: boolean;
555
582
  }, "/api/workspaces/forget", z.ZodObject<{
556
583
  root: z.ZodOptional<z.ZodString>;
557
- }, z.core.$strip>> | Route<"workspaces.list", import("../../core/instances.js").WorkspaceDetail[], "/api/workspaces", undefined> | Route<"workspaces.start", import("./router.js").Reply<import("../../core/instances.js").StartResult>, "/api/workspaces/start", z.ZodObject<{
584
+ }, z.core.$strip>> | Route<"workspaces.list", (import("../../core/instances.js").WorkspaceDetail & {
585
+ here?: true;
586
+ })[], "/api/workspaces", undefined> | Route<"workspaces.start", import("./router.js").Reply<import("../../core/instances.js").StartResult & {
587
+ slug?: string;
588
+ here?: true;
589
+ }>, "/api/workspaces/start", z.ZodObject<{
558
590
  root: z.ZodOptional<z.ZodString>;
559
591
  }, z.core.$strip>> | Route<"workspaces.stop", import("./router.js").Reply<{
560
592
  ok: boolean;
@@ -4,6 +4,7 @@ import { attentionRoutes } from "./attention.js";
4
4
  import { channelRoutes } from "./channels.js";
5
5
  import { chatRoutes } from "./chats.js";
6
6
  import { deviceRoutes } from "./devices.js";
7
+ import { hubRoutes } from "./hub.js";
7
8
  import { fileRoutes } from "./files.js";
8
9
  import { gitRoutes } from "./git.js";
9
10
  import { knowledgeRoutes } from "./knowledge.js";
@@ -34,6 +35,7 @@ const domainRoutes = [
34
35
  ...gitRoutes,
35
36
  ...vibeableRoutes,
36
37
  ...deviceRoutes,
38
+ ...hubRoutes,
37
39
  ];
38
40
  const protocolRoute = route({ name: "protocol.get", method: "GET", path: "/api/protocol", summary: "the protocol version and every route", access: "public" }, async () => ({
39
41
  version: PROTOCOL_VERSION,
@@ -1,4 +1,5 @@
1
1
  import type { Workspace } from "../../core/workspace.js";
2
+ import type { Hub } from "../server.js";
2
3
  import type { Trust } from "../trust.js";
3
4
  import type { Socket } from "../ws.js";
4
5
  /**
@@ -6,4 +7,11 @@ import type { Socket } from "../ws.js";
6
7
  * been revoked since it connected): a device whose answer is no longer
7
8
  * `device` is disconnected at once (close code 4401).
8
9
  */
9
- export declare function serveSocket(conn: Socket, workspace: Workspace, version: string, trust: Trust, recheck: () => Promise<Trust>): void;
10
+ /**
11
+ * `workspaceOf` finds the workspace a message is for: its `ws` (a slug), or
12
+ * the connection's own (the one the socket was opened for) when it names
13
+ * none. A daemon's socket opened at its root has no workspace of its own:
14
+ * there, a message without `ws` reaches only the machine's routes (hub,
15
+ * devices, protocol).
16
+ */
17
+ export declare function serveSocket(conn: Socket, workspaceOf: (slug?: string) => Workspace | undefined, version: string, trust: Trust, recheck: () => Promise<Trust>, hub: Hub): void;
@@ -1,6 +1,7 @@
1
1
  import { subscribeChat } from "../../core/chat/sessions.js";
2
2
  import { watch } from "../live.js";
3
3
  import { resolveVibeable, subscribeVibeable } from "../../core/vibeables.js";
4
+ import { MACHINE_ROUTES, runInHub } from "../hub-context.js";
4
5
  import { apiRouter, PROTOCOL_VERSION, routes } from "./index.js";
5
6
  /**
6
7
  * The control plane over one WebSocket (`/api/ws`): every JSON route by
@@ -30,7 +31,14 @@ const RECHECK_MS = 5_000;
30
31
  * been revoked since it connected): a device whose answer is no longer
31
32
  * `device` is disconnected at once (close code 4401).
32
33
  */
33
- export function serveSocket(conn, workspace, version, trust, recheck) {
34
+ /**
35
+ * `workspaceOf` finds the workspace a message is for: its `ws` (a slug), or
36
+ * the connection's own (the one the socket was opened for) when it names
37
+ * none. A daemon's socket opened at its root has no workspace of its own:
38
+ * there, a message without `ws` reaches only the machine's routes (hub,
39
+ * devices, protocol).
40
+ */
41
+ export function serveSocket(conn, workspaceOf, version, trust, recheck, hub) {
34
42
  const trustKey = trust.kind === "device" ? `device:${trust.device.id}` : trust.kind;
35
43
  const stops = new Map();
36
44
  const send = (msg) => conn.send(JSON.stringify(msg));
@@ -72,6 +80,8 @@ export function serveSocket(conn, workspace, version, trust, recheck) {
72
80
  return unsubscribe();
73
81
  stops.set(key, unsubscribe);
74
82
  }
83
+ /** A refusal answered the way the message expects one (its id, watch or sub key). */
84
+ const refuse = (msg, status, error) => send({ id: msg.id, ...(typeof msg.watch === "string" ? { watch: msg.watch } : {}), ...(typeof msg.sub === "string" ? { sub: msg.sub } : {}), status, data: { error }, error });
75
85
  async function handle(msg) {
76
86
  if (typeof msg.call === "string") {
77
87
  const r = await apiRouter.invoke(msg.call, objectOf(msg.input), trust);
@@ -129,17 +139,27 @@ export function serveSocket(conn, workspace, version, trust, recheck) {
129
139
  return send({ error: "invalid JSON" });
130
140
  }
131
141
  // Every message runs in the connection's workspace, whatever started the read; a revoked device's message runs nowhere.
132
- void workspace
133
- .run(async () => {
142
+ const slug = typeof msg.ws === "string" ? msg.ws : undefined;
143
+ const ws = workspaceOf(slug);
144
+ if (msg.ws !== undefined && !ws)
145
+ return refuse(msg, 404, `no workspace "${String(msg.ws)}" here`);
146
+ if (!ws && !(typeof msg.call === "string" && MACHINE_ROUTES.test(msg.call)))
147
+ return refuse(msg, 404, "name a workspace: `ws` on the message");
148
+ const run = (fn) => runInHub(hub, () => (ws ? ws.run(fn) : fn()));
149
+ void run(async () => {
134
150
  if (await stillPaired())
135
151
  await handle(msg);
136
- })
137
- .catch((err) => send({ id: msg.id, error: err.message }));
152
+ }).catch((err) => send({ id: msg.id, error: err.message }));
138
153
  });
139
154
  conn.onClose(() => {
140
155
  clearInterval(watchdog);
141
- for (const fn of stops.values())
142
- fn();
156
+ // A subscription of a workspace that closed meanwhile has nothing left to let go of.
157
+ for (const fn of stops.values()) {
158
+ try {
159
+ fn();
160
+ }
161
+ catch { }
162
+ }
143
163
  stops.clear();
144
164
  });
145
165
  send({ hello: { protocol: PROTOCOL_VERSION, version } });
@@ -1,7 +1,14 @@
1
1
  import * as z from "zod";
2
+ import { type StartResult } from "../../core/instances.js";
3
+ /** A start's answer; `here` (with its slug) when the hub answering serves it now. */
4
+ type StartReply = StartResult & {
5
+ slug?: string;
6
+ here?: true;
7
+ };
2
8
  /** The workspace itself: identity and features, settings, the other workspaces, updates. */
3
9
  export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
4
10
  workspaceName: string;
11
+ workspaceSlug: string;
5
12
  workspaceIcon: string;
6
13
  workspaceColor: string;
7
14
  workspaceNamed: boolean;
@@ -25,7 +32,11 @@ export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
25
32
  publicUrl: string;
26
33
  cloud: import("../cloud.js").CloudStatus;
27
34
  switcher: import("../../config.js").SwitcherEntry[];
28
- }, "/api/meta", undefined> | import("./router.js").Route<"search.agents", import("../../core/search.js").AgentSearch, "/api/search/agents", undefined> | import("./router.js").Route<"server.restart", {
35
+ }, "/api/meta", undefined> | import("./router.js").Route<"search.agents", {
36
+ workspaces: (import("../../core/search.js").WorkspaceAgents & {
37
+ here?: true;
38
+ })[];
39
+ }, "/api/search/agents", undefined> | import("./router.js").Route<"server.restart", {
29
40
  ok: boolean;
30
41
  }, "/api/restart", undefined> | import("./router.js").Route<"settings.get", import("../../core/settings.js").SettingsView, "/api/settings", undefined> | import("./router.js").Route<"settings.save", import("../../core/settings.js").SettingsView, "/api/settings", z.ZodObject<{
31
42
  name: z.ZodOptional<z.ZodString>;
@@ -90,7 +101,9 @@ export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
90
101
  ok: boolean;
91
102
  }, "/api/workspaces/forget", z.ZodObject<{
92
103
  root: z.ZodOptional<z.ZodString>;
93
- }, z.core.$strip>> | import("./router.js").Route<"workspaces.list", import("../../core/instances.js").WorkspaceDetail[], "/api/workspaces", undefined> | import("./router.js").Route<"workspaces.start", import("./router.js").Reply<import("../../core/instances.js").StartResult>, "/api/workspaces/start", z.ZodObject<{
104
+ }, z.core.$strip>> | import("./router.js").Route<"workspaces.list", (import("../../core/instances.js").WorkspaceDetail & {
105
+ here?: true;
106
+ })[], "/api/workspaces", undefined> | import("./router.js").Route<"workspaces.start", import("./router.js").Reply<StartReply>, "/api/workspaces/start", z.ZodObject<{
94
107
  root: z.ZodOptional<z.ZodString>;
95
108
  }, z.core.$strip>> | import("./router.js").Route<"workspaces.stop", import("./router.js").Reply<{
96
109
  ok: boolean;
@@ -99,3 +112,4 @@ export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
99
112
  root: z.ZodOptional<z.ZodString>;
100
113
  url: z.ZodOptional<z.ZodString>;
101
114
  }, z.core.$strip>>)[];
115
+ export {};
@@ -1,10 +1,11 @@
1
1
  import path from "node:path";
2
2
  import * as z from "zod";
3
- import { resolveWorkspace } from "../../config.js";
3
+ import { absolutePath, resolveWorkspace, slugFromFolder, workspaceSlug } from "../../config.js";
4
4
  import { disposeAllBackends } from "../../core/chat/sessions.js";
5
5
  import { cloudStatus } from "../cloud.js";
6
6
  import { workspaceRoot } from "../../core/context.js";
7
7
  import { discoverWorkspaces, forgetWorkspace, listWorkspacesDetailed, startWorkspace, stopWorkspace, tildify } from "../../core/instances.js";
8
+ import { currentHub } from "../hub-context.js";
8
9
  import { searchAgents } from "../../core/search.js";
9
10
  import { getSettings, saveSettings } from "../../core/settings.js";
10
11
  import { canSelfUpdate, startUpdate, updateStatus } from "../../core/update.js";
@@ -32,6 +33,26 @@ const SettingsFields = z.looseObject({
32
33
  vibeables: RootBlock.optional(),
33
34
  projects: RootBlock.optional(),
34
35
  });
36
+ /**
37
+ * Mark the workspaces the hub answering this request serves — or, a daemon,
38
+ * would serve once started (it opens a stopped one next to the others, at
39
+ * <slug>.localhost:<its port>) — as `here`. The UI links those in the form
40
+ * the page itself was reached by: a paired phone on the network cannot
41
+ * resolve <slug>.localhost, so it gets /w/<slug>/ on the address it used.
42
+ */
43
+ function placeInHub(entries) {
44
+ const hub = currentHub();
45
+ const served = new Set(hub.list().map((o) => o.root));
46
+ return entries.map((e) => {
47
+ if (!e.root || !e.slug)
48
+ return e;
49
+ if (served.has(e.root))
50
+ return { ...e, here: true };
51
+ if (hub.daemon && !e.live && e.exists !== false)
52
+ return { ...e, here: true, url: `http://${e.slug}.localhost:${hub.port}` };
53
+ return e;
54
+ });
55
+ }
35
56
  /** The workspace itself: identity and features, settings, the other workspaces, updates. */
36
57
  export const workspaceRoutes = [
37
58
  // ?probe=1 marks a discovery probe from another instance: answer identity
@@ -42,7 +63,7 @@ export const workspaceRoutes = [
42
63
  const name = ws?.config.name ?? (ws ? path.basename(ws.root) : "");
43
64
  const root = ws?.root ?? workspaceRoot();
44
65
  const manual = ws?.config.switcher ?? [];
45
- const discovered = c.query.get("probe") === "1" ? [] : await discoverWorkspaces();
66
+ const discovered = c.query.get("probe") === "1" ? [] : placeInHub(await discoverWorkspaces());
46
67
  // Manual switcher entries keep their configured name/icon; discovered
47
68
  // workspaces that duplicate one (same url, running) only contribute
48
69
  // the live flag. Stopped workspaces never collide — they carry a root,
@@ -56,6 +77,7 @@ export const workspaceRoutes = [
56
77
  ];
57
78
  const identity = {
58
79
  workspaceName: name,
80
+ workspaceSlug: ws ? workspaceSlug(ws) : slugFromFolder(path.basename(root)),
59
81
  workspaceIcon: ws?.config.icon ?? "",
60
82
  workspaceColor: ws?.config.color ?? "",
61
83
  workspaceNamed: !!ws?.config.name,
@@ -88,12 +110,23 @@ export const workspaceRoutes = [
88
110
  }),
89
111
  route({ name: "settings.get", method: "GET", path: "/api/settings", summary: "kraftwerk.yml, parsed, with resolved paths" }, async () => getSettings()),
90
112
  route({ name: "settings.save", method: "PUT", path: "/api/settings", summary: "write the UI-editable subset of kraftwerk.yml", errors: 400, body: SettingsFields }, async (c) => saveSettings((await c.body()))),
91
- route({ name: "workspaces.list", method: "GET", path: "/api/workspaces", summary: "every known workspace incl. this one, with state and counts" }, async () => listWorkspacesDetailed()),
113
+ route({ name: "workspaces.list", method: "GET", path: "/api/workspaces", summary: "every known workspace incl. this one, with state and counts" }, async () => placeInHub(await listWorkspacesDetailed())),
92
114
  // Launches `kraftwerk ui` for a known workspace as a detached process (the switcher's Start button).
93
115
  route({ name: "workspaces.start", method: "POST", path: "/api/workspaces/start", summary: "start a known workspace {root}", errors: 400, body: z.object({ root: z.string().optional() }) }, async (c) => {
94
116
  const { root } = await c.body();
95
117
  if (!root)
96
118
  fail(400, "root required");
119
+ // A daemon opens it itself, next to the workspace asking.
120
+ const hub = currentHub();
121
+ if (hub.daemon) {
122
+ try {
123
+ const o = await hub.open(absolutePath(root));
124
+ return reply(200, { ok: true, url: o.url, slug: o.slug, here: true, live: true, pid: process.pid });
125
+ }
126
+ catch (err) {
127
+ return reply(409, { ok: false, error: err.message });
128
+ }
129
+ }
97
130
  const result = await startWorkspace(root);
98
131
  return reply(result.ok ? 200 : 409, result);
99
132
  }),
@@ -101,6 +134,13 @@ export const workspaceRoutes = [
101
134
  const target = await c.body();
102
135
  if (!target.root && !target.url)
103
136
  fail(400, "root or url required");
137
+ // One the hub answering serves (not the one asking): closed right here.
138
+ const hub = currentHub();
139
+ const open = target.root ? hub.list().find((o) => o.root === absolutePath(target.root)) : undefined;
140
+ if (hub.daemon && open && open.root !== workspaceRoot()) {
141
+ await hub.close(open.root);
142
+ return reply(200, { ok: true });
143
+ }
104
144
  const result = await stopWorkspace(target);
105
145
  return reply(result.ok ? 200 : 409, result);
106
146
  }),
@@ -110,7 +150,7 @@ export const workspaceRoutes = [
110
150
  fail(400, "root required");
111
151
  return { ok: await forgetWorkspace(root) };
112
152
  }),
113
- route({ name: "search.agents", method: "GET", path: "/api/search/agents", summary: "active agents and channels of every reachable workspace (⌘K)" }, async () => searchAgents()),
153
+ route({ name: "search.agents", method: "GET", path: "/api/search/agents", summary: "active agents and channels of every reachable workspace (⌘K)" }, async () => searchAgents().then((r) => ({ workspaces: placeInHub(r.workspaces) }))),
114
154
  route({ name: "update.check", method: "GET", path: "/api/update-check", summary: "the latest version on the npm registry" }, async () => {
115
155
  const name = await getPkgName();
116
156
  const current = await getDiskVersion();
@@ -38,7 +38,7 @@ export declare const cloudStatus: () => CloudStatus;
38
38
  * registration attempt (success or not); the heartbeat loop runs on its
39
39
  * own afterwards.
40
40
  */
41
- export declare function startCloudSync(port: number, version: string): Promise<void>;
41
+ export declare function startCloudSync(url: string, version: string): Promise<void>;
42
42
  /** Stop the heartbeat loop (server close, tests). */
43
43
  export declare function stopCloudSync(): void;
44
44
  /**
@@ -47,3 +47,7 @@ export declare function stopCloudSync(): void;
47
47
  * this, and a slow cloud must not hold the process.
48
48
  */
49
49
  export declare function cloudGoodbye(): Promise<void>;
50
+ /** Every served workspace: stop its heartbeat (the server closes). */
51
+ export declare function stopAllCloudSync(): void;
52
+ /** Every served workspace says goodbye, in parallel (still bounded per workspace). */
53
+ export declare function cloudGoodbyeAll(): Promise<void>;