@runeya/runeya 2.0.68 → 2.0.70

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.
@@ -2555,1440 +2555,1448 @@ var settingsManager = new SettingsManager();
2555
2555
  import { isAbsolute as isAbsolute3 } from "path";
2556
2556
 
2557
2557
  // ../server/src/services/workspace-cwd.ts
2558
- import { isAbsolute as isAbsolute2, join as join9, dirname as dirname4 } from "path";
2559
- async function projectRoot(ref) {
2560
- const project = await projectStore.get(ref);
2561
- if (!project?.orgId) return null;
2562
- return projectPathStore.get(project);
2558
+ import { isAbsolute as isAbsolute2, join as join10, dirname as dirname5 } from "path";
2559
+
2560
+ // ../server/src/services/conversation-store.ts
2561
+ import { randomUUID as randomUUID3 } from "crypto";
2562
+ import { readFile as readFile8, writeFile as writeFile8, rename as rename8, mkdir as mkdir8, chmod as chmod7 } from "fs/promises";
2563
+ import { dirname as dirname4, join as join9 } from "path";
2564
+ import { EventEmitter as EventEmitter2 } from "events";
2565
+ var FILENAME4 = "conversations.private.json";
2566
+ async function localProjectId() {
2567
+ const projects = await projectStore.list().catch(() => []);
2568
+ return projects.find((p) => !p.orgId)?.id ?? null;
2563
2569
  }
2564
- async function resolveWorkspaceCwd(target) {
2565
- const launchDir = localVaultRootParent();
2566
- if (target.serviceId) {
2567
- const service = await serviceStore.get(target.serviceId);
2568
- if (service) {
2569
- const raw = (target.override ?? service.cwd ?? "").trim();
2570
- const projects = await projectStore.list();
2571
- const owner = projects.find((p) => p.serviceIds.includes(service.id));
2572
- const root = owner ? await projectRoot(owner) : null;
2573
- return root ? anchor(root, raw) : raw || ".";
2574
- }
2570
+ var ConversationStore = class {
2571
+ conversations = /* @__PURE__ */ new Map();
2572
+ /** Which vault each conversation was read from, until its project is known. */
2573
+ roots = /* @__PURE__ */ new Map();
2574
+ /**
2575
+ * Les conversations qu'on n'a pas su déchiffrer, par coffre, sous leur forme
2576
+ * de disque.
2577
+ *
2578
+ * Une entrée illisible était simplement ignorée. Tant qu'aucune de ses
2579
+ * voisines ne se chargeait, le fichier n'était jamais réécrit et la donnée
2580
+ * survivait par accident ; dès qu'une seule s'ouvrait, la sauvegarde suivante
2581
+ * réécrivait le fichier avec elle seule — et effaçait les autres. Elles sont
2582
+ * donc gardées ici, telles quelles, et réémises à l'identique.
2583
+ */
2584
+ unreadable = /* @__PURE__ */ new Map();
2585
+ loaded = false;
2586
+ events = new EventEmitter2().setMaxListeners(0);
2587
+ saveQueue = Promise.resolve();
2588
+ snapshotConversation(conv) {
2589
+ return structuredClone(conv);
2575
2590
  }
2576
- if (target.projectId) {
2577
- const root = await projectRoot({ id: target.projectId, orgId: target.orgId });
2578
- const raw = (target.override ?? "").trim();
2579
- return root ? anchor(root, raw) : raw || launchDir;
2591
+ /**
2592
+ * Encrypted, so it lives beside the key that opens it — usually the machine
2593
+ * root, but a launch directory owning its own key keeps both together.
2594
+ */
2595
+ getFilePath() {
2596
+ return keyedFilePath(FILENAME4);
2580
2597
  }
2581
- return anchor(launchDir, (target.override ?? "").trim());
2582
- }
2583
- function anchor(base, raw) {
2584
- if (!raw || raw === ".") return base;
2585
- if (raw.startsWith("~") || raw.startsWith("$") || isAbsolute2(raw)) return raw;
2586
- return join9(base, raw);
2587
- }
2588
- function localVaultRootParent() {
2589
- return dirname4(localVaultRoot());
2590
- }
2591
- async function resolveAgentCwd(target) {
2592
- if (target.serviceId) {
2593
- const service = await serviceStore.get(target.serviceId);
2594
- if (service) {
2595
- const projects = await projectStore.list();
2596
- const owner = projects.find((p) => p.serviceIds.includes(service.id));
2597
- const root = owner ? await projectRoot(owner) : null;
2598
- if (!root) return void 0;
2599
- return anchor(root, (target.override ?? service.cwd ?? "").trim());
2600
- }
2598
+ /** Where older versions kept it — read only, so an older Runeya keeps working. */
2599
+ getLegacyFilePath() {
2600
+ return legacyMachineFilePath(FILENAME4);
2601
2601
  }
2602
- if (target.projectId) {
2603
- const root = await projectRoot({ id: target.projectId, orgId: target.orgId });
2604
- if (!root) return void 0;
2605
- return anchor(root, (target.override ?? "").trim());
2602
+ /**
2603
+ * Read the machine file, falling back to the launch directory of old.
2604
+ *
2605
+ * The legacy file is left in place: the next save writes the new location,
2606
+ * and a directory still opened by an older Runeya keeps working meanwhile.
2607
+ */
2608
+ async readFromDisk() {
2609
+ try {
2610
+ return await readFile8(await this.getFilePath(), "utf-8");
2611
+ } catch {
2612
+ return readFile8(this.getLegacyFilePath(), "utf-8");
2613
+ }
2606
2614
  }
2607
- return void 0;
2608
- }
2609
-
2610
- // ../server/src/trpc/routers/service.ts
2611
- async function resolveEffectiveAgentId(service) {
2612
- const known = /* @__PURE__ */ new Set([
2613
- ...agentManager.listAgents().map((a) => a.config.id),
2614
- ...(await agentStore.list()).map((a) => a.id)
2615
- ]);
2616
- if (service.agentId && known.has(service.agentId)) return service.agentId;
2617
- const projects = await projectStore.list();
2618
- const project = projects.find((p) => p.serviceIds.includes(service.id));
2619
- if (project?.activeEnvironmentId) {
2620
- const activeEnv = await environmentStore.get(project.activeEnvironmentId);
2621
- if (activeEnv?.agentId && known.has(activeEnv.agentId)) return activeEnv.agentId;
2615
+ async ensureDir() {
2616
+ await mkdir8(dirname4(await this.getFilePath()), { recursive: true });
2622
2617
  }
2623
- return agentManager.getDefaultAgentId();
2624
- }
2625
- async function resolveServiceVariables(service) {
2626
- const projects = await projectStore.list();
2627
- const project = projects.find((p) => p.serviceIds.includes(service.id));
2628
- let resolvedService = service;
2629
- const anchored = await resolveWorkspaceCwd({ serviceId: service.id });
2630
- if (anchored !== (service.cwd ?? "").trim() && isAbsolute3(anchored)) {
2631
- resolvedService = { ...resolvedService, resolvedCwd: anchored };
2618
+ encryptMessages(messages, vault) {
2619
+ return encryptValue(JSON.stringify(messages), vault);
2632
2620
  }
2633
- try {
2634
- if (service.parserIds && service.parserIds.length > 0) {
2635
- const settings = await settingsManager.getSettings();
2636
- const allParsers = [...nativeParsers, ...settings.logParsers || []];
2637
- const resolvedParsers = service.parserIds.map((id) => allParsers.find((p) => p.id === id)).filter((p) => p !== void 0);
2638
- resolvedService = { ...resolvedService, resolvedLogParsers: resolvedParsers };
2639
- }
2640
- } catch (err) {
2641
- console.error("[service-router] Failed to resolve parsers for service", service.id + ":", err.message);
2621
+ decryptMessages(encrypted, vault) {
2622
+ const json = decryptValue(encrypted, vault);
2623
+ return JSON.parse(json);
2642
2624
  }
2643
- if (!project || !project.activeEnvironmentId) {
2644
- return resolvedService;
2625
+ /**
2626
+ * Refuser d'écrire dans le coffre d'une organisation sans nommer sa clé.
2627
+ *
2628
+ * `getEncryptionKey(undefined)` rend la clé machine, et c'est le bon défaut
2629
+ * pour le coffre local. Appliqué à un fichier rangé sous `orgs/<org>/`, il
2630
+ * produit un chiffré que ce coffre n'emploie pas — lisible par personne à la
2631
+ * relecture, et impossible à distinguer d'une corruption.
2632
+ *
2633
+ * Le cas a été observé plusieurs fois sans qu'on parvienne à nommer le
2634
+ * chemin fautif : les garde-fous en place traitent le coffre fermé, le
2635
+ * coffre mal résolu et la copie périmée, aucun ne couvrait « coffre non
2636
+ * précisé ». Ces écritures-là n'ont jamais fonctionné — elles échouaient en
2637
+ * silence, ce qui est pire. On les saute désormais en le disant, avec la
2638
+ * pile d'appels qui manquait pour remonter au chemin fautif.
2639
+ */
2640
+ isVaultNamed(root, vault) {
2641
+ const orgId = orgIdOfRoot(root);
2642
+ if (!orgId || vault) return true;
2643
+ console.error(
2644
+ `[conversation-store] \xC9criture ignor\xE9e dans le coffre de l'organisation ${orgId} (${root}) : aucune cl\xE9 d'organisation nomm\xE9e, le contenu serait chiffr\xE9 par la cl\xE9 machine et illisible \xE0 la relecture.
2645
+ ${new Error("trace").stack}`
2646
+ );
2647
+ return false;
2645
2648
  }
2646
- try {
2647
- const resolved = await environmentResolver.resolveForService({
2648
- serviceId: service.id,
2649
- activeEnvironmentId: project.activeEnvironmentId
2650
- });
2651
- const serviceBuiltins = {
2652
- HOST: env.HOST,
2653
- HOST_LAN: "0.0.0.0"
2649
+ toDisk(conv, vault) {
2650
+ return {
2651
+ id: conv.id,
2652
+ projectId: conv.projectId,
2653
+ title: conv.title,
2654
+ encryptedMessages: this.encryptMessages(conv.messages, vault),
2655
+ isCli: conv.isCli,
2656
+ runner: conv.runner,
2657
+ providerId: conv.providerId,
2658
+ codexThreadId: conv.codexThreadId,
2659
+ contextUsage: conv.contextUsage,
2660
+ workflowId: conv.workflowId,
2661
+ workflowType: conv.workflowType,
2662
+ kanbanBoardId: conv.kanbanBoardId,
2663
+ kanbanCardId: conv.kanbanCardId,
2664
+ archived: conv.archived,
2665
+ createdAt: conv.createdAt,
2666
+ updatedAt: conv.updatedAt
2654
2667
  };
2655
- const interpolatedEnv = {};
2656
- for (const [key, value] of Object.entries(resolved.env)) {
2657
- interpolatedEnv[key] = value.replace(
2658
- /\{\{service\.([^}]+)\}\}/g,
2659
- (match, k) => serviceBuiltins[k] !== void 0 ? serviceBuiltins[k] : match
2660
- );
2661
- }
2662
- resolvedService = {
2663
- ...resolvedService,
2664
- env: interpolatedEnv,
2665
- envSecrets: [...resolved.envSecrets]
2668
+ }
2669
+ fromDisk(disk, vault) {
2670
+ const workflowType = (disk.workflowType === "scenario" ? "scenario" : void 0) ?? (disk.scenarioId ? "scenario" : void 0);
2671
+ return {
2672
+ id: disk.id,
2673
+ projectId: disk.projectId,
2674
+ title: disk.title,
2675
+ messages: this.decryptMessages(disk.encryptedMessages, vault),
2676
+ isCli: disk.isCli ?? disk.isClaudeCode ?? false,
2677
+ runner: disk.runner,
2678
+ providerId: disk.providerId,
2679
+ codexThreadId: disk.codexThreadId,
2680
+ contextUsage: disk.contextUsage,
2681
+ workflowId: disk.workflowId ?? disk.scenarioId,
2682
+ workflowType,
2683
+ kanbanBoardId: disk.kanbanBoardId,
2684
+ kanbanCardId: disk.kanbanCardId,
2685
+ archived: disk.archived,
2686
+ createdAt: disk.createdAt,
2687
+ updatedAt: disk.updatedAt
2666
2688
  };
2667
- } catch (err) {
2668
- console.error("[service-router] Failed to resolve variables for service", service.id + ":", err.message);
2669
2689
  }
2670
- return resolvedService;
2671
- }
2672
- var DockerConfigInput = DockerConfigSchema;
2673
- var serviceRouter = router({
2674
- list: protectedProcedure.meta({ openapi: { method: "GET", path: "/services", tags: ["services"], summary: "List all services", protect: true } }).output(z4.any()).query(async ({ ctx }) => {
2675
- const all = await serviceStore.list();
2676
- const allowedIds = getAllowedServiceIds(ctx);
2677
- const filtered = allowedIds ? all.filter((s) => allowedIds.has(s.id)) : all;
2678
- return Promise.all(filtered.map(async (s) => ({
2679
- ...s,
2680
- effectiveAgentId: await resolveEffectiveAgentId(s)
2681
- })));
2682
- }),
2683
- get: protectedProcedure.meta({ openapi: { method: "GET", path: "/services/{id}", tags: ["services"], summary: "Get a service by ID", protect: true } }).input(z4.object({ id: z4.string().max(128) })).output(z4.any()).query(async ({ input, ctx }) => {
2684
- requireServiceAction(ctx, input.id, "service:read");
2685
- const service = await serviceStore.get(input.id);
2686
- if (!service) {
2687
- throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
2690
+ /**
2691
+ * Read every project vault, then the homes older versions used.
2692
+ *
2693
+ * A conversation found in a legacy file is attributed to the local project of
2694
+ * that directory which is what it was about, back when one directory meant
2695
+ * one project. With no local project to point at, it is left unattributed
2696
+ * rather than filed under a project picked at random.
2697
+ */
2698
+ async load() {
2699
+ if (this.loaded) return;
2700
+ if (!getEncryptionKey()) {
2701
+ return;
2688
2702
  }
2689
- return service;
2690
- }),
2691
- create: protectedProcedure.meta({ openapi: { method: "POST", path: "/services", tags: ["services"], summary: "Create a new service", protect: true } }).output(z4.any()).input(
2692
- z4.object({
2693
- agentId: z4.string().max(128).optional(),
2694
- name: z4.string().min(1).max(200),
2695
- commands: z4.array(ServiceCommandSchema).max(50).optional(),
2696
- cwd: z4.string().max(4096).optional(),
2697
- ports: z4.array(z4.number().int().min(1).max(65535)).max(20).optional(),
2698
- groups: z4.array(z4.string().max(200)).max(20).optional(),
2699
- description: z4.string().max(2e3).optional(),
2700
- docsPath: z4.string().max(4096).optional(),
2701
- url: z4.string().max(2048).optional(),
2702
- localUrls: z4.array(z4.object({
2703
- url: z4.string().max(253),
2704
- port: z4.number().int().min(1).max(65535)
2705
- })).max(20).optional(),
2706
- openapiUrl: z4.string().max(2048).optional(),
2707
- git: z4.object({ remote: z4.string().max(2048).optional(), home: z4.string().max(2048).optional() }).optional(),
2708
- meta: z4.record(z4.string().max(256), z4.string().max(2e3)).optional().refine((obj) => !obj || Object.keys(obj).length <= 100, { message: "Too many meta keys (max 100)" }),
2709
- runner: z4.enum(["native", "docker"]).optional().default("native"),
2710
- dockerConfig: DockerConfigInput.optional(),
2711
- healthCheck: HealthCheckSchema.optional(),
2712
- autoRestart: z4.boolean().optional().default(false),
2713
- restartStrategy: RestartStrategySchema.optional(),
2714
- maxRestarts: z4.number().int().nonnegative().optional(),
2715
- restartBackoffMs: z4.number().int().positive().optional().default(1e3),
2716
- orgId: z4.string().max(128).nullable().optional(),
2717
- shortcuts: z4.array(ServiceShortcutSchema).max(50).optional(),
2718
- parserIds: z4.array(z4.string().max(128)).max(50).optional()
2719
- })
2720
- ).mutation(async ({ input }) => {
2721
- if (input.agentId) {
2722
- const status = agentManager.getStatus(input.agentId);
2723
- if (!status) {
2724
- throw new TRPCError4({
2725
- code: "NOT_FOUND",
2726
- message: `Agent ${input.agentId} not found`
2727
- });
2703
+ this.loaded = true;
2704
+ this.unreadable.clear();
2705
+ await ensureProjectsLoaded();
2706
+ const vaults = await allVaultRoots();
2707
+ const legacyPaths = [this.getLegacyFilePath(), await keyedFilePath(FILENAME4)];
2708
+ const sources = [
2709
+ ...legacyPaths.map((path) => ({ path, legacy: true })),
2710
+ ...vaults.map((root) => ({ path: join9(root, FILENAME4), legacy: false }))
2711
+ ];
2712
+ for (const { path, legacy } of sources) {
2713
+ let data;
2714
+ try {
2715
+ data = JSON.parse(await readFile8(path, "utf-8"));
2716
+ } catch {
2717
+ continue;
2728
2718
  }
2729
- }
2730
- if (input.runner === "docker") {
2731
- throw new TRPCError4({ code: "BAD_REQUEST", message: 'Docker runner is not yet supported. Use "native" runner.' });
2732
- }
2733
- const service = await serviceStore.create({
2734
- ...input,
2735
- agentId: input.agentId
2736
- });
2737
- await auditService.log({ userId: "local", action: "create", entityType: "service", entityId: service.id, metadata: { name: input.name } });
2738
- return service;
2739
- }),
2740
- update: protectedProcedure.meta({ openapi: { method: "PATCH", path: "/services/{id}", tags: ["services"], summary: "Update a service", protect: true } }).output(z4.any()).input(
2741
- z4.object({
2742
- id: z4.string().max(128),
2743
- agentId: z4.string().max(128).nullable().optional(),
2744
- // null = clear (inherit from env)
2745
- name: z4.string().min(1).max(200).optional(),
2746
- commands: z4.array(ServiceCommandSchema).max(50).optional(),
2747
- cwd: z4.string().max(4096).nullable().optional(),
2748
- ports: z4.array(z4.number().int().min(1).max(65535)).max(20).optional(),
2749
- groups: z4.array(z4.string().max(200)).max(20).optional(),
2750
- description: z4.string().max(2e3).optional(),
2751
- docsPath: z4.string().max(4096).optional(),
2752
- url: z4.string().max(2048).optional(),
2753
- localUrls: z4.array(z4.object({
2754
- url: z4.string().max(253),
2755
- port: z4.number().int().min(1).max(65535)
2756
- })).max(20).optional(),
2757
- openapiUrl: z4.string().max(2048).optional(),
2758
- git: z4.object({ remote: z4.string().max(2048).optional(), home: z4.string().max(2048).optional() }).nullable().optional(),
2759
- meta: z4.record(z4.string().max(256), z4.string().max(2e3)).nullable().optional().refine((obj) => !obj || Object.keys(obj).length <= 100, { message: "Too many meta keys (max 100)" }),
2760
- runner: z4.enum(["native", "docker"]).optional(),
2761
- dockerConfig: DockerConfigInput.optional(),
2762
- healthCheck: HealthCheckSchema.nullable().optional(),
2763
- autoRestart: z4.boolean().optional(),
2764
- clearLogsOnStart: z4.boolean().optional(),
2765
- restartStrategy: RestartStrategySchema.optional(),
2766
- maxRestarts: z4.number().int().nonnegative().optional(),
2767
- restartBackoffMs: z4.number().int().positive().optional(),
2768
- orgId: z4.string().max(128).nullable().optional(),
2769
- shortcuts: z4.array(ServiceShortcutSchema).max(50).optional(),
2770
- parserIds: z4.array(z4.string().max(128)).max(50).optional(),
2771
- logSources: z4.array(LogSourceConfigSchema).max(20).optional()
2772
- })
2773
- ).mutation(async ({ input }) => {
2774
- const existing = await serviceStore.get(input.id);
2775
- if (!existing) {
2776
- throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
2777
- }
2778
- let stoppedForAgentChange = false;
2779
- if (input.agentId !== void 0 && input.agentId !== existing.agentId && existing.agentId) {
2780
- const oldAgentStatus = agentManager.getStatus(existing.agentId);
2781
- if (oldAgentStatus?.connected) {
2719
+ const inherited = legacy ? await localProjectId() : null;
2720
+ const root = dirname4(path);
2721
+ await rememberFile(path);
2722
+ for (const item of data) {
2782
2723
  try {
2783
- const processes = await agentManager.agentQuery(existing.agentId, "process.list");
2784
- const proc = processes.find((p) => p.serviceId === input.id);
2785
- if (proc && (proc.state === "running" || proc.state === "starting")) {
2786
- await agentManager.agentMutation(existing.agentId, "process.stop", { id: input.id }, AGENT_STOP_FETCH_TIMEOUT);
2787
- stoppedForAgentChange = true;
2788
- }
2724
+ const conv = this.fromDisk(item, vaultIdOfRoot(root));
2725
+ if (!conv.projectId && inherited) conv.projectId = inherited;
2726
+ this.conversations.set(item.id, conv);
2727
+ this.roots.set(item.id, root);
2789
2728
  } catch {
2790
- }
2791
- }
2792
- if (input.agentId) {
2793
- const newAgentStatus = agentManager.getStatus(input.agentId);
2794
- if (!newAgentStatus) {
2795
- throw new TRPCError4({
2796
- code: "NOT_FOUND",
2797
- message: `Agent ${input.agentId} not found`
2798
- });
2799
- }
2800
- }
2801
- }
2802
- const service = await serviceStore.update(input);
2803
- if (!service) {
2804
- throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
2805
- }
2806
- if (input.name && input.name !== existing.name) {
2807
- await environmentStore.renameByReference(input.id, input.name);
2808
- }
2809
- if (service.agentId) {
2810
- const agentStatus = agentManager.getStatus(service.agentId);
2811
- if (agentStatus?.connected) {
2812
- try {
2813
- const resolvedService = await resolveServiceVariables(service);
2814
- await agentManager.agentMutation(service.agentId, "process.updateConfig", {
2815
- serviceId: service.id,
2816
- config: resolvedService
2817
- });
2818
- } catch (err) {
2819
- console.debug("[service-router] Failed to propagate config to agent", service.agentId + ":", err.message);
2729
+ const bucket = this.unreadable.get(root) ?? [];
2730
+ bucket.push(item);
2731
+ this.unreadable.set(root, bucket);
2820
2732
  }
2821
2733
  }
2822
2734
  }
2823
- await auditService.log({ userId: "local", action: "update", entityType: "service", entityId: input.id });
2824
- return { ...service, effectiveAgentId: await resolveEffectiveAgentId(service), stoppedForAgentChange };
2825
- }),
2826
- delete: protectedProcedure.meta({ openapi: { method: "DELETE", path: "/services/{id}", tags: ["services"], summary: "Delete a service", protect: true } }).input(z4.object({ id: z4.string().max(128) })).output(z4.object({ success: z4.literal(true) })).mutation(async ({ input }) => {
2827
- const deleted = await serviceStore.delete(input.id);
2828
- if (!deleted) {
2829
- throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
2735
+ }
2736
+ /**
2737
+ * Oublier ce qui a été lu, pour tout relire sous la clé courante.
2738
+ *
2739
+ * Appelé après l'adoption d'une clé d'organisation : le disque vient d'être
2740
+ * re-chiffré sous les pieds du store, si bien que sa mémoire décrit un état
2741
+ * qui n'existe plus. La laisser en place n'affichait pas seulement des
2742
+ * comptes périmés — la sauvegarde suivante réécrivait l'ancien chiffré
2743
+ * par-dessus le nouveau, et défaisait l'adoption.
2744
+ */
2745
+ resetCache() {
2746
+ this.conversations.clear();
2747
+ this.roots.clear();
2748
+ this.unreadable.clear();
2749
+ this.loaded = false;
2750
+ this.events.emit("changed", { type: "reloaded" });
2751
+ }
2752
+ /**
2753
+ * Combien de conversations ce coffre garde sans savoir les lire, par
2754
+ * organisation. La machine elle-même est rangée sous `null`.
2755
+ */
2756
+ async unreadableByVault() {
2757
+ await this.load();
2758
+ const out = /* @__PURE__ */ new Map();
2759
+ for (const [root, items] of this.unreadable) {
2760
+ const vault = vaultIdOfRoot(root) ?? null;
2761
+ out.set(vault, (out.get(vault) ?? 0) + items.length);
2830
2762
  }
2831
- await auditService.log({ userId: "local", action: "delete", entityType: "service", entityId: input.id });
2832
- return { success: true };
2833
- }),
2834
- checkCwd: protectedProcedure.input(z4.object({ id: z4.string().max(128), checkReachability: z4.boolean().optional() })).query(async ({ input }) => {
2835
- const service = await serviceStore.get(input.id);
2836
- if (!service) {
2837
- throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
2763
+ return out;
2764
+ }
2765
+ /**
2766
+ * Write each vault that holds or held a conversation.
2767
+ *
2768
+ * A vault emptied by this save is still written, as an empty list: a
2769
+ * conversation that found its project must not survive in the file it was
2770
+ * read from.
2771
+ */
2772
+ async saveInternal() {
2773
+ if (!getEncryptionKey()) return;
2774
+ const byRoot = /* @__PURE__ */ new Map();
2775
+ for (const root of this.roots.values()) byRoot.set(root, []);
2776
+ for (const conv of this.conversations.values()) {
2777
+ const root = await this.rootFor(conv);
2778
+ this.roots.set(conv.id, root);
2779
+ const bucket = byRoot.get(root) ?? [];
2780
+ const vault = vaultIdOfRoot(root);
2781
+ if (!this.isVaultNamed(root, vault)) continue;
2782
+ bucket.push(this.toDisk(conv, vault));
2783
+ byRoot.set(root, bucket);
2838
2784
  }
2839
- const effectiveAgentId = await resolveEffectiveAgentId(service);
2840
- if (!effectiveAgentId) {
2841
- throw new TRPCError4({ code: "BAD_REQUEST", message: "No agent configured for this service" });
2785
+ for (const [root, items] of this.unreadable) {
2786
+ byRoot.set(root, [...items, ...byRoot.get(root) ?? []]);
2842
2787
  }
2843
- const agentResult = await agentManager.agentQuery(effectiveAgentId, "git.checkCwd", {
2844
- // Anchored on the project, so the check answers about the directory the
2845
- // service will actually run in.
2846
- cwd: await resolveWorkspaceCwd({ serviceId: service.id }),
2847
- remote: service.git?.remote,
2848
- checkReachability: input.checkReachability
2849
- });
2850
- const { exists: exists4, actualRemote, unreachable, resolvedCwd, unresolvedVars } = agentResult;
2851
- if (unresolvedVars.length > 0) {
2852
- return { exists: false, canClone: false, unresolvedVars, resolvedCwd, gitMismatch: null };
2788
+ const stale = /* @__PURE__ */ new Set();
2789
+ for (const root of byRoot.keys()) {
2790
+ const path = join9(root, FILENAME4);
2791
+ if (wasRead(path) && await hasChangedSinceRead(path)) stale.add(root);
2853
2792
  }
2854
- const canClone = !!service.git?.remote && (!exists4 || actualRemote === null);
2855
- let gitMismatch = null;
2856
- if (exists4 && service.git?.remote && !canClone) {
2857
- if (actualRemote === null) {
2858
- gitMismatch = { actual: null, configured: service.git.remote };
2859
- } else if (actualRemote !== service.git.remote) {
2860
- gitMismatch = { actual: actualRemote, configured: service.git.remote };
2861
- } else if (unreachable) {
2862
- gitMismatch = { actual: actualRemote, configured: service.git.remote, unreachable: true };
2793
+ for (const [root, data] of byRoot) {
2794
+ const filePath = join9(root, FILENAME4);
2795
+ if (stale.has(root)) {
2796
+ console.warn(
2797
+ `[conversation-store] ${filePath} a chang\xE9 depuis sa lecture \u2014 \xE9criture ignor\xE9e pour ne pas \xE9craser une r\xE9paration faite en dehors du serveur.`
2798
+ );
2799
+ continue;
2863
2800
  }
2801
+ await mkdir8(root, { recursive: true });
2802
+ const tmpPath = `${filePath}.tmp`;
2803
+ await writeFile8(tmpPath, JSON.stringify(data, null, 2), "utf-8");
2804
+ await rename8(tmpPath, filePath);
2805
+ await chmod7(filePath, 384);
2806
+ await rememberFile(filePath);
2864
2807
  }
2865
- return { exists: exists4, canClone, unresolvedVars: [], resolvedCwd, gitMismatch };
2866
- }),
2867
- cloneRepository: protectedProcedure.input(z4.object({ id: z4.string().max(128) })).mutation(async ({ input }) => {
2868
- const service = await serviceStore.get(input.id);
2869
- if (!service) {
2870
- throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
2871
- }
2872
- if (!service.git?.remote) {
2873
- throw new TRPCError4({ code: "BAD_REQUEST", message: "Service has no git remote configured" });
2874
- }
2875
- const effectiveAgentId = await resolveEffectiveAgentId(service);
2876
- if (!effectiveAgentId) {
2877
- throw new TRPCError4({ code: "BAD_REQUEST", message: "No agent configured for this service" });
2878
- }
2879
- await agentManager.agentMutation(effectiveAgentId, "git.clone", {
2880
- remote: service.git.remote,
2881
- // Clones WRITE: landing this in the launch directory instead of the
2882
- // project's is how a repository ends up in the wrong place.
2883
- cwd: await resolveWorkspaceCwd({ serviceId: service.id })
2884
- }, 5 * 60 * 1e3);
2885
- return { success: true };
2886
- })
2887
- });
2888
-
2889
- // ../server/src/trpc/routers/agent.ts
2890
- import { z as z5 } from "zod";
2891
- import { TRPCError as TRPCError5 } from "@trpc/server";
2892
- var SUBSCRIPTION_QUEUE_MAX = LOG_BUFFER_SIZE;
2893
- var agentRouter = router({
2894
- /** List all registered agents with their connection status */
2895
- list: protectedProcedure.query(() => {
2896
- return agentManager.listAgents();
2897
- }),
2898
- /** Get a single agent's config and status */
2899
- get: protectedProcedure.input(z5.object({ id: z5.string().max(128) })).query(({ input }) => {
2900
- const agent = agentManager.getAgent(input.id);
2901
- if (!agent) {
2902
- throw new TRPCError5({ code: "NOT_FOUND", message: `Agent ${input.id} not found` });
2903
- }
2904
- return {
2905
- config: { ...agent.config, passphrase: "***" },
2906
- status: agent.status
2907
- };
2908
- }),
2909
- /** Test connectivity to an agent given url + passphrase */
2910
- testConnection: protectedProcedure.input(
2911
- z5.object({
2912
- url: z5.string().url().max(2048),
2913
- passphrase: z5.string().min(1).max(1024).optional(),
2914
- agentId: z5.string().max(128).optional()
2915
- })
2916
- ).mutation(async ({ input }) => {
2917
- let passphrase = input.passphrase;
2918
- let targetUrl = input.url;
2919
- if (!passphrase && input.agentId) {
2920
- const agent = agentManager.getAgentConfig(input.agentId);
2921
- if (!agent) {
2922
- throw new TRPCError5({ code: "NOT_FOUND", message: "Agent not found" });
2923
- }
2924
- passphrase = agent.passphrase;
2925
- targetUrl = agent.url;
2926
- }
2927
- if (!passphrase) {
2928
- throw new TRPCError5({ code: "BAD_REQUEST", message: "Passphrase is required" });
2929
- }
2930
- const url = new URL("/api/trpc/health.check", targetUrl);
2931
- try {
2932
- const res = await fetch(url.toString(), {
2933
- headers: { "Authorization": `Bearer ${passphrase}`, "Content-Type": "application/json" },
2934
- signal: AbortSignal.timeout(5e3)
2935
- });
2936
- if (!res.ok) {
2937
- return { success: false, error: `HTTP ${res.status} ${res.statusText}` };
2938
- }
2939
- const json = await res.json();
2940
- if (json.error) {
2941
- return { success: false, error: json.error.message ?? "Agent returned an error" };
2942
- }
2943
- return { success: true, uptime: json.result?.data?.uptime };
2944
- } catch (err) {
2945
- const msg = err instanceof Error ? err.message : "Unknown error";
2946
- return { success: false, error: msg };
2947
- }
2948
- }),
2949
- /** Register a new agent */
2950
- register: protectedProcedure.input(
2951
- z5.object({
2952
- name: z5.string().min(1).max(100),
2953
- url: z5.string().url().max(2048),
2954
- passphrase: z5.string().min(1).max(1024),
2955
- // À quelle organisation rattacher l'agent. Absent = la machine, ce que
2956
- // font l'agent local et tout client antérieur à ce champ.
2957
- orgId: z5.string().min(1).max(128).nullable().optional()
2958
- })
2959
- ).mutation(async ({ input }) => {
2960
- const config = await agentManager.register(
2961
- input.name,
2962
- input.url,
2963
- input.passphrase,
2964
- input.orgId ?? null
2965
- );
2966
- return { ...config, passphrase: "***" };
2967
- }),
2968
- /** Update an existing agent */
2969
- update: protectedProcedure.input(
2970
- z5.object({
2971
- id: z5.string().max(128),
2972
- name: z5.string().min(1).max(100).optional(),
2973
- url: z5.string().url().max(2048).optional(),
2974
- passphrase: z5.string().min(1).max(1024).optional(),
2975
- passthroughEnv: z5.array(
2976
- z5.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(256)
2977
- ).max(100).optional()
2978
- })
2979
- ).mutation(async ({ input }) => {
2980
- try {
2981
- const { id, ...fields } = input;
2982
- const config = await agentManager.update(id, fields);
2983
- return { ...config, passphrase: "***" };
2984
- } catch (err) {
2985
- if (err instanceof Error) {
2986
- if (err.message.includes("not found")) {
2987
- throw new TRPCError5({ code: "NOT_FOUND", message: err.message });
2988
- }
2989
- if (err.message.includes("Cannot modify")) {
2990
- throw new TRPCError5({ code: "FORBIDDEN", message: err.message });
2991
- }
2992
- }
2993
- throw err;
2994
- }
2995
- }),
2996
- /** Get resolved passthrough env values from an agent */
2997
- getPassthroughEnv: protectedProcedure.input(z5.object({ id: z5.string().max(128) })).query(async ({ input }) => {
2808
+ }
2809
+ /**
2810
+ * The vault a conversation is written to: its project's.
2811
+ *
2812
+ * One that names no project stays where it was read, falling back to the
2813
+ * key's own directory never lost for want of an owner.
2814
+ */
2815
+ async rootFor(conv) {
2816
+ const known = this.roots.get(conv.id) ?? null;
2817
+ if (conv.projectId) return rootOfProjectAmong(conv.projectId, known);
2818
+ return known ?? dirname4(await keyedFilePath(FILENAME4));
2819
+ }
2820
+ /**
2821
+ * Le projet d'une conversation, organisation comprise.
2822
+ *
2823
+ * Ce que la conversation ne dit pas d'elle-même : elle ne nomme que l'id.
2824
+ * Le coffre d'où elle vient fournit l'organisation, et c'est ce couple qu'il
2825
+ * faut pour retrouver le répertoire de travail du projet.
2826
+ */
2827
+ async projectRefOf(conv) {
2828
+ if (!conv.projectId) return null;
2829
+ const known = this.roots.get(conv.id) ?? null;
2830
+ const ref = refOfProjectAmong(conv.projectId, known);
2831
+ return { id: ref.id, orgId: ref.orgId ?? null };
2832
+ }
2833
+ /**
2834
+ * Réécrire chaque coffre, en déplaçant ce qui appartient désormais ailleurs.
2835
+ *
2836
+ * Appelé quand un projet a changé de coffre : ses conversations sont écrites
2837
+ * dans le nouveau, et le fichier d'où elles viennent est réécrit sans elles.
2838
+ */
2839
+ async relocateAll() {
2840
+ await this.load();
2841
+ await this.save();
2842
+ }
2843
+ async save() {
2844
+ const write = this.saveQueue.then(() => this.saveInternal());
2845
+ this.saveQueue = write.catch(() => {
2846
+ });
2847
+ return write;
2848
+ }
2849
+ async list() {
2850
+ await this.load();
2851
+ return Array.from(this.conversations.values()).sort((a, b) => b.updatedAt - a.updatedAt);
2852
+ }
2853
+ /**
2854
+ * Les conversations d'un projet.
2855
+ *
2856
+ * Celle qui ne nomme aucun projet — écrite avant le champ, ou qu'une
2857
+ * migration n'a pas su attribuer — n'est rendue que dans les projets de son
2858
+ * propre coffre : elle reste atteignable là où elle a été écrite, sans se
2859
+ * déverser dans toutes les organisations de la machine.
2860
+ */
2861
+ async listForProject(ref) {
2862
+ const belongs = await this.matcherForProject(ref);
2863
+ return (await this.list()).filter(belongs);
2864
+ }
2865
+ /**
2866
+ * Le test d'appartenance à un projet, préparé une fois.
2867
+ *
2868
+ * Le flux temps réel en a besoin autant que la liste : une conversation qui
2869
+ * arrive par événement doit passer le même filtre, sans quoi elle se
2870
+ * réinvite dans un projet qui ne l'a jamais demandée.
2871
+ */
2872
+ async matcherForProject(ref) {
2873
+ await this.load();
2874
+ let projectRoot2 = null;
2998
2875
  try {
2999
- return await agentManager.agentQuery(input.id, "config.getPassthroughEnv");
2876
+ projectRoot2 = ref.orgId === void 0 ? rootOfProjectAmong(ref.id, null) : rootOfProjectRef({ id: ref.id, orgId: ref.orgId ?? null });
3000
2877
  } catch {
3001
- return {};
3002
- }
3003
- }),
3004
- /** Remove a registered agent */
3005
- remove: protectedProcedure.input(z5.object({ id: z5.string().max(128) })).mutation(async ({ input }) => {
3006
- try {
3007
- const removed = await agentManager.remove(input.id);
3008
- if (!removed) {
3009
- throw new TRPCError5({ code: "NOT_FOUND", message: `Agent ${input.id} not found` });
3010
- }
3011
- return { success: true };
3012
- } catch (err) {
3013
- if (err instanceof Error && err.message === "Cannot remove the local agent") {
3014
- throw new TRPCError5({ code: "FORBIDDEN", message: err.message });
3015
- }
3016
- throw err;
3017
- }
3018
- }),
3019
- /** Subscribe to real-time agent status changes */
3020
- onStatus: protectedProcedure.subscription(async function* () {
3021
- for (const agent of agentManager.listAgents()) {
3022
- yield { agentId: agent.config.id, status: agent.status };
2878
+ projectRoot2 = null;
3023
2879
  }
3024
- const queue = [];
3025
- let resolve7 = null;
3026
- const handler = (event) => {
3027
- if (queue.length >= SUBSCRIPTION_QUEUE_MAX) queue.shift();
3028
- queue.push(event);
3029
- if (resolve7) {
3030
- resolve7();
3031
- resolve7 = null;
3032
- }
2880
+ return (conv) => {
2881
+ if (conv.projectId) return conv.projectId === ref.id;
2882
+ if (!projectRoot2) return true;
2883
+ return this.roots.get(conv.id) === projectRoot2;
3033
2884
  };
3034
- agentManager.on("status", handler);
3035
- try {
3036
- while (true) {
3037
- if (queue.length > 0) {
3038
- yield queue.shift();
3039
- } else {
3040
- await new Promise((r) => {
3041
- resolve7 = r;
3042
- });
3043
- }
3044
- }
3045
- } finally {
3046
- agentManager.off("status", handler);
3047
- }
3048
- })
3049
- });
3050
-
3051
- // ../server/src/trpc/routers/process.ts
3052
- import { z as z6 } from "zod";
3053
- import { TRPCError as TRPCError6 } from "@trpc/server";
3054
- var SUBSCRIPTION_QUEUE_MAX2 = LOG_BUFFER_SIZE;
3055
- async function* forwardEvents(eventName, filter) {
3056
- const queue = [];
3057
- let resolve7 = null;
3058
- const handler = (data) => {
3059
- if (filter && !filter(data)) return;
3060
- if (queue.length >= SUBSCRIPTION_QUEUE_MAX2) queue.shift();
3061
- queue.push(data);
3062
- if (resolve7) {
3063
- resolve7();
3064
- resolve7 = null;
2885
+ }
2886
+ async get(id) {
2887
+ await this.load();
2888
+ return this.conversations.get(id) ?? null;
2889
+ }
2890
+ async create(title, isCli = false, workflowId, workflowType, runner, kanbanBoardId, kanbanCardId, providerId, projectId, orgId) {
2891
+ await this.load();
2892
+ const now = Date.now();
2893
+ const conv = {
2894
+ id: randomUUID3(),
2895
+ projectId,
2896
+ title: title ?? "New conversation",
2897
+ messages: [],
2898
+ isCli,
2899
+ runner,
2900
+ providerId,
2901
+ workflowId,
2902
+ workflowType,
2903
+ kanbanBoardId,
2904
+ kanbanCardId,
2905
+ createdAt: now,
2906
+ updatedAt: now
2907
+ };
2908
+ this.conversations.set(conv.id, conv);
2909
+ if (projectId && orgId !== void 0) {
2910
+ this.roots.set(conv.id, vaultFor({ id: projectId, orgId }).root);
3065
2911
  }
3066
- };
3067
- agentManager.on(eventName, handler);
3068
- try {
3069
- while (true) {
3070
- if (queue.length > 0) {
3071
- yield queue.shift();
3072
- } else {
3073
- await new Promise((r) => {
3074
- resolve7 = r;
3075
- });
3076
- }
2912
+ await this.save();
2913
+ this.events.emit("changed", { type: "created", conversation: this.snapshotConversation(conv) });
2914
+ return conv;
2915
+ }
2916
+ async update(id, patch, options) {
2917
+ await this.load();
2918
+ const conv = this.conversations.get(id);
2919
+ if (!conv) return null;
2920
+ if (patch.title !== void 0) conv.title = patch.title;
2921
+ if (patch.messages !== void 0) conv.messages = patch.messages;
2922
+ if (patch.contextUsage !== void 0) conv.contextUsage = patch.contextUsage;
2923
+ if (patch.workflowId !== void 0) conv.workflowId = patch.workflowId;
2924
+ if (patch.archived !== void 0) conv.archived = patch.archived;
2925
+ if (patch.codexThreadId !== void 0) conv.codexThreadId = patch.codexThreadId;
2926
+ if (options?.touch !== false) conv.updatedAt = Date.now();
2927
+ await this.save();
2928
+ if (!options?.silent) {
2929
+ this.events.emit("changed", { type: "updated", conversation: this.snapshotConversation(conv) });
3077
2930
  }
3078
- } finally {
3079
- agentManager.off(eventName, handler);
2931
+ return conv;
3080
2932
  }
3081
- }
3082
- async function* forwardLogBatches(agentId, processId) {
3083
- const queue = [];
3084
- let resolve7 = null;
3085
- const handler = (data) => {
3086
- if (data.agentId !== agentId) return;
3087
- const batch = data.batch;
3088
- if (!Array.isArray(batch)) return;
3089
- const filtered = batch.filter((log) => log.processId === processId);
3090
- if (filtered.length === 0) return;
3091
- if (queue.length >= SUBSCRIPTION_QUEUE_MAX2) queue.shift();
3092
- queue.push(filtered);
3093
- if (resolve7) {
3094
- resolve7();
3095
- resolve7 = null;
3096
- }
3097
- };
3098
- agentManager.on("process:log", handler);
3099
- try {
3100
- while (true) {
3101
- if (queue.length > 0) {
3102
- yield queue.shift();
3103
- } else {
3104
- await new Promise((r) => {
3105
- resolve7 = r;
3106
- });
3107
- }
2933
+ /**
2934
+ * Synchronously update the last assistant message content in memory, then schedule a save.
2935
+ * Used by activeSessionRegistry.persistNow() to avoid the async get() → .then() race.
2936
+ */
2937
+ updateLastAssistantContent(id, content, parts) {
2938
+ const conv = this.conversations.get(id);
2939
+ if (!conv) return;
2940
+ const lastMsg = conv.messages[conv.messages.length - 1];
2941
+ if (!lastMsg || lastMsg.role !== "assistant") return;
2942
+ lastMsg.content = content;
2943
+ if (parts !== void 0) lastMsg.parts = parts;
2944
+ conv.updatedAt = Date.now();
2945
+ this.save().catch(() => {
2946
+ });
2947
+ }
2948
+ async listByKanbanBoardId(boardId) {
2949
+ await this.load();
2950
+ return Array.from(this.conversations.values()).filter((c) => c.kanbanBoardId === boardId);
2951
+ }
2952
+ async listByKanbanCardId(cardId) {
2953
+ await this.load();
2954
+ return Array.from(this.conversations.values()).filter((c) => c.kanbanCardId === cardId);
2955
+ }
2956
+ async delete(id) {
2957
+ await this.load();
2958
+ const existed = this.conversations.delete(id);
2959
+ await this.save();
2960
+ if (existed) {
2961
+ this.events.emit("changed", { type: "deleted", id });
3108
2962
  }
3109
- } finally {
3110
- agentManager.off("process:log", handler);
3111
2963
  }
2964
+ onChanged(handler) {
2965
+ this.events.on("changed", handler);
2966
+ }
2967
+ offChanged(handler) {
2968
+ this.events.off("changed", handler);
2969
+ }
2970
+ };
2971
+ var conversationStore = new ConversationStore();
2972
+
2973
+ // ../server/src/services/workspace-cwd.ts
2974
+ async function projectRoot(ref) {
2975
+ const project = await projectStore.get(ref);
2976
+ if (!project?.orgId) return null;
2977
+ return projectPathStore.get(project);
3112
2978
  }
3113
- var processRouter = router({
3114
- /** List all processes across all connected agents */
3115
- list: protectedProcedure.meta({ openapi: { method: "GET", path: "/processes", tags: ["processes"], summary: "List all processes", protect: true } }).output(z6.any()).query(async () => {
3116
- const agents = agentManager.listAgents();
3117
- const results = [];
3118
- await Promise.allSettled(
3119
- agents.filter((a) => a.status.connected).map(async (a) => {
3120
- try {
3121
- const processes = await agentManager.agentQuery(a.config.id, "process.list");
3122
- for (const proc of processes) {
3123
- results.push({ ...proc, agentId: a.config.id });
3124
- }
3125
- } catch {
3126
- }
3127
- })
3128
- );
3129
- return results;
3130
- }),
3131
- /** Get a single process from a specific agent */
3132
- get: protectedProcedure.meta({ openapi: { method: "GET", path: "/processes/{agentId}/{id}", tags: ["processes"], summary: "Get a process by agent and ID", protect: true } }).input(z6.object({ agentId: z6.string().max(128), id: z6.string().max(128) })).output(z6.any()).query(async ({ input, ctx }) => {
3133
- requireServiceAction(ctx, input.id, "service:read");
3134
- const status = agentManager.getStatus(input.agentId);
3135
- if (!status) {
3136
- throw new TRPCError6({ code: "NOT_FOUND", message: `Agent ${input.agentId} not found` });
3137
- }
3138
- const proc = await agentManager.agentQuery(input.agentId, "process.get", { id: input.id });
3139
- return { ...proc, agentId: input.agentId };
3140
- }),
3141
- /** Start a process on an agent.
3142
- * Lazy deploy: the agent only learns about a service when it is first started
3143
- * (or restarted). This avoids pushing configs for every service at agent boot,
3144
- * which would be wasteful when many services are never started.
3145
- * The deploy call is idempotent — creates the process if missing, updates config if it exists. */
3146
- start: protectedProcedure.meta({ openapi: { method: "POST", path: "/processes/{agentId}/{processId}/start", tags: ["processes"], summary: "Start a process", protect: true } }).input(z6.object({ agentId: z6.string().max(128).optional(), processId: z6.string().max(128) })).output(z6.any()).mutation(async ({ input, ctx }) => {
3147
- requireServiceAction(ctx, input.processId, "service:start");
3148
- const service = await serviceStore.get(input.processId);
3149
- const effectiveAgentId = (service ? await resolveEffectiveAgentId(service) : null) ?? input.agentId ?? null;
3150
- if (!effectiveAgentId) {
3151
- throw new TRPCError6({ code: "BAD_REQUEST", message: "No agent assigned to this service. Set an agent on the service or on the active environment." });
3152
- }
2979
+ async function resolveWorkspaceCwd(target) {
2980
+ const launchDir = localVaultRootParent();
2981
+ if (target.serviceId) {
2982
+ const service = await serviceStore.get(target.serviceId);
3153
2983
  if (service) {
3154
- try {
3155
- const resolved = await resolveServiceVariables(service);
3156
- await agentManager.agentMutation(effectiveAgentId, "process.deploy", resolved);
3157
- } catch (err) {
3158
- console.debug("[process-router] Failed to deploy before start for", input.processId + ":", err.message);
3159
- }
3160
- }
3161
- if (service?.clearLogsOnStart) {
3162
- try {
3163
- await agentManager.agentMutation(effectiveAgentId, "process.clearLogBuffer", { id: input.processId });
3164
- agentManager.emit("process:log", { agentId: effectiveAgentId, batch: [{ processId: input.processId, __clearLogBuffer: true }] });
3165
- } catch (err) {
3166
- console.debug("[process-router] Failed to clear logs before start for", input.processId + ":", err.message);
3167
- }
3168
- }
3169
- let result;
3170
- try {
3171
- result = await agentManager.agentMutation(effectiveAgentId, "process.start", { id: input.processId });
3172
- } catch (err) {
3173
- const err_ = err;
3174
- const isFetchError = err_?.cause?.code === "ECONNREFUSED" || err_?.message === "fetch failed" || err_?.name === "TypeError";
3175
- throw new TRPCError6({
3176
- code: "INTERNAL_SERVER_ERROR",
3177
- message: isFetchError ? `Cannot reach agent \u2014 make sure the agent is running` : err_?.message ?? "Failed to start process"
3178
- });
3179
- }
3180
- await auditService.log({ userId: "local", action: "start", entityType: "process", entityId: input.processId, metadata: { agentId: effectiveAgentId } });
3181
- return { ...result, agentId: effectiveAgentId };
3182
- }),
3183
- /** Stop a process on an agent */
3184
- stop: protectedProcedure.meta({ openapi: { method: "POST", path: "/processes/{agentId}/{processId}/stop", tags: ["processes"], summary: "Stop a process", protect: true } }).input(z6.object({ agentId: z6.string().max(128), processId: z6.string().max(128) })).output(z6.any()).mutation(async ({ input, ctx }) => {
3185
- requireServiceAction(ctx, input.processId, "service:stop");
3186
- const result = await agentManager.agentMutation(input.agentId, "process.stop", { id: input.processId }, AGENT_STOP_FETCH_TIMEOUT);
3187
- await auditService.log({ userId: "local", action: "stop", entityType: "process", entityId: input.processId, metadata: { agentId: input.agentId } });
3188
- return { ...result, agentId: input.agentId };
3189
- }),
3190
- /** Restart a process on an agent.
3191
- * Sends stop → deploy (latest resolved config) → start so that
3192
- * config/variable changes take effect. Same lazy deploy pattern as start. */
3193
- restart: protectedProcedure.meta({ openapi: { method: "POST", path: "/processes/{agentId}/{processId}/restart", tags: ["processes"], summary: "Restart a process", protect: true } }).input(z6.object({ agentId: z6.string().max(128).optional(), processId: z6.string().max(128) })).output(z6.any()).mutation(async ({ input, ctx }) => {
3194
- requireServiceAction(ctx, input.processId, "service:restart");
3195
- const service = await serviceStore.get(input.processId);
3196
- const effectiveAgentId = (service ? await resolveEffectiveAgentId(service) : null) ?? input.agentId ?? null;
3197
- if (!effectiveAgentId) {
3198
- throw new TRPCError6({ code: "BAD_REQUEST", message: "No agent assigned to this service. Set an agent on the service or on the active environment." });
3199
- }
3200
- try {
3201
- await agentManager.agentMutation(effectiveAgentId, "process.stop", { id: input.processId }, AGENT_STOP_FETCH_TIMEOUT);
3202
- } catch (err) {
3203
- console.debug("[process-router] Stop before restart ignored (process not found on agent?):", err.message);
2984
+ const raw = (target.override ?? service.cwd ?? "").trim();
2985
+ const projects = await projectStore.list();
2986
+ const owner = projects.find((p) => p.serviceIds.includes(service.id));
2987
+ const root = owner ? await projectRoot(owner) : null;
2988
+ return root ? anchor(root, raw) : raw || ".";
3204
2989
  }
2990
+ }
2991
+ if (target.projectId) {
2992
+ const root = await projectRoot({ id: target.projectId, orgId: target.orgId });
2993
+ const raw = (target.override ?? "").trim();
2994
+ return root ? anchor(root, raw) : raw || launchDir;
2995
+ }
2996
+ return anchor(launchDir, (target.override ?? "").trim());
2997
+ }
2998
+ function anchor(base, raw) {
2999
+ if (!raw || raw === ".") return base;
3000
+ if (raw.startsWith("~") || raw.startsWith("$") || isAbsolute2(raw)) return raw;
3001
+ return join10(base, raw);
3002
+ }
3003
+ function localVaultRootParent() {
3004
+ return dirname5(localVaultRoot());
3005
+ }
3006
+ async function resolveAgentCwd(target) {
3007
+ if (target.serviceId) {
3008
+ const service = await serviceStore.get(target.serviceId);
3205
3009
  if (service) {
3206
- try {
3207
- const resolved = await resolveServiceVariables(service);
3208
- await agentManager.agentMutation(effectiveAgentId, "process.deploy", resolved);
3209
- } catch (err) {
3210
- console.debug("[process-router] Failed to deploy before restart for", input.processId + ":", err.message);
3211
- }
3010
+ const projects = await projectStore.list();
3011
+ const owner = projects.find((p) => p.serviceIds.includes(service.id));
3012
+ const root = owner ? await projectRoot(owner) : null;
3013
+ if (!root) return void 0;
3014
+ return anchor(root, (target.override ?? service.cwd ?? "").trim());
3212
3015
  }
3213
- if (service?.clearLogsOnStart) {
3214
- try {
3215
- await agentManager.agentMutation(effectiveAgentId, "process.clearLogBuffer", { id: input.processId });
3216
- agentManager.emit("process:log", { agentId: effectiveAgentId, batch: [{ processId: input.processId, __clearLogBuffer: true }] });
3217
- } catch (err) {
3218
- console.debug("[process-router] Failed to clear logs before restart for", input.processId + ":", err.message);
3219
- }
3016
+ }
3017
+ if (target.projectId) {
3018
+ const root = await projectRoot({ id: target.projectId, orgId: target.orgId });
3019
+ if (!root) return void 0;
3020
+ return anchor(root, (target.override ?? "").trim());
3021
+ }
3022
+ return void 0;
3023
+ }
3024
+ async function resolveConversationCwd(conversationId) {
3025
+ const conv = await conversationStore.get(conversationId);
3026
+ const ref = conv ? await conversationStore.projectRefOf(conv) : null;
3027
+ if (!ref) return resolveAgentCwd({});
3028
+ return resolveAgentCwd({ projectId: ref.id, orgId: ref.orgId });
3029
+ }
3030
+
3031
+ // ../server/src/trpc/routers/service.ts
3032
+ async function resolveEffectiveAgentId(service) {
3033
+ const known = /* @__PURE__ */ new Set([
3034
+ ...agentManager.listAgents().map((a) => a.config.id),
3035
+ ...(await agentStore.list()).map((a) => a.id)
3036
+ ]);
3037
+ if (service.agentId && known.has(service.agentId)) return service.agentId;
3038
+ const projects = await projectStore.list();
3039
+ const project = projects.find((p) => p.serviceIds.includes(service.id));
3040
+ if (project?.activeEnvironmentId) {
3041
+ const activeEnv = await environmentStore.get(project.activeEnvironmentId);
3042
+ if (activeEnv?.agentId && known.has(activeEnv.agentId)) return activeEnv.agentId;
3043
+ }
3044
+ return agentManager.getDefaultAgentId();
3045
+ }
3046
+ async function resolveServiceVariables(service) {
3047
+ const projects = await projectStore.list();
3048
+ const project = projects.find((p) => p.serviceIds.includes(service.id));
3049
+ let resolvedService = service;
3050
+ const anchored = await resolveWorkspaceCwd({ serviceId: service.id });
3051
+ if (anchored !== (service.cwd ?? "").trim() && isAbsolute3(anchored)) {
3052
+ resolvedService = { ...resolvedService, resolvedCwd: anchored };
3053
+ }
3054
+ try {
3055
+ if (service.parserIds && service.parserIds.length > 0) {
3056
+ const settings = await settingsManager.getSettings();
3057
+ const allParsers = [...nativeParsers, ...settings.logParsers || []];
3058
+ const resolvedParsers = service.parserIds.map((id) => allParsers.find((p) => p.id === id)).filter((p) => p !== void 0);
3059
+ resolvedService = { ...resolvedService, resolvedLogParsers: resolvedParsers };
3220
3060
  }
3221
- const result = await agentManager.agentMutation(effectiveAgentId, "process.start", { id: input.processId });
3222
- await auditService.log({ userId: "local", action: "restart", entityType: "process", entityId: input.processId, metadata: { agentId: effectiveAgentId } });
3223
- return { ...result, agentId: effectiveAgentId };
3224
- }),
3225
- /** Get buffered logs for a process from an agent */
3226
- logs: protectedProcedure.meta({ openapi: { method: "GET", path: "/processes/{agentId}/{processId}/logs", tags: ["processes"], summary: "Get buffered logs for a process", protect: true } }).input(z6.object({ agentId: z6.string().max(128), processId: z6.string().max(128) })).output(z6.any()).query(async ({ input, ctx }) => {
3227
- requireServiceAction(ctx, input.processId, "logs:read");
3228
- return agentManager.agentQuery(input.agentId, "process.logs", { id: input.processId });
3229
- }),
3230
- /** Clear buffered logs for a process on an agent */
3231
- clearLogBuffer: protectedProcedure.input(z6.object({ agentId: z6.string().max(128), processId: z6.string().max(128) })).mutation(async ({ input }) => {
3232
- await agentManager.agentMutation(input.agentId, "process.clearLogBuffer", { id: input.processId });
3233
- await auditService.log({ userId: "local", action: "clearLogBuffer", entityType: "process", entityId: input.processId, metadata: { agentId: input.agentId } });
3234
- return { success: true };
3061
+ } catch (err) {
3062
+ console.error("[service-router] Failed to resolve parsers for service", service.id + ":", err.message);
3063
+ }
3064
+ if (!project || !project.activeEnvironmentId) {
3065
+ return resolvedService;
3066
+ }
3067
+ try {
3068
+ const resolved = await environmentResolver.resolveForService({
3069
+ serviceId: service.id,
3070
+ activeEnvironmentId: project.activeEnvironmentId
3071
+ });
3072
+ const serviceBuiltins = {
3073
+ HOST: env.HOST,
3074
+ HOST_LAN: "0.0.0.0"
3075
+ };
3076
+ const interpolatedEnv = {};
3077
+ for (const [key, value] of Object.entries(resolved.env)) {
3078
+ interpolatedEnv[key] = value.replace(
3079
+ /\{\{service\.([^}]+)\}\}/g,
3080
+ (match, k) => serviceBuiltins[k] !== void 0 ? serviceBuiltins[k] : match
3081
+ );
3082
+ }
3083
+ resolvedService = {
3084
+ ...resolvedService,
3085
+ env: interpolatedEnv,
3086
+ envSecrets: [...resolved.envSecrets]
3087
+ };
3088
+ } catch (err) {
3089
+ console.error("[service-router] Failed to resolve variables for service", service.id + ":", err.message);
3090
+ }
3091
+ return resolvedService;
3092
+ }
3093
+ var DockerConfigInput = DockerConfigSchema;
3094
+ var serviceRouter = router({
3095
+ list: protectedProcedure.meta({ openapi: { method: "GET", path: "/services", tags: ["services"], summary: "List all services", protect: true } }).output(z4.any()).query(async ({ ctx }) => {
3096
+ const all = await serviceStore.list();
3097
+ const allowedIds = getAllowedServiceIds(ctx);
3098
+ const filtered = allowedIds ? all.filter((s) => allowedIds.has(s.id)) : all;
3099
+ return Promise.all(filtered.map(async (s) => ({
3100
+ ...s,
3101
+ effectiveAgentId: await resolveEffectiveAgentId(s)
3102
+ })));
3235
3103
  }),
3236
- /** Execute a shortcut command on an agent, streaming output into the process log */
3237
- executeShortcut: protectedProcedure.input(z6.object({
3238
- agentId: z6.string().max(128),
3239
- processId: z6.string().max(128),
3240
- shortcut: ServiceShortcutSchema
3241
- })).mutation(async ({ input }) => {
3242
- const service = await serviceStore.get(input.processId);
3243
- if (service) {
3244
- try {
3245
- const resolved = await resolveServiceVariables(service);
3246
- await agentManager.agentMutation(input.agentId, "process.deploy", resolved);
3247
- } catch (err) {
3248
- console.debug("[process-router] Failed to deploy before executeShortcut for", input.processId + ":", err.message);
3249
- }
3104
+ get: protectedProcedure.meta({ openapi: { method: "GET", path: "/services/{id}", tags: ["services"], summary: "Get a service by ID", protect: true } }).input(z4.object({ id: z4.string().max(128) })).output(z4.any()).query(async ({ input, ctx }) => {
3105
+ requireServiceAction(ctx, input.id, "service:read");
3106
+ const service = await serviceStore.get(input.id);
3107
+ if (!service) {
3108
+ throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
3250
3109
  }
3251
- const result = await agentManager.agentMutation(
3252
- input.agentId,
3253
- "process.executeShortcut",
3254
- { id: input.processId, shortcut: input.shortcut },
3255
- AGENT_LONG_FETCH_TIMEOUT
3256
- );
3257
- await auditService.log({
3258
- userId: "local",
3259
- action: "executeShortcut",
3260
- entityType: "process",
3261
- entityId: input.processId,
3262
- metadata: { agentId: input.agentId, shortcutId: input.shortcut.id, shortcutLabel: input.shortcut.label }
3263
- });
3264
- return result;
3110
+ return service;
3265
3111
  }),
3266
- /** Execute an ad-hoc command on an agent, streaming output into the process log */
3267
- executeAdHocCommand: protectedProcedure.input(z6.object({
3268
- agentId: z6.string().max(128),
3269
- processId: z6.string().max(128),
3270
- command: z6.string().min(1).max(4096),
3271
- cwd: z6.string().max(4096).optional(),
3272
- commandId: z6.string().max(128).optional()
3273
- })).mutation(async ({ input }) => {
3274
- const service = await serviceStore.get(input.processId);
3275
- if (service) {
3276
- try {
3277
- const resolved = await resolveServiceVariables(service);
3278
- await agentManager.agentMutation(input.agentId, "process.deploy", resolved);
3279
- } catch (err) {
3280
- console.debug("[process-router] Failed to deploy before executeAdHocCommand for", input.processId + ":", err.message);
3112
+ create: protectedProcedure.meta({ openapi: { method: "POST", path: "/services", tags: ["services"], summary: "Create a new service", protect: true } }).output(z4.any()).input(
3113
+ z4.object({
3114
+ agentId: z4.string().max(128).optional(),
3115
+ name: z4.string().min(1).max(200),
3116
+ commands: z4.array(ServiceCommandSchema).max(50).optional(),
3117
+ cwd: z4.string().max(4096).optional(),
3118
+ ports: z4.array(z4.number().int().min(1).max(65535)).max(20).optional(),
3119
+ groups: z4.array(z4.string().max(200)).max(20).optional(),
3120
+ description: z4.string().max(2e3).optional(),
3121
+ docsPath: z4.string().max(4096).optional(),
3122
+ url: z4.string().max(2048).optional(),
3123
+ localUrls: z4.array(z4.object({
3124
+ url: z4.string().max(253),
3125
+ port: z4.number().int().min(1).max(65535)
3126
+ })).max(20).optional(),
3127
+ openapiUrl: z4.string().max(2048).optional(),
3128
+ git: z4.object({ remote: z4.string().max(2048).optional(), home: z4.string().max(2048).optional() }).optional(),
3129
+ meta: z4.record(z4.string().max(256), z4.string().max(2e3)).optional().refine((obj) => !obj || Object.keys(obj).length <= 100, { message: "Too many meta keys (max 100)" }),
3130
+ runner: z4.enum(["native", "docker"]).optional().default("native"),
3131
+ dockerConfig: DockerConfigInput.optional(),
3132
+ healthCheck: HealthCheckSchema.optional(),
3133
+ autoRestart: z4.boolean().optional().default(false),
3134
+ restartStrategy: RestartStrategySchema.optional(),
3135
+ maxRestarts: z4.number().int().nonnegative().optional(),
3136
+ restartBackoffMs: z4.number().int().positive().optional().default(1e3),
3137
+ orgId: z4.string().max(128).nullable().optional(),
3138
+ shortcuts: z4.array(ServiceShortcutSchema).max(50).optional(),
3139
+ parserIds: z4.array(z4.string().max(128)).max(50).optional()
3140
+ })
3141
+ ).mutation(async ({ input }) => {
3142
+ if (input.agentId) {
3143
+ const status = agentManager.getStatus(input.agentId);
3144
+ if (!status) {
3145
+ throw new TRPCError4({
3146
+ code: "NOT_FOUND",
3147
+ message: `Agent ${input.agentId} not found`
3148
+ });
3281
3149
  }
3282
3150
  }
3283
- const result = await agentManager.agentMutation(
3284
- input.agentId,
3285
- "process.executeAdHocCommand",
3286
- {
3287
- id: input.processId,
3288
- command: input.command,
3289
- // An explicit cwd wins; otherwise the service's directory, anchored on
3290
- // its project rather than on the server's launch directory.
3291
- cwd: input.cwd ?? await resolveAgentCwd({ serviceId: input.processId }),
3292
- commandId: input.commandId
3293
- },
3294
- AGENT_LONG_FETCH_TIMEOUT
3295
- );
3296
- await auditService.log({
3297
- userId: "local",
3298
- action: "executeAdHocCommand",
3299
- entityType: "process",
3300
- entityId: input.processId,
3301
- metadata: { agentId: input.agentId, command: input.command }
3151
+ if (input.runner === "docker") {
3152
+ throw new TRPCError4({ code: "BAD_REQUEST", message: 'Docker runner is not yet supported. Use "native" runner.' });
3153
+ }
3154
+ const service = await serviceStore.create({
3155
+ ...input,
3156
+ agentId: input.agentId
3302
3157
  });
3303
- return result;
3304
- }),
3305
- /** Cancel a running shortcut command on an agent */
3306
- cancelShortcut: protectedProcedure.input(z6.object({
3307
- agentId: z6.string().max(128),
3308
- processId: z6.string().max(128),
3309
- shortcutId: z6.string().max(128)
3310
- })).mutation(async ({ input }) => {
3311
- await agentManager.agentMutation(input.agentId, "process.cancelShortcut", { id: input.processId, shortcutId: input.shortcutId });
3312
- return { success: true };
3313
- }),
3314
- /** Cancel a running ad-hoc command on an agent */
3315
- cancelAdHocCommand: protectedProcedure.input(z6.object({
3316
- agentId: z6.string().max(128),
3317
- processId: z6.string().max(128),
3318
- commandId: z6.string().max(128).optional()
3319
- })).mutation(async ({ input }) => {
3320
- await agentManager.agentMutation(input.agentId, "process.cancelAdHocCommand", { id: input.processId, commandId: input.commandId });
3321
- return { success: true };
3322
- }),
3323
- /** Get current metrics for a process from an agent */
3324
- metrics: protectedProcedure.input(z6.object({ agentId: z6.string().max(128), processId: z6.string().max(128) })).query(async ({ input }) => {
3325
- return agentManager.agentQuery(input.agentId, "process.metrics", { id: input.processId });
3326
- }),
3327
- /**
3328
- * Subscribe to real-time log stream for a process.
3329
- * Forwards batched events from the AgentWSBridge in real-time.
3330
- * Historical logs are fetched separately via process.logs query.
3331
- */
3332
- onLog: protectedProcedure.input(z6.object({ agentId: z6.string().max(128), processId: z6.string().max(128) })).subscription(async function* ({ input, ctx }) {
3333
- requireServiceAction(ctx, input.processId, "logs:read");
3334
- yield* forwardLogBatches(input.agentId, input.processId);
3158
+ await auditService.log({ userId: "local", action: "create", entityType: "service", entityId: service.id, metadata: { name: input.name } });
3159
+ return service;
3335
3160
  }),
3336
- /**
3337
- * Subscribe to real-time status changes for processes.
3338
- * Input is optional — omit agentId/processId for global monitoring (dashboard).
3339
- * Forwards events from the AgentWSBridge in real-time.
3340
- */
3341
- onStatus: protectedProcedure.input(z6.object({
3342
- agentId: z6.string().max(128).optional(),
3343
- processId: z6.string().max(128).optional()
3344
- }).optional()).subscription(async function* ({ input, ctx }) {
3345
- const allowedIds = getAllowedServiceIds(ctx);
3346
- if (input?.processId) requireServiceAction(ctx, input.processId, "service:status");
3347
- try {
3348
- const agents = agentManager.listAgents();
3349
- for (const agent of agents.filter((a) => a.status.connected)) {
3350
- if (input?.agentId && agent.config.id !== input.agentId) continue;
3161
+ update: protectedProcedure.meta({ openapi: { method: "PATCH", path: "/services/{id}", tags: ["services"], summary: "Update a service", protect: true } }).output(z4.any()).input(
3162
+ z4.object({
3163
+ id: z4.string().max(128),
3164
+ agentId: z4.string().max(128).nullable().optional(),
3165
+ // null = clear (inherit from env)
3166
+ name: z4.string().min(1).max(200).optional(),
3167
+ commands: z4.array(ServiceCommandSchema).max(50).optional(),
3168
+ cwd: z4.string().max(4096).nullable().optional(),
3169
+ ports: z4.array(z4.number().int().min(1).max(65535)).max(20).optional(),
3170
+ groups: z4.array(z4.string().max(200)).max(20).optional(),
3171
+ description: z4.string().max(2e3).optional(),
3172
+ docsPath: z4.string().max(4096).optional(),
3173
+ url: z4.string().max(2048).optional(),
3174
+ localUrls: z4.array(z4.object({
3175
+ url: z4.string().max(253),
3176
+ port: z4.number().int().min(1).max(65535)
3177
+ })).max(20).optional(),
3178
+ openapiUrl: z4.string().max(2048).optional(),
3179
+ git: z4.object({ remote: z4.string().max(2048).optional(), home: z4.string().max(2048).optional() }).nullable().optional(),
3180
+ meta: z4.record(z4.string().max(256), z4.string().max(2e3)).nullable().optional().refine((obj) => !obj || Object.keys(obj).length <= 100, { message: "Too many meta keys (max 100)" }),
3181
+ runner: z4.enum(["native", "docker"]).optional(),
3182
+ dockerConfig: DockerConfigInput.optional(),
3183
+ healthCheck: HealthCheckSchema.nullable().optional(),
3184
+ autoRestart: z4.boolean().optional(),
3185
+ clearLogsOnStart: z4.boolean().optional(),
3186
+ restartStrategy: RestartStrategySchema.optional(),
3187
+ maxRestarts: z4.number().int().nonnegative().optional(),
3188
+ restartBackoffMs: z4.number().int().positive().optional(),
3189
+ orgId: z4.string().max(128).nullable().optional(),
3190
+ shortcuts: z4.array(ServiceShortcutSchema).max(50).optional(),
3191
+ parserIds: z4.array(z4.string().max(128)).max(50).optional(),
3192
+ logSources: z4.array(LogSourceConfigSchema).max(20).optional()
3193
+ })
3194
+ ).mutation(async ({ input }) => {
3195
+ const existing = await serviceStore.get(input.id);
3196
+ if (!existing) {
3197
+ throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
3198
+ }
3199
+ let stoppedForAgentChange = false;
3200
+ if (input.agentId !== void 0 && input.agentId !== existing.agentId && existing.agentId) {
3201
+ const oldAgentStatus = agentManager.getStatus(existing.agentId);
3202
+ if (oldAgentStatus?.connected) {
3351
3203
  try {
3352
- const processes = await agentManager.agentQuery(agent.config.id, "process.list");
3353
- for (const proc of processes) {
3354
- if (input?.processId && proc.id !== input.processId) continue;
3355
- if (allowedIds && !allowedIds.has(proc.id)) continue;
3356
- yield { ...proc, agentId: agent.config.id };
3204
+ const processes = await agentManager.agentQuery(existing.agentId, "process.list");
3205
+ const proc = processes.find((p) => p.serviceId === input.id);
3206
+ if (proc && (proc.state === "running" || proc.state === "starting")) {
3207
+ await agentManager.agentMutation(existing.agentId, "process.stop", { id: input.id }, AGENT_STOP_FETCH_TIMEOUT);
3208
+ stoppedForAgentChange = true;
3357
3209
  }
3358
3210
  } catch {
3359
3211
  }
3360
3212
  }
3361
- } catch {
3213
+ if (input.agentId) {
3214
+ const newAgentStatus = agentManager.getStatus(input.agentId);
3215
+ if (!newAgentStatus) {
3216
+ throw new TRPCError4({
3217
+ code: "NOT_FOUND",
3218
+ message: `Agent ${input.agentId} not found`
3219
+ });
3220
+ }
3221
+ }
3362
3222
  }
3363
- yield* forwardEvents("process:status", (d) => {
3364
- if (input?.agentId && d.agentId !== input.agentId) return false;
3365
- if (input?.processId && d.processId !== input.processId) return false;
3366
- if (allowedIds && !allowedIds.has(d.processId)) return false;
3367
- return true;
3368
- });
3223
+ const service = await serviceStore.update(input);
3224
+ if (!service) {
3225
+ throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
3226
+ }
3227
+ if (input.name && input.name !== existing.name) {
3228
+ await environmentStore.renameByReference(input.id, input.name);
3229
+ }
3230
+ if (service.agentId) {
3231
+ const agentStatus = agentManager.getStatus(service.agentId);
3232
+ if (agentStatus?.connected) {
3233
+ try {
3234
+ const resolvedService = await resolveServiceVariables(service);
3235
+ await agentManager.agentMutation(service.agentId, "process.updateConfig", {
3236
+ serviceId: service.id,
3237
+ config: resolvedService
3238
+ });
3239
+ } catch (err) {
3240
+ console.debug("[service-router] Failed to propagate config to agent", service.agentId + ":", err.message);
3241
+ }
3242
+ }
3243
+ }
3244
+ await auditService.log({ userId: "local", action: "update", entityType: "service", entityId: input.id });
3245
+ return { ...service, effectiveAgentId: await resolveEffectiveAgentId(service), stoppedForAgentChange };
3369
3246
  }),
3370
- /** Subscribe to real-time metrics for a process. */
3371
- onMetrics: protectedProcedure.input(z6.object({
3372
- agentId: z6.string().max(128).optional(),
3373
- processId: z6.string().max(128).optional()
3374
- }).optional()).subscription(async function* ({ input, ctx }) {
3375
- const allowedIds = getAllowedServiceIds(ctx);
3376
- if (input?.processId) requireServiceAction(ctx, input.processId, "service:status");
3377
- yield* forwardEvents("process:metrics", (d) => {
3378
- if (input?.agentId && d.agentId !== input.agentId) return false;
3379
- if (input?.processId && d.processId !== input.processId) return false;
3380
- if (allowedIds && !allowedIds.has(d.processId)) return false;
3381
- return true;
3382
- });
3247
+ delete: protectedProcedure.meta({ openapi: { method: "DELETE", path: "/services/{id}", tags: ["services"], summary: "Delete a service", protect: true } }).input(z4.object({ id: z4.string().max(128) })).output(z4.object({ success: z4.literal(true) })).mutation(async ({ input }) => {
3248
+ const deleted = await serviceStore.delete(input.id);
3249
+ if (!deleted) {
3250
+ throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
3251
+ }
3252
+ await auditService.log({ userId: "local", action: "delete", entityType: "service", entityId: input.id });
3253
+ return { success: true };
3383
3254
  }),
3384
- /** Subscribe to real-time health status changes. */
3385
- onHealth: protectedProcedure.input(z6.object({
3386
- agentId: z6.string().max(128).optional(),
3387
- processId: z6.string().max(128).optional()
3388
- }).optional()).subscription(async function* ({ input, ctx }) {
3389
- const allowedIds = getAllowedServiceIds(ctx);
3390
- if (input?.processId) requireServiceAction(ctx, input.processId, "service:healthcheck");
3391
- yield* forwardEvents("process:health", (d) => {
3392
- if (input?.agentId && d.agentId !== input.agentId) return false;
3393
- if (input?.processId && d.processId !== input.processId) return false;
3394
- if (allowedIds && !allowedIds.has(d.processId)) return false;
3395
- return true;
3255
+ checkCwd: protectedProcedure.input(z4.object({ id: z4.string().max(128), checkReachability: z4.boolean().optional() })).query(async ({ input }) => {
3256
+ const service = await serviceStore.get(input.id);
3257
+ if (!service) {
3258
+ throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
3259
+ }
3260
+ const effectiveAgentId = await resolveEffectiveAgentId(service);
3261
+ if (!effectiveAgentId) {
3262
+ throw new TRPCError4({ code: "BAD_REQUEST", message: "No agent configured for this service" });
3263
+ }
3264
+ const agentResult = await agentManager.agentQuery(effectiveAgentId, "git.checkCwd", {
3265
+ // Anchored on the project, so the check answers about the directory the
3266
+ // service will actually run in.
3267
+ cwd: await resolveWorkspaceCwd({ serviceId: service.id }),
3268
+ remote: service.git?.remote,
3269
+ checkReachability: input.checkReachability
3396
3270
  });
3271
+ const { exists: exists4, actualRemote, unreachable, resolvedCwd, unresolvedVars } = agentResult;
3272
+ if (unresolvedVars.length > 0) {
3273
+ return { exists: false, canClone: false, unresolvedVars, resolvedCwd, gitMismatch: null };
3274
+ }
3275
+ const canClone = !!service.git?.remote && (!exists4 || actualRemote === null);
3276
+ let gitMismatch = null;
3277
+ if (exists4 && service.git?.remote && !canClone) {
3278
+ if (actualRemote === null) {
3279
+ gitMismatch = { actual: null, configured: service.git.remote };
3280
+ } else if (actualRemote !== service.git.remote) {
3281
+ gitMismatch = { actual: actualRemote, configured: service.git.remote };
3282
+ } else if (unreachable) {
3283
+ gitMismatch = { actual: actualRemote, configured: service.git.remote, unreachable: true };
3284
+ }
3285
+ }
3286
+ return { exists: exists4, canClone, unresolvedVars: [], resolvedCwd, gitMismatch };
3287
+ }),
3288
+ cloneRepository: protectedProcedure.input(z4.object({ id: z4.string().max(128) })).mutation(async ({ input }) => {
3289
+ const service = await serviceStore.get(input.id);
3290
+ if (!service) {
3291
+ throw new TRPCError4({ code: "NOT_FOUND", message: `Service ${input.id} not found` });
3292
+ }
3293
+ if (!service.git?.remote) {
3294
+ throw new TRPCError4({ code: "BAD_REQUEST", message: "Service has no git remote configured" });
3295
+ }
3296
+ const effectiveAgentId = await resolveEffectiveAgentId(service);
3297
+ if (!effectiveAgentId) {
3298
+ throw new TRPCError4({ code: "BAD_REQUEST", message: "No agent configured for this service" });
3299
+ }
3300
+ await agentManager.agentMutation(effectiveAgentId, "git.clone", {
3301
+ remote: service.git.remote,
3302
+ // Clones WRITE: landing this in the launch directory instead of the
3303
+ // project's is how a repository ends up in the wrong place.
3304
+ cwd: await resolveWorkspaceCwd({ serviceId: service.id })
3305
+ }, 5 * 60 * 1e3);
3306
+ return { success: true };
3397
3307
  })
3398
3308
  });
3399
3309
 
3400
- // ../server/src/trpc/routers/audit.ts
3401
- import { z as z7 } from "zod";
3402
- var auditRouter = router({
3403
- list: protectedProcedure.input(z7.object({
3404
- action: z7.string().max(50).optional(),
3405
- entityType: z7.string().max(50).optional(),
3406
- entityId: z7.string().max(100).optional(),
3407
- limit: z7.number().int().positive().max(100).default(50),
3408
- offset: z7.number().int().nonnegative().default(0)
3409
- }).optional()).query(async ({ input }) => {
3410
- return auditService.list(input ?? {});
3411
- })
3412
- });
3413
-
3414
- // ../server/src/trpc/routers/setup.ts
3415
- import { z as z8 } from "zod";
3416
-
3417
- // ../server/src/services/cloud-session-store.ts
3418
- import { readFile as readFile8, writeFile as writeFile8, rename as rename8, mkdir as mkdir8, chmod as chmod7, unlink as unlink2 } from "fs/promises";
3419
- import { dirname as dirname5 } from "path";
3420
- import { randomBytes as randomBytes4, timingSafeEqual as timingSafeEqual2 } from "crypto";
3421
- var FILENAME4 = "cloud-session.json";
3422
- var STATE_TTL_MS = 5 * 60 * 1e3;
3423
- var CloudSessionStore = class {
3424
- session = null;
3425
- loaded = false;
3426
- /** Pending login states (CSRF binding between `start` and the callback). */
3427
- pending = /* @__PURE__ */ new Map();
3428
- /**
3429
- * Encrypted, so it lives beside the key that opens it — usually the machine
3430
- * root, but a launch directory owning its own key keeps both together.
3431
- */
3432
- getFilePath() {
3433
- return keyedFilePath(FILENAME4);
3434
- }
3435
- /** Where older versions kept it — read only, so an older Runeya keeps working. */
3436
- getLegacyFilePath() {
3437
- return legacyMachineFilePath(FILENAME4);
3438
- }
3439
- /**
3440
- * Read the machine file, falling back to the launch directory of old.
3441
- *
3442
- * The legacy file is left in place: the next save writes the new location,
3443
- * and a directory still opened by an older Runeya keeps working meanwhile.
3444
- */
3445
- async readFromDisk() {
3310
+ // ../server/src/trpc/routers/agent.ts
3311
+ import { z as z5 } from "zod";
3312
+ import { TRPCError as TRPCError5 } from "@trpc/server";
3313
+ var SUBSCRIPTION_QUEUE_MAX = LOG_BUFFER_SIZE;
3314
+ var agentRouter = router({
3315
+ /** List all registered agents with their connection status */
3316
+ list: protectedProcedure.query(() => {
3317
+ return agentManager.listAgents();
3318
+ }),
3319
+ /** Get a single agent's config and status */
3320
+ get: protectedProcedure.input(z5.object({ id: z5.string().max(128) })).query(({ input }) => {
3321
+ const agent = agentManager.getAgent(input.id);
3322
+ if (!agent) {
3323
+ throw new TRPCError5({ code: "NOT_FOUND", message: `Agent ${input.id} not found` });
3324
+ }
3325
+ return {
3326
+ config: { ...agent.config, passphrase: "***" },
3327
+ status: agent.status
3328
+ };
3329
+ }),
3330
+ /** Test connectivity to an agent given url + passphrase */
3331
+ testConnection: protectedProcedure.input(
3332
+ z5.object({
3333
+ url: z5.string().url().max(2048),
3334
+ passphrase: z5.string().min(1).max(1024).optional(),
3335
+ agentId: z5.string().max(128).optional()
3336
+ })
3337
+ ).mutation(async ({ input }) => {
3338
+ let passphrase = input.passphrase;
3339
+ let targetUrl = input.url;
3340
+ if (!passphrase && input.agentId) {
3341
+ const agent = agentManager.getAgentConfig(input.agentId);
3342
+ if (!agent) {
3343
+ throw new TRPCError5({ code: "NOT_FOUND", message: "Agent not found" });
3344
+ }
3345
+ passphrase = agent.passphrase;
3346
+ targetUrl = agent.url;
3347
+ }
3348
+ if (!passphrase) {
3349
+ throw new TRPCError5({ code: "BAD_REQUEST", message: "Passphrase is required" });
3350
+ }
3351
+ const url = new URL("/api/trpc/health.check", targetUrl);
3352
+ try {
3353
+ const res = await fetch(url.toString(), {
3354
+ headers: { "Authorization": `Bearer ${passphrase}`, "Content-Type": "application/json" },
3355
+ signal: AbortSignal.timeout(5e3)
3356
+ });
3357
+ if (!res.ok) {
3358
+ return { success: false, error: `HTTP ${res.status} ${res.statusText}` };
3359
+ }
3360
+ const json = await res.json();
3361
+ if (json.error) {
3362
+ return { success: false, error: json.error.message ?? "Agent returned an error" };
3363
+ }
3364
+ return { success: true, uptime: json.result?.data?.uptime };
3365
+ } catch (err) {
3366
+ const msg = err instanceof Error ? err.message : "Unknown error";
3367
+ return { success: false, error: msg };
3368
+ }
3369
+ }),
3370
+ /** Register a new agent */
3371
+ register: protectedProcedure.input(
3372
+ z5.object({
3373
+ name: z5.string().min(1).max(100),
3374
+ url: z5.string().url().max(2048),
3375
+ passphrase: z5.string().min(1).max(1024),
3376
+ // À quelle organisation rattacher l'agent. Absent = la machine, ce que
3377
+ // font l'agent local et tout client antérieur à ce champ.
3378
+ orgId: z5.string().min(1).max(128).nullable().optional()
3379
+ })
3380
+ ).mutation(async ({ input }) => {
3381
+ const config = await agentManager.register(
3382
+ input.name,
3383
+ input.url,
3384
+ input.passphrase,
3385
+ input.orgId ?? null
3386
+ );
3387
+ return { ...config, passphrase: "***" };
3388
+ }),
3389
+ /** Update an existing agent */
3390
+ update: protectedProcedure.input(
3391
+ z5.object({
3392
+ id: z5.string().max(128),
3393
+ name: z5.string().min(1).max(100).optional(),
3394
+ url: z5.string().url().max(2048).optional(),
3395
+ passphrase: z5.string().min(1).max(1024).optional(),
3396
+ passthroughEnv: z5.array(
3397
+ z5.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(256)
3398
+ ).max(100).optional()
3399
+ })
3400
+ ).mutation(async ({ input }) => {
3446
3401
  try {
3447
- return await readFile8(await this.getFilePath(), "utf-8");
3448
- } catch {
3449
- return readFile8(this.getLegacyFilePath(), "utf-8");
3402
+ const { id, ...fields } = input;
3403
+ const config = await agentManager.update(id, fields);
3404
+ return { ...config, passphrase: "***" };
3405
+ } catch (err) {
3406
+ if (err instanceof Error) {
3407
+ if (err.message.includes("not found")) {
3408
+ throw new TRPCError5({ code: "NOT_FOUND", message: err.message });
3409
+ }
3410
+ if (err.message.includes("Cannot modify")) {
3411
+ throw new TRPCError5({ code: "FORBIDDEN", message: err.message });
3412
+ }
3413
+ }
3414
+ throw err;
3450
3415
  }
3451
- }
3452
- async load() {
3453
- if (this.loaded) return;
3416
+ }),
3417
+ /** Get resolved passthrough env values from an agent */
3418
+ getPassthroughEnv: protectedProcedure.input(z5.object({ id: z5.string().max(128) })).query(async ({ input }) => {
3454
3419
  try {
3455
- const raw = await this.readFromDisk();
3456
- this.session = JSON.parse(raw);
3420
+ return await agentManager.agentQuery(input.id, "config.getPassthroughEnv");
3457
3421
  } catch {
3422
+ return {};
3458
3423
  }
3459
- this.loaded = true;
3460
- }
3461
- /**
3462
- * Mint a state value binding a login attempt to this server. Returned to the
3463
- * cloud page and echoed back on the callback, so a redirect we never
3464
- * initiated cannot install a token.
3465
- */
3466
- createState(returnTo) {
3467
- this.sweepStates();
3468
- const state = randomBytes4(32).toString("hex");
3469
- this.pending.set(state, { expiresAt: Date.now() + STATE_TTL_MS, returnTo });
3470
- return state;
3471
- }
3472
- /**
3473
- * Consume a state — single use, constant-time compare, TTL enforced. Returns
3474
- * the URL to send the browser back to, or null when the state is unknown or
3475
- * expired (i.e. a callback this server never initiated).
3476
- */
3477
- consumeState(candidate) {
3478
- this.sweepStates();
3479
- for (const [state, entry] of this.pending) {
3480
- const a = Buffer.from(state);
3481
- const b = Buffer.from(candidate);
3482
- if (a.length === b.length && timingSafeEqual2(a, b)) {
3483
- this.pending.delete(state);
3484
- return entry.expiresAt > Date.now() ? entry.returnTo : null;
3424
+ }),
3425
+ /** Remove a registered agent */
3426
+ remove: protectedProcedure.input(z5.object({ id: z5.string().max(128) })).mutation(async ({ input }) => {
3427
+ try {
3428
+ const removed = await agentManager.remove(input.id);
3429
+ if (!removed) {
3430
+ throw new TRPCError5({ code: "NOT_FOUND", message: `Agent ${input.id} not found` });
3485
3431
  }
3432
+ return { success: true };
3433
+ } catch (err) {
3434
+ if (err instanceof Error && err.message === "Cannot remove the local agent") {
3435
+ throw new TRPCError5({ code: "FORBIDDEN", message: err.message });
3436
+ }
3437
+ throw err;
3486
3438
  }
3487
- return null;
3488
- }
3489
- sweepStates() {
3490
- const now = Date.now();
3491
- for (const [state, entry] of this.pending) {
3492
- if (entry.expiresAt <= now) this.pending.delete(state);
3493
- }
3494
- }
3495
- async save() {
3496
- await mkdir8(dirname5(await this.getFilePath()), { recursive: true });
3497
- const filePath = await this.getFilePath();
3498
- const tmpPath = filePath + ".tmp";
3499
- await writeFile8(tmpPath, JSON.stringify(this.session, null, 2), "utf-8");
3500
- await rename8(tmpPath, filePath);
3501
- await chmod7(filePath, 384);
3502
- }
3503
- /** Store the session token obtained from the one-time-token exchange. */
3504
- async set(cloudUrl, token) {
3505
- await this.load();
3506
- this.session = { cloudUrl, encryptedToken: encryptValue(token) };
3507
- await this.save();
3508
- }
3509
- /** The decrypted session token, or null when not logged in. */
3510
- async getToken() {
3511
- await this.load();
3512
- if (!this.session) return null;
3513
- try {
3514
- return decryptValue(this.session.encryptedToken);
3515
- } catch {
3516
- return null;
3439
+ }),
3440
+ /** Subscribe to real-time agent status changes */
3441
+ onStatus: protectedProcedure.subscription(async function* () {
3442
+ for (const agent of agentManager.listAgents()) {
3443
+ yield { agentId: agent.config.id, status: agent.status };
3517
3444
  }
3518
- }
3519
- /**
3520
- * The cloud this machine is signed into, or null when not linked.
3521
- *
3522
- * Needed to tell "an org I am not a member of" apart from "an org of another
3523
- * cloud instance entirely" — the ids of two clouds share no namespace, so
3524
- * comparing them without this would call every foreign-cloud project a
3525
- * permission problem.
3526
- */
3527
- async getCloudUrl() {
3528
- await this.load();
3529
- return this.session?.cloudUrl ?? null;
3530
- }
3531
- async isLinked() {
3532
- return await this.getToken() !== null;
3533
- }
3534
- /** Forget the session (sign-out). */
3535
- async clear() {
3536
- await this.load();
3537
- this.session = null;
3538
- this.pending.clear();
3445
+ const queue = [];
3446
+ let resolve7 = null;
3447
+ const handler = (event) => {
3448
+ if (queue.length >= SUBSCRIPTION_QUEUE_MAX) queue.shift();
3449
+ queue.push(event);
3450
+ if (resolve7) {
3451
+ resolve7();
3452
+ resolve7 = null;
3453
+ }
3454
+ };
3455
+ agentManager.on("status", handler);
3539
3456
  try {
3540
- await unlink2(await this.getFilePath());
3541
- } catch {
3457
+ while (true) {
3458
+ if (queue.length > 0) {
3459
+ yield queue.shift();
3460
+ } else {
3461
+ await new Promise((r) => {
3462
+ resolve7 = r;
3463
+ });
3464
+ }
3465
+ }
3466
+ } finally {
3467
+ agentManager.off("status", handler);
3542
3468
  }
3543
- }
3544
- };
3545
- var cloudSessionStore = new CloudSessionStore();
3546
-
3547
- // ../server/src/services/cloud-api.ts
3548
- var DEFAULT_CLOUD_API_URL = "https://api.runeya.dev";
3549
- var DEFAULT_CLOUD_APP_URL = "https://runeya.dev";
3550
- function getCloudApiUrl() {
3551
- return (env.CLOUD_URL ?? DEFAULT_CLOUD_API_URL).replace(/\/+$/, "");
3552
- }
3553
- function getCloudAppUrl() {
3554
- return (env.CLOUD_APP_URL ?? DEFAULT_CLOUD_APP_URL).replace(/\/+$/, "");
3555
- }
3556
- var CloudNotLinkedError = class extends Error {
3557
- code = "CLOUD_NOT_LINKED";
3558
- constructor() {
3559
- super("Not linked to the Runeya cloud.");
3560
- this.name = "CloudNotLinkedError";
3561
- }
3562
- };
3563
- async function cloudFetch(path, init = {}) {
3564
- const token = await cloudSessionStore.getToken();
3565
- if (!token) throw new CloudNotLinkedError();
3566
- return fetch(`${getCloudApiUrl()}${path}`, {
3567
- method: init.method ?? "GET",
3568
- headers: {
3569
- Authorization: `Bearer ${token}`,
3570
- // Le cloud refuse (426) les apps trop anciennes pour son schéma courant.
3571
- [APP_VERSION_HEADER]: appVersion,
3572
- ...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
3573
- },
3574
- ...init.body !== void 0 ? { body: JSON.stringify(init.body) } : {},
3575
- // 15s suits a JSON call; attachment downloads pass a longer budget.
3576
- signal: AbortSignal.timeout(init.timeoutMs ?? 15e3)
3577
- });
3578
- }
3469
+ })
3470
+ });
3579
3471
 
3580
- // ../server/src/services/conversation-store.ts
3581
- import { randomUUID as randomUUID3 } from "crypto";
3582
- import { readFile as readFile9, writeFile as writeFile9, rename as rename9, mkdir as mkdir9, chmod as chmod8 } from "fs/promises";
3583
- import { dirname as dirname6, join as join10 } from "path";
3584
- import { EventEmitter as EventEmitter2 } from "events";
3585
- var FILENAME5 = "conversations.private.json";
3586
- async function localProjectId() {
3587
- const projects = await projectStore.list().catch(() => []);
3588
- return projects.find((p) => !p.orgId)?.id ?? null;
3589
- }
3590
- var ConversationStore = class {
3591
- conversations = /* @__PURE__ */ new Map();
3592
- /** Which vault each conversation was read from, until its project is known. */
3593
- roots = /* @__PURE__ */ new Map();
3594
- /**
3595
- * Les conversations qu'on n'a pas su déchiffrer, par coffre, sous leur forme
3596
- * de disque.
3597
- *
3598
- * Une entrée illisible était simplement ignorée. Tant qu'aucune de ses
3599
- * voisines ne se chargeait, le fichier n'était jamais réécrit et la donnée
3600
- * survivait par accident ; dès qu'une seule s'ouvrait, la sauvegarde suivante
3601
- * réécrivait le fichier avec elle seule — et effaçait les autres. Elles sont
3602
- * donc gardées ici, telles quelles, et réémises à l'identique.
3603
- */
3604
- unreadable = /* @__PURE__ */ new Map();
3605
- loaded = false;
3606
- events = new EventEmitter2().setMaxListeners(0);
3607
- saveQueue = Promise.resolve();
3608
- snapshotConversation(conv) {
3609
- return structuredClone(conv);
3610
- }
3611
- /**
3612
- * Encrypted, so it lives beside the key that opens it — usually the machine
3613
- * root, but a launch directory owning its own key keeps both together.
3614
- */
3615
- getFilePath() {
3616
- return keyedFilePath(FILENAME5);
3617
- }
3618
- /** Where older versions kept it — read only, so an older Runeya keeps working. */
3619
- getLegacyFilePath() {
3620
- return legacyMachineFilePath(FILENAME5);
3621
- }
3622
- /**
3623
- * Read the machine file, falling back to the launch directory of old.
3624
- *
3625
- * The legacy file is left in place: the next save writes the new location,
3626
- * and a directory still opened by an older Runeya keeps working meanwhile.
3627
- */
3628
- async readFromDisk() {
3629
- try {
3630
- return await readFile9(await this.getFilePath(), "utf-8");
3631
- } catch {
3632
- return readFile9(this.getLegacyFilePath(), "utf-8");
3472
+ // ../server/src/trpc/routers/process.ts
3473
+ import { z as z6 } from "zod";
3474
+ import { TRPCError as TRPCError6 } from "@trpc/server";
3475
+ var SUBSCRIPTION_QUEUE_MAX2 = LOG_BUFFER_SIZE;
3476
+ async function* forwardEvents(eventName, filter) {
3477
+ const queue = [];
3478
+ let resolve7 = null;
3479
+ const handler = (data) => {
3480
+ if (filter && !filter(data)) return;
3481
+ if (queue.length >= SUBSCRIPTION_QUEUE_MAX2) queue.shift();
3482
+ queue.push(data);
3483
+ if (resolve7) {
3484
+ resolve7();
3485
+ resolve7 = null;
3633
3486
  }
3487
+ };
3488
+ agentManager.on(eventName, handler);
3489
+ try {
3490
+ while (true) {
3491
+ if (queue.length > 0) {
3492
+ yield queue.shift();
3493
+ } else {
3494
+ await new Promise((r) => {
3495
+ resolve7 = r;
3496
+ });
3497
+ }
3498
+ }
3499
+ } finally {
3500
+ agentManager.off(eventName, handler);
3634
3501
  }
3635
- async ensureDir() {
3636
- await mkdir9(dirname6(await this.getFilePath()), { recursive: true });
3637
- }
3638
- encryptMessages(messages, vault) {
3639
- return encryptValue(JSON.stringify(messages), vault);
3640
- }
3641
- decryptMessages(encrypted, vault) {
3642
- const json = decryptValue(encrypted, vault);
3643
- return JSON.parse(json);
3502
+ }
3503
+ async function* forwardLogBatches(agentId, processId) {
3504
+ const queue = [];
3505
+ let resolve7 = null;
3506
+ const handler = (data) => {
3507
+ if (data.agentId !== agentId) return;
3508
+ const batch = data.batch;
3509
+ if (!Array.isArray(batch)) return;
3510
+ const filtered = batch.filter((log) => log.processId === processId);
3511
+ if (filtered.length === 0) return;
3512
+ if (queue.length >= SUBSCRIPTION_QUEUE_MAX2) queue.shift();
3513
+ queue.push(filtered);
3514
+ if (resolve7) {
3515
+ resolve7();
3516
+ resolve7 = null;
3517
+ }
3518
+ };
3519
+ agentManager.on("process:log", handler);
3520
+ try {
3521
+ while (true) {
3522
+ if (queue.length > 0) {
3523
+ yield queue.shift();
3524
+ } else {
3525
+ await new Promise((r) => {
3526
+ resolve7 = r;
3527
+ });
3528
+ }
3529
+ }
3530
+ } finally {
3531
+ agentManager.off("process:log", handler);
3644
3532
  }
3645
- /**
3646
- * Refuser d'écrire dans le coffre d'une organisation sans nommer sa clé.
3647
- *
3648
- * `getEncryptionKey(undefined)` rend la clé machine, et c'est le bon défaut
3649
- * pour le coffre local. Appliqué à un fichier rangé sous `orgs/<org>/`, il
3650
- * produit un chiffré que ce coffre n'emploie pas — lisible par personne à la
3651
- * relecture, et impossible à distinguer d'une corruption.
3652
- *
3653
- * Le cas a été observé plusieurs fois sans qu'on parvienne à nommer le
3654
- * chemin fautif : les garde-fous en place traitent le coffre fermé, le
3655
- * coffre mal résolu et la copie périmée, aucun ne couvrait « coffre non
3656
- * précisé ». Ces écritures-là n'ont jamais fonctionné — elles échouaient en
3657
- * silence, ce qui est pire. On les saute désormais en le disant, avec la
3658
- * pile d'appels qui manquait pour remonter au chemin fautif.
3659
- */
3660
- isVaultNamed(root, vault) {
3661
- const orgId = orgIdOfRoot(root);
3662
- if (!orgId || vault) return true;
3663
- console.error(
3664
- `[conversation-store] \xC9criture ignor\xE9e dans le coffre de l'organisation ${orgId} (${root}) : aucune cl\xE9 d'organisation nomm\xE9e, le contenu serait chiffr\xE9 par la cl\xE9 machine et illisible \xE0 la relecture.
3665
- ${new Error("trace").stack}`
3533
+ }
3534
+ var processRouter = router({
3535
+ /** List all processes across all connected agents */
3536
+ list: protectedProcedure.meta({ openapi: { method: "GET", path: "/processes", tags: ["processes"], summary: "List all processes", protect: true } }).output(z6.any()).query(async () => {
3537
+ const agents = agentManager.listAgents();
3538
+ const results = [];
3539
+ await Promise.allSettled(
3540
+ agents.filter((a) => a.status.connected).map(async (a) => {
3541
+ try {
3542
+ const processes = await agentManager.agentQuery(a.config.id, "process.list");
3543
+ for (const proc of processes) {
3544
+ results.push({ ...proc, agentId: a.config.id });
3545
+ }
3546
+ } catch {
3547
+ }
3548
+ })
3666
3549
  );
3667
- return false;
3668
- }
3669
- toDisk(conv, vault) {
3670
- return {
3671
- id: conv.id,
3672
- projectId: conv.projectId,
3673
- title: conv.title,
3674
- encryptedMessages: this.encryptMessages(conv.messages, vault),
3675
- isCli: conv.isCli,
3676
- runner: conv.runner,
3677
- providerId: conv.providerId,
3678
- codexThreadId: conv.codexThreadId,
3679
- contextUsage: conv.contextUsage,
3680
- workflowId: conv.workflowId,
3681
- workflowType: conv.workflowType,
3682
- kanbanBoardId: conv.kanbanBoardId,
3683
- kanbanCardId: conv.kanbanCardId,
3684
- archived: conv.archived,
3685
- createdAt: conv.createdAt,
3686
- updatedAt: conv.updatedAt
3687
- };
3688
- }
3689
- fromDisk(disk, vault) {
3690
- const workflowType = (disk.workflowType === "scenario" ? "scenario" : void 0) ?? (disk.scenarioId ? "scenario" : void 0);
3691
- return {
3692
- id: disk.id,
3693
- projectId: disk.projectId,
3694
- title: disk.title,
3695
- messages: this.decryptMessages(disk.encryptedMessages, vault),
3696
- isCli: disk.isCli ?? disk.isClaudeCode ?? false,
3697
- runner: disk.runner,
3698
- providerId: disk.providerId,
3699
- codexThreadId: disk.codexThreadId,
3700
- contextUsage: disk.contextUsage,
3701
- workflowId: disk.workflowId ?? disk.scenarioId,
3702
- workflowType,
3703
- kanbanBoardId: disk.kanbanBoardId,
3704
- kanbanCardId: disk.kanbanCardId,
3705
- archived: disk.archived,
3706
- createdAt: disk.createdAt,
3707
- updatedAt: disk.updatedAt
3708
- };
3709
- }
3710
- /**
3711
- * Read every project vault, then the homes older versions used.
3712
- *
3713
- * A conversation found in a legacy file is attributed to the local project of
3714
- * that directory — which is what it was about, back when one directory meant
3715
- * one project. With no local project to point at, it is left unattributed
3716
- * rather than filed under a project picked at random.
3717
- */
3718
- async load() {
3719
- if (this.loaded) return;
3720
- if (!getEncryptionKey()) {
3721
- return;
3550
+ return results;
3551
+ }),
3552
+ /** Get a single process from a specific agent */
3553
+ get: protectedProcedure.meta({ openapi: { method: "GET", path: "/processes/{agentId}/{id}", tags: ["processes"], summary: "Get a process by agent and ID", protect: true } }).input(z6.object({ agentId: z6.string().max(128), id: z6.string().max(128) })).output(z6.any()).query(async ({ input, ctx }) => {
3554
+ requireServiceAction(ctx, input.id, "service:read");
3555
+ const status = agentManager.getStatus(input.agentId);
3556
+ if (!status) {
3557
+ throw new TRPCError6({ code: "NOT_FOUND", message: `Agent ${input.agentId} not found` });
3722
3558
  }
3723
- this.loaded = true;
3724
- this.unreadable.clear();
3725
- await ensureProjectsLoaded();
3726
- const vaults = await allVaultRoots();
3727
- const legacyPaths = [this.getLegacyFilePath(), await keyedFilePath(FILENAME5)];
3728
- const sources = [
3729
- ...legacyPaths.map((path) => ({ path, legacy: true })),
3730
- ...vaults.map((root) => ({ path: join10(root, FILENAME5), legacy: false }))
3731
- ];
3732
- for (const { path, legacy } of sources) {
3733
- let data;
3559
+ const proc = await agentManager.agentQuery(input.agentId, "process.get", { id: input.id });
3560
+ return { ...proc, agentId: input.agentId };
3561
+ }),
3562
+ /** Start a process on an agent.
3563
+ * Lazy deploy: the agent only learns about a service when it is first started
3564
+ * (or restarted). This avoids pushing configs for every service at agent boot,
3565
+ * which would be wasteful when many services are never started.
3566
+ * The deploy call is idempotent — creates the process if missing, updates config if it exists. */
3567
+ start: protectedProcedure.meta({ openapi: { method: "POST", path: "/processes/{agentId}/{processId}/start", tags: ["processes"], summary: "Start a process", protect: true } }).input(z6.object({ agentId: z6.string().max(128).optional(), processId: z6.string().max(128) })).output(z6.any()).mutation(async ({ input, ctx }) => {
3568
+ requireServiceAction(ctx, input.processId, "service:start");
3569
+ const service = await serviceStore.get(input.processId);
3570
+ const effectiveAgentId = (service ? await resolveEffectiveAgentId(service) : null) ?? input.agentId ?? null;
3571
+ if (!effectiveAgentId) {
3572
+ throw new TRPCError6({ code: "BAD_REQUEST", message: "No agent assigned to this service. Set an agent on the service or on the active environment." });
3573
+ }
3574
+ if (service) {
3575
+ try {
3576
+ const resolved = await resolveServiceVariables(service);
3577
+ await agentManager.agentMutation(effectiveAgentId, "process.deploy", resolved);
3578
+ } catch (err) {
3579
+ console.debug("[process-router] Failed to deploy before start for", input.processId + ":", err.message);
3580
+ }
3581
+ }
3582
+ if (service?.clearLogsOnStart) {
3583
+ try {
3584
+ await agentManager.agentMutation(effectiveAgentId, "process.clearLogBuffer", { id: input.processId });
3585
+ agentManager.emit("process:log", { agentId: effectiveAgentId, batch: [{ processId: input.processId, __clearLogBuffer: true }] });
3586
+ } catch (err) {
3587
+ console.debug("[process-router] Failed to clear logs before start for", input.processId + ":", err.message);
3588
+ }
3589
+ }
3590
+ let result;
3591
+ try {
3592
+ result = await agentManager.agentMutation(effectiveAgentId, "process.start", { id: input.processId });
3593
+ } catch (err) {
3594
+ const err_ = err;
3595
+ const isFetchError = err_?.cause?.code === "ECONNREFUSED" || err_?.message === "fetch failed" || err_?.name === "TypeError";
3596
+ throw new TRPCError6({
3597
+ code: "INTERNAL_SERVER_ERROR",
3598
+ message: isFetchError ? `Cannot reach agent \u2014 make sure the agent is running` : err_?.message ?? "Failed to start process"
3599
+ });
3600
+ }
3601
+ await auditService.log({ userId: "local", action: "start", entityType: "process", entityId: input.processId, metadata: { agentId: effectiveAgentId } });
3602
+ return { ...result, agentId: effectiveAgentId };
3603
+ }),
3604
+ /** Stop a process on an agent */
3605
+ stop: protectedProcedure.meta({ openapi: { method: "POST", path: "/processes/{agentId}/{processId}/stop", tags: ["processes"], summary: "Stop a process", protect: true } }).input(z6.object({ agentId: z6.string().max(128), processId: z6.string().max(128) })).output(z6.any()).mutation(async ({ input, ctx }) => {
3606
+ requireServiceAction(ctx, input.processId, "service:stop");
3607
+ const result = await agentManager.agentMutation(input.agentId, "process.stop", { id: input.processId }, AGENT_STOP_FETCH_TIMEOUT);
3608
+ await auditService.log({ userId: "local", action: "stop", entityType: "process", entityId: input.processId, metadata: { agentId: input.agentId } });
3609
+ return { ...result, agentId: input.agentId };
3610
+ }),
3611
+ /** Restart a process on an agent.
3612
+ * Sends stop → deploy (latest resolved config) → start so that
3613
+ * config/variable changes take effect. Same lazy deploy pattern as start. */
3614
+ restart: protectedProcedure.meta({ openapi: { method: "POST", path: "/processes/{agentId}/{processId}/restart", tags: ["processes"], summary: "Restart a process", protect: true } }).input(z6.object({ agentId: z6.string().max(128).optional(), processId: z6.string().max(128) })).output(z6.any()).mutation(async ({ input, ctx }) => {
3615
+ requireServiceAction(ctx, input.processId, "service:restart");
3616
+ const service = await serviceStore.get(input.processId);
3617
+ const effectiveAgentId = (service ? await resolveEffectiveAgentId(service) : null) ?? input.agentId ?? null;
3618
+ if (!effectiveAgentId) {
3619
+ throw new TRPCError6({ code: "BAD_REQUEST", message: "No agent assigned to this service. Set an agent on the service or on the active environment." });
3620
+ }
3621
+ try {
3622
+ await agentManager.agentMutation(effectiveAgentId, "process.stop", { id: input.processId }, AGENT_STOP_FETCH_TIMEOUT);
3623
+ } catch (err) {
3624
+ console.debug("[process-router] Stop before restart ignored (process not found on agent?):", err.message);
3625
+ }
3626
+ if (service) {
3627
+ try {
3628
+ const resolved = await resolveServiceVariables(service);
3629
+ await agentManager.agentMutation(effectiveAgentId, "process.deploy", resolved);
3630
+ } catch (err) {
3631
+ console.debug("[process-router] Failed to deploy before restart for", input.processId + ":", err.message);
3632
+ }
3633
+ }
3634
+ if (service?.clearLogsOnStart) {
3635
+ try {
3636
+ await agentManager.agentMutation(effectiveAgentId, "process.clearLogBuffer", { id: input.processId });
3637
+ agentManager.emit("process:log", { agentId: effectiveAgentId, batch: [{ processId: input.processId, __clearLogBuffer: true }] });
3638
+ } catch (err) {
3639
+ console.debug("[process-router] Failed to clear logs before restart for", input.processId + ":", err.message);
3640
+ }
3641
+ }
3642
+ const result = await agentManager.agentMutation(effectiveAgentId, "process.start", { id: input.processId });
3643
+ await auditService.log({ userId: "local", action: "restart", entityType: "process", entityId: input.processId, metadata: { agentId: effectiveAgentId } });
3644
+ return { ...result, agentId: effectiveAgentId };
3645
+ }),
3646
+ /** Get buffered logs for a process from an agent */
3647
+ logs: protectedProcedure.meta({ openapi: { method: "GET", path: "/processes/{agentId}/{processId}/logs", tags: ["processes"], summary: "Get buffered logs for a process", protect: true } }).input(z6.object({ agentId: z6.string().max(128), processId: z6.string().max(128) })).output(z6.any()).query(async ({ input, ctx }) => {
3648
+ requireServiceAction(ctx, input.processId, "logs:read");
3649
+ return agentManager.agentQuery(input.agentId, "process.logs", { id: input.processId });
3650
+ }),
3651
+ /** Clear buffered logs for a process on an agent */
3652
+ clearLogBuffer: protectedProcedure.input(z6.object({ agentId: z6.string().max(128), processId: z6.string().max(128) })).mutation(async ({ input }) => {
3653
+ await agentManager.agentMutation(input.agentId, "process.clearLogBuffer", { id: input.processId });
3654
+ await auditService.log({ userId: "local", action: "clearLogBuffer", entityType: "process", entityId: input.processId, metadata: { agentId: input.agentId } });
3655
+ return { success: true };
3656
+ }),
3657
+ /** Execute a shortcut command on an agent, streaming output into the process log */
3658
+ executeShortcut: protectedProcedure.input(z6.object({
3659
+ agentId: z6.string().max(128),
3660
+ processId: z6.string().max(128),
3661
+ shortcut: ServiceShortcutSchema
3662
+ })).mutation(async ({ input }) => {
3663
+ const service = await serviceStore.get(input.processId);
3664
+ if (service) {
3734
3665
  try {
3735
- data = JSON.parse(await readFile9(path, "utf-8"));
3736
- } catch {
3737
- continue;
3666
+ const resolved = await resolveServiceVariables(service);
3667
+ await agentManager.agentMutation(input.agentId, "process.deploy", resolved);
3668
+ } catch (err) {
3669
+ console.debug("[process-router] Failed to deploy before executeShortcut for", input.processId + ":", err.message);
3738
3670
  }
3739
- const inherited = legacy ? await localProjectId() : null;
3740
- const root = dirname6(path);
3741
- await rememberFile(path);
3742
- for (const item of data) {
3743
- try {
3744
- const conv = this.fromDisk(item, vaultIdOfRoot(root));
3745
- if (!conv.projectId && inherited) conv.projectId = inherited;
3746
- this.conversations.set(item.id, conv);
3747
- this.roots.set(item.id, root);
3748
- } catch {
3749
- const bucket = this.unreadable.get(root) ?? [];
3750
- bucket.push(item);
3751
- this.unreadable.set(root, bucket);
3752
- }
3671
+ }
3672
+ const result = await agentManager.agentMutation(
3673
+ input.agentId,
3674
+ "process.executeShortcut",
3675
+ { id: input.processId, shortcut: input.shortcut },
3676
+ AGENT_LONG_FETCH_TIMEOUT
3677
+ );
3678
+ await auditService.log({
3679
+ userId: "local",
3680
+ action: "executeShortcut",
3681
+ entityType: "process",
3682
+ entityId: input.processId,
3683
+ metadata: { agentId: input.agentId, shortcutId: input.shortcut.id, shortcutLabel: input.shortcut.label }
3684
+ });
3685
+ return result;
3686
+ }),
3687
+ /** Execute an ad-hoc command on an agent, streaming output into the process log */
3688
+ executeAdHocCommand: protectedProcedure.input(z6.object({
3689
+ agentId: z6.string().max(128),
3690
+ processId: z6.string().max(128),
3691
+ command: z6.string().min(1).max(4096),
3692
+ cwd: z6.string().max(4096).optional(),
3693
+ commandId: z6.string().max(128).optional()
3694
+ })).mutation(async ({ input }) => {
3695
+ const service = await serviceStore.get(input.processId);
3696
+ if (service) {
3697
+ try {
3698
+ const resolved = await resolveServiceVariables(service);
3699
+ await agentManager.agentMutation(input.agentId, "process.deploy", resolved);
3700
+ } catch (err) {
3701
+ console.debug("[process-router] Failed to deploy before executeAdHocCommand for", input.processId + ":", err.message);
3753
3702
  }
3754
3703
  }
3755
- }
3756
- /**
3757
- * Oublier ce qui a été lu, pour tout relire sous la clé courante.
3758
- *
3759
- * Appelé après l'adoption d'une clé d'organisation : le disque vient d'être
3760
- * re-chiffré sous les pieds du store, si bien que sa mémoire décrit un état
3761
- * qui n'existe plus. La laisser en place n'affichait pas seulement des
3762
- * comptes périmés la sauvegarde suivante réécrivait l'ancien chiffré
3763
- * par-dessus le nouveau, et défaisait l'adoption.
3764
- */
3765
- resetCache() {
3766
- this.conversations.clear();
3767
- this.roots.clear();
3768
- this.unreadable.clear();
3769
- this.loaded = false;
3770
- this.events.emit("changed", { type: "reloaded" });
3771
- }
3704
+ const result = await agentManager.agentMutation(
3705
+ input.agentId,
3706
+ "process.executeAdHocCommand",
3707
+ {
3708
+ id: input.processId,
3709
+ command: input.command,
3710
+ // An explicit cwd wins; otherwise the service's directory, anchored on
3711
+ // its project rather than on the server's launch directory.
3712
+ cwd: input.cwd ?? await resolveAgentCwd({ serviceId: input.processId }),
3713
+ commandId: input.commandId
3714
+ },
3715
+ AGENT_LONG_FETCH_TIMEOUT
3716
+ );
3717
+ await auditService.log({
3718
+ userId: "local",
3719
+ action: "executeAdHocCommand",
3720
+ entityType: "process",
3721
+ entityId: input.processId,
3722
+ metadata: { agentId: input.agentId, command: input.command }
3723
+ });
3724
+ return result;
3725
+ }),
3726
+ /** Cancel a running shortcut command on an agent */
3727
+ cancelShortcut: protectedProcedure.input(z6.object({
3728
+ agentId: z6.string().max(128),
3729
+ processId: z6.string().max(128),
3730
+ shortcutId: z6.string().max(128)
3731
+ })).mutation(async ({ input }) => {
3732
+ await agentManager.agentMutation(input.agentId, "process.cancelShortcut", { id: input.processId, shortcutId: input.shortcutId });
3733
+ return { success: true };
3734
+ }),
3735
+ /** Cancel a running ad-hoc command on an agent */
3736
+ cancelAdHocCommand: protectedProcedure.input(z6.object({
3737
+ agentId: z6.string().max(128),
3738
+ processId: z6.string().max(128),
3739
+ commandId: z6.string().max(128).optional()
3740
+ })).mutation(async ({ input }) => {
3741
+ await agentManager.agentMutation(input.agentId, "process.cancelAdHocCommand", { id: input.processId, commandId: input.commandId });
3742
+ return { success: true };
3743
+ }),
3744
+ /** Get current metrics for a process from an agent */
3745
+ metrics: protectedProcedure.input(z6.object({ agentId: z6.string().max(128), processId: z6.string().max(128) })).query(async ({ input }) => {
3746
+ return agentManager.agentQuery(input.agentId, "process.metrics", { id: input.processId });
3747
+ }),
3772
3748
  /**
3773
- * Combien de conversations ce coffre garde sans savoir les lire, par
3774
- * organisation. La machine elle-même est rangée sous `null`.
3749
+ * Subscribe to real-time log stream for a process.
3750
+ * Forwards batched events from the AgentWSBridge in real-time.
3751
+ * Historical logs are fetched separately via process.logs query.
3775
3752
  */
3776
- async unreadableByVault() {
3777
- await this.load();
3778
- const out = /* @__PURE__ */ new Map();
3779
- for (const [root, items] of this.unreadable) {
3780
- const vault = vaultIdOfRoot(root) ?? null;
3781
- out.set(vault, (out.get(vault) ?? 0) + items.length);
3782
- }
3783
- return out;
3784
- }
3753
+ onLog: protectedProcedure.input(z6.object({ agentId: z6.string().max(128), processId: z6.string().max(128) })).subscription(async function* ({ input, ctx }) {
3754
+ requireServiceAction(ctx, input.processId, "logs:read");
3755
+ yield* forwardLogBatches(input.agentId, input.processId);
3756
+ }),
3785
3757
  /**
3786
- * Write each vault that holds or held — a conversation.
3787
- *
3788
- * A vault emptied by this save is still written, as an empty list: a
3789
- * conversation that found its project must not survive in the file it was
3790
- * read from.
3758
+ * Subscribe to real-time status changes for processes.
3759
+ * Input is optional — omit agentId/processId for global monitoring (dashboard).
3760
+ * Forwards events from the AgentWSBridge in real-time.
3791
3761
  */
3792
- async saveInternal() {
3793
- if (!getEncryptionKey()) return;
3794
- const byRoot = /* @__PURE__ */ new Map();
3795
- for (const root of this.roots.values()) byRoot.set(root, []);
3796
- for (const conv of this.conversations.values()) {
3797
- const root = await this.rootFor(conv);
3798
- this.roots.set(conv.id, root);
3799
- const bucket = byRoot.get(root) ?? [];
3800
- const vault = vaultIdOfRoot(root);
3801
- if (!this.isVaultNamed(root, vault)) continue;
3802
- bucket.push(this.toDisk(conv, vault));
3803
- byRoot.set(root, bucket);
3804
- }
3805
- for (const [root, items] of this.unreadable) {
3806
- byRoot.set(root, [...items, ...byRoot.get(root) ?? []]);
3807
- }
3808
- const stale = /* @__PURE__ */ new Set();
3809
- for (const root of byRoot.keys()) {
3810
- const path = join10(root, FILENAME5);
3811
- if (wasRead(path) && await hasChangedSinceRead(path)) stale.add(root);
3812
- }
3813
- for (const [root, data] of byRoot) {
3814
- const filePath = join10(root, FILENAME5);
3815
- if (stale.has(root)) {
3816
- console.warn(
3817
- `[conversation-store] ${filePath} a chang\xE9 depuis sa lecture \u2014 \xE9criture ignor\xE9e pour ne pas \xE9craser une r\xE9paration faite en dehors du serveur.`
3818
- );
3819
- continue;
3762
+ onStatus: protectedProcedure.input(z6.object({
3763
+ agentId: z6.string().max(128).optional(),
3764
+ processId: z6.string().max(128).optional()
3765
+ }).optional()).subscription(async function* ({ input, ctx }) {
3766
+ const allowedIds = getAllowedServiceIds(ctx);
3767
+ if (input?.processId) requireServiceAction(ctx, input.processId, "service:status");
3768
+ try {
3769
+ const agents = agentManager.listAgents();
3770
+ for (const agent of agents.filter((a) => a.status.connected)) {
3771
+ if (input?.agentId && agent.config.id !== input.agentId) continue;
3772
+ try {
3773
+ const processes = await agentManager.agentQuery(agent.config.id, "process.list");
3774
+ for (const proc of processes) {
3775
+ if (input?.processId && proc.id !== input.processId) continue;
3776
+ if (allowedIds && !allowedIds.has(proc.id)) continue;
3777
+ yield { ...proc, agentId: agent.config.id };
3778
+ }
3779
+ } catch {
3780
+ }
3820
3781
  }
3821
- await mkdir9(root, { recursive: true });
3822
- const tmpPath = `${filePath}.tmp`;
3823
- await writeFile9(tmpPath, JSON.stringify(data, null, 2), "utf-8");
3824
- await rename9(tmpPath, filePath);
3825
- await chmod8(filePath, 384);
3826
- await rememberFile(filePath);
3782
+ } catch {
3827
3783
  }
3828
- }
3784
+ yield* forwardEvents("process:status", (d) => {
3785
+ if (input?.agentId && d.agentId !== input.agentId) return false;
3786
+ if (input?.processId && d.processId !== input.processId) return false;
3787
+ if (allowedIds && !allowedIds.has(d.processId)) return false;
3788
+ return true;
3789
+ });
3790
+ }),
3791
+ /** Subscribe to real-time metrics for a process. */
3792
+ onMetrics: protectedProcedure.input(z6.object({
3793
+ agentId: z6.string().max(128).optional(),
3794
+ processId: z6.string().max(128).optional()
3795
+ }).optional()).subscription(async function* ({ input, ctx }) {
3796
+ const allowedIds = getAllowedServiceIds(ctx);
3797
+ if (input?.processId) requireServiceAction(ctx, input.processId, "service:status");
3798
+ yield* forwardEvents("process:metrics", (d) => {
3799
+ if (input?.agentId && d.agentId !== input.agentId) return false;
3800
+ if (input?.processId && d.processId !== input.processId) return false;
3801
+ if (allowedIds && !allowedIds.has(d.processId)) return false;
3802
+ return true;
3803
+ });
3804
+ }),
3805
+ /** Subscribe to real-time health status changes. */
3806
+ onHealth: protectedProcedure.input(z6.object({
3807
+ agentId: z6.string().max(128).optional(),
3808
+ processId: z6.string().max(128).optional()
3809
+ }).optional()).subscription(async function* ({ input, ctx }) {
3810
+ const allowedIds = getAllowedServiceIds(ctx);
3811
+ if (input?.processId) requireServiceAction(ctx, input.processId, "service:healthcheck");
3812
+ yield* forwardEvents("process:health", (d) => {
3813
+ if (input?.agentId && d.agentId !== input.agentId) return false;
3814
+ if (input?.processId && d.processId !== input.processId) return false;
3815
+ if (allowedIds && !allowedIds.has(d.processId)) return false;
3816
+ return true;
3817
+ });
3818
+ })
3819
+ });
3820
+
3821
+ // ../server/src/trpc/routers/audit.ts
3822
+ import { z as z7 } from "zod";
3823
+ var auditRouter = router({
3824
+ list: protectedProcedure.input(z7.object({
3825
+ action: z7.string().max(50).optional(),
3826
+ entityType: z7.string().max(50).optional(),
3827
+ entityId: z7.string().max(100).optional(),
3828
+ limit: z7.number().int().positive().max(100).default(50),
3829
+ offset: z7.number().int().nonnegative().default(0)
3830
+ }).optional()).query(async ({ input }) => {
3831
+ return auditService.list(input ?? {});
3832
+ })
3833
+ });
3834
+
3835
+ // ../server/src/trpc/routers/setup.ts
3836
+ import { z as z8 } from "zod";
3837
+
3838
+ // ../server/src/services/cloud-session-store.ts
3839
+ import { readFile as readFile9, writeFile as writeFile9, rename as rename9, mkdir as mkdir9, chmod as chmod8, unlink as unlink2 } from "fs/promises";
3840
+ import { dirname as dirname6 } from "path";
3841
+ import { randomBytes as randomBytes4, timingSafeEqual as timingSafeEqual2 } from "crypto";
3842
+ var FILENAME5 = "cloud-session.json";
3843
+ var STATE_TTL_MS = 5 * 60 * 1e3;
3844
+ var CloudSessionStore = class {
3845
+ session = null;
3846
+ loaded = false;
3847
+ /** Pending login states (CSRF binding between `start` and the callback). */
3848
+ pending = /* @__PURE__ */ new Map();
3829
3849
  /**
3830
- * The vault a conversation is written to: its project's.
3831
- *
3832
- * One that names no project stays where it was read, falling back to the
3833
- * key's own directory — never lost for want of an owner.
3850
+ * Encrypted, so it lives beside the key that opens it — usually the machine
3851
+ * root, but a launch directory owning its own key keeps both together.
3834
3852
  */
3835
- async rootFor(conv) {
3836
- const known = this.roots.get(conv.id) ?? null;
3837
- if (conv.projectId) return rootOfProjectAmong(conv.projectId, known);
3838
- return known ?? dirname6(await keyedFilePath(FILENAME5));
3853
+ getFilePath() {
3854
+ return keyedFilePath(FILENAME5);
3839
3855
  }
3840
- /**
3841
- * Le projet d'une conversation, organisation comprise.
3842
- *
3843
- * Ce que la conversation ne dit pas d'elle-même : elle ne nomme que l'id.
3844
- * Le coffre d'où elle vient fournit l'organisation, et c'est ce couple qu'il
3845
- * faut pour retrouver le répertoire de travail du projet.
3846
- */
3847
- async projectRefOf(conv) {
3848
- if (!conv.projectId) return null;
3849
- const known = this.roots.get(conv.id) ?? null;
3850
- const ref = refOfProjectAmong(conv.projectId, known);
3851
- return { id: ref.id, orgId: ref.orgId ?? null };
3856
+ /** Where older versions kept it — read only, so an older Runeya keeps working. */
3857
+ getLegacyFilePath() {
3858
+ return legacyMachineFilePath(FILENAME5);
3852
3859
  }
3853
3860
  /**
3854
- * Réécrire chaque coffre, en déplaçant ce qui appartient désormais ailleurs.
3861
+ * Read the machine file, falling back to the launch directory of old.
3855
3862
  *
3856
- * Appelé quand un projet a changé de coffre : ses conversations sont écrites
3857
- * dans le nouveau, et le fichier d'où elles viennent est réécrit sans elles.
3863
+ * The legacy file is left in place: the next save writes the new location,
3864
+ * and a directory still opened by an older Runeya keeps working meanwhile.
3858
3865
  */
3859
- async relocateAll() {
3860
- await this.load();
3861
- await this.save();
3862
- }
3863
- async save() {
3864
- const write = this.saveQueue.then(() => this.saveInternal());
3865
- this.saveQueue = write.catch(() => {
3866
- });
3867
- return write;
3866
+ async readFromDisk() {
3867
+ try {
3868
+ return await readFile9(await this.getFilePath(), "utf-8");
3869
+ } catch {
3870
+ return readFile9(this.getLegacyFilePath(), "utf-8");
3871
+ }
3868
3872
  }
3869
- async list() {
3870
- await this.load();
3871
- return Array.from(this.conversations.values()).sort((a, b) => b.updatedAt - a.updatedAt);
3873
+ async load() {
3874
+ if (this.loaded) return;
3875
+ try {
3876
+ const raw = await this.readFromDisk();
3877
+ this.session = JSON.parse(raw);
3878
+ } catch {
3879
+ }
3880
+ this.loaded = true;
3872
3881
  }
3873
3882
  /**
3874
- * Les conversations d'un projet.
3875
- *
3876
- * Celle qui ne nomme aucun projet — écrite avant le champ, ou qu'une
3877
- * migration n'a pas su attribuer — n'est rendue que dans les projets de son
3878
- * propre coffre : elle reste atteignable là où elle a été écrite, sans se
3879
- * déverser dans toutes les organisations de la machine.
3883
+ * Mint a state value binding a login attempt to this server. Returned to the
3884
+ * cloud page and echoed back on the callback, so a redirect we never
3885
+ * initiated cannot install a token.
3880
3886
  */
3881
- async listForProject(ref) {
3882
- const belongs = await this.matcherForProject(ref);
3883
- return (await this.list()).filter(belongs);
3887
+ createState(returnTo) {
3888
+ this.sweepStates();
3889
+ const state = randomBytes4(32).toString("hex");
3890
+ this.pending.set(state, { expiresAt: Date.now() + STATE_TTL_MS, returnTo });
3891
+ return state;
3884
3892
  }
3885
3893
  /**
3886
- * Le test d'appartenance à un projet, préparé une fois.
3887
- *
3888
- * Le flux temps réel en a besoin autant que la liste : une conversation qui
3889
- * arrive par événement doit passer le même filtre, sans quoi elle se
3890
- * réinvite dans un projet qui ne l'a jamais demandée.
3894
+ * Consume a state single use, constant-time compare, TTL enforced. Returns
3895
+ * the URL to send the browser back to, or null when the state is unknown or
3896
+ * expired (i.e. a callback this server never initiated).
3891
3897
  */
3892
- async matcherForProject(ref) {
3893
- await this.load();
3894
- let projectRoot2 = null;
3895
- try {
3896
- projectRoot2 = ref.orgId === void 0 ? rootOfProjectAmong(ref.id, null) : rootOfProjectRef({ id: ref.id, orgId: ref.orgId ?? null });
3897
- } catch {
3898
- projectRoot2 = null;
3898
+ consumeState(candidate) {
3899
+ this.sweepStates();
3900
+ for (const [state, entry] of this.pending) {
3901
+ const a = Buffer.from(state);
3902
+ const b = Buffer.from(candidate);
3903
+ if (a.length === b.length && timingSafeEqual2(a, b)) {
3904
+ this.pending.delete(state);
3905
+ return entry.expiresAt > Date.now() ? entry.returnTo : null;
3906
+ }
3899
3907
  }
3900
- return (conv) => {
3901
- if (conv.projectId) return conv.projectId === ref.id;
3902
- if (!projectRoot2) return true;
3903
- return this.roots.get(conv.id) === projectRoot2;
3904
- };
3905
- }
3906
- async get(id) {
3907
- await this.load();
3908
- return this.conversations.get(id) ?? null;
3908
+ return null;
3909
3909
  }
3910
- async create(title, isCli = false, workflowId, workflowType, runner, kanbanBoardId, kanbanCardId, providerId, projectId, orgId) {
3911
- await this.load();
3910
+ sweepStates() {
3912
3911
  const now = Date.now();
3913
- const conv = {
3914
- id: randomUUID3(),
3915
- projectId,
3916
- title: title ?? "New conversation",
3917
- messages: [],
3918
- isCli,
3919
- runner,
3920
- providerId,
3921
- workflowId,
3922
- workflowType,
3923
- kanbanBoardId,
3924
- kanbanCardId,
3925
- createdAt: now,
3926
- updatedAt: now
3927
- };
3928
- this.conversations.set(conv.id, conv);
3929
- if (projectId && orgId !== void 0) {
3930
- this.roots.set(conv.id, vaultFor({ id: projectId, orgId }).root);
3912
+ for (const [state, entry] of this.pending) {
3913
+ if (entry.expiresAt <= now) this.pending.delete(state);
3931
3914
  }
3932
- await this.save();
3933
- this.events.emit("changed", { type: "created", conversation: this.snapshotConversation(conv) });
3934
- return conv;
3935
3915
  }
3936
- async update(id, patch, options) {
3916
+ async save() {
3917
+ await mkdir9(dirname6(await this.getFilePath()), { recursive: true });
3918
+ const filePath = await this.getFilePath();
3919
+ const tmpPath = filePath + ".tmp";
3920
+ await writeFile9(tmpPath, JSON.stringify(this.session, null, 2), "utf-8");
3921
+ await rename9(tmpPath, filePath);
3922
+ await chmod8(filePath, 384);
3923
+ }
3924
+ /** Store the session token obtained from the one-time-token exchange. */
3925
+ async set(cloudUrl, token) {
3937
3926
  await this.load();
3938
- const conv = this.conversations.get(id);
3939
- if (!conv) return null;
3940
- if (patch.title !== void 0) conv.title = patch.title;
3941
- if (patch.messages !== void 0) conv.messages = patch.messages;
3942
- if (patch.contextUsage !== void 0) conv.contextUsage = patch.contextUsage;
3943
- if (patch.workflowId !== void 0) conv.workflowId = patch.workflowId;
3944
- if (patch.archived !== void 0) conv.archived = patch.archived;
3945
- if (patch.codexThreadId !== void 0) conv.codexThreadId = patch.codexThreadId;
3946
- if (options?.touch !== false) conv.updatedAt = Date.now();
3927
+ this.session = { cloudUrl, encryptedToken: encryptValue(token) };
3947
3928
  await this.save();
3948
- if (!options?.silent) {
3949
- this.events.emit("changed", { type: "updated", conversation: this.snapshotConversation(conv) });
3929
+ }
3930
+ /** The decrypted session token, or null when not logged in. */
3931
+ async getToken() {
3932
+ await this.load();
3933
+ if (!this.session) return null;
3934
+ try {
3935
+ return decryptValue(this.session.encryptedToken);
3936
+ } catch {
3937
+ return null;
3950
3938
  }
3951
- return conv;
3952
3939
  }
3953
3940
  /**
3954
- * Synchronously update the last assistant message content in memory, then schedule a save.
3955
- * Used by activeSessionRegistry.persistNow() to avoid the async get() → .then() race.
3941
+ * The cloud this machine is signed into, or null when not linked.
3942
+ *
3943
+ * Needed to tell "an org I am not a member of" apart from "an org of another
3944
+ * cloud instance entirely" — the ids of two clouds share no namespace, so
3945
+ * comparing them without this would call every foreign-cloud project a
3946
+ * permission problem.
3956
3947
  */
3957
- updateLastAssistantContent(id, content, parts) {
3958
- const conv = this.conversations.get(id);
3959
- if (!conv) return;
3960
- const lastMsg = conv.messages[conv.messages.length - 1];
3961
- if (!lastMsg || lastMsg.role !== "assistant") return;
3962
- lastMsg.content = content;
3963
- if (parts !== void 0) lastMsg.parts = parts;
3964
- conv.updatedAt = Date.now();
3965
- this.save().catch(() => {
3966
- });
3967
- }
3968
- async listByKanbanBoardId(boardId) {
3948
+ async getCloudUrl() {
3969
3949
  await this.load();
3970
- return Array.from(this.conversations.values()).filter((c) => c.kanbanBoardId === boardId);
3950
+ return this.session?.cloudUrl ?? null;
3971
3951
  }
3972
- async listByKanbanCardId(cardId) {
3973
- await this.load();
3974
- return Array.from(this.conversations.values()).filter((c) => c.kanbanCardId === cardId);
3952
+ async isLinked() {
3953
+ return await this.getToken() !== null;
3975
3954
  }
3976
- async delete(id) {
3955
+ /** Forget the session (sign-out). */
3956
+ async clear() {
3977
3957
  await this.load();
3978
- const existed = this.conversations.delete(id);
3979
- await this.save();
3980
- if (existed) {
3981
- this.events.emit("changed", { type: "deleted", id });
3958
+ this.session = null;
3959
+ this.pending.clear();
3960
+ try {
3961
+ await unlink2(await this.getFilePath());
3962
+ } catch {
3982
3963
  }
3983
3964
  }
3984
- onChanged(handler) {
3985
- this.events.on("changed", handler);
3986
- }
3987
- offChanged(handler) {
3988
- this.events.off("changed", handler);
3965
+ };
3966
+ var cloudSessionStore = new CloudSessionStore();
3967
+
3968
+ // ../server/src/services/cloud-api.ts
3969
+ var DEFAULT_CLOUD_API_URL = "https://api.runeya.dev";
3970
+ var DEFAULT_CLOUD_APP_URL = "https://runeya.dev";
3971
+ function getCloudApiUrl() {
3972
+ return (env.CLOUD_URL ?? DEFAULT_CLOUD_API_URL).replace(/\/+$/, "");
3973
+ }
3974
+ function getCloudAppUrl() {
3975
+ return (env.CLOUD_APP_URL ?? DEFAULT_CLOUD_APP_URL).replace(/\/+$/, "");
3976
+ }
3977
+ var CloudNotLinkedError = class extends Error {
3978
+ code = "CLOUD_NOT_LINKED";
3979
+ constructor() {
3980
+ super("Not linked to the Runeya cloud.");
3981
+ this.name = "CloudNotLinkedError";
3989
3982
  }
3990
3983
  };
3991
- var conversationStore = new ConversationStore();
3984
+ async function cloudFetch(path, init = {}) {
3985
+ const token = await cloudSessionStore.getToken();
3986
+ if (!token) throw new CloudNotLinkedError();
3987
+ return fetch(`${getCloudApiUrl()}${path}`, {
3988
+ method: init.method ?? "GET",
3989
+ headers: {
3990
+ Authorization: `Bearer ${token}`,
3991
+ // Le cloud refuse (426) les apps trop anciennes pour son schéma courant.
3992
+ [APP_VERSION_HEADER]: appVersion,
3993
+ ...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
3994
+ },
3995
+ ...init.body !== void 0 ? { body: JSON.stringify(init.body) } : {},
3996
+ // 15s suits a JSON call; attachment downloads pass a longer budget.
3997
+ signal: AbortSignal.timeout(init.timeoutMs ?? 15e3)
3998
+ });
3999
+ }
3992
4000
 
3993
4001
  // ../server/src/trpc/routers/setup.ts
3994
4002
  var setupRouter = router({
@@ -7965,11 +7973,11 @@ ${apiDocs}` : systemContext;
7965
7973
 
7966
7974
  ${TOKEN_INSTRUCTIONS}`;
7967
7975
  const mcpArgs = mcpConfig && !opts.excludeMcp ? ["--mcp-config", mcpConfig] : [];
7976
+ const modelArgs = model ? ["--model", model] : [];
7968
7977
  const args = [
7969
7978
  ...sessionArg,
7970
7979
  "--dangerously-skip-permissions",
7971
- "--model",
7972
- model,
7980
+ ...modelArgs,
7973
7981
  "--append-system-prompt",
7974
7982
  systemPrompt,
7975
7983
  ...mcpArgs,
@@ -8076,6 +8084,15 @@ async function runClaudeViaPty(params) {
8076
8084
  });
8077
8085
  } catch {
8078
8086
  }
8087
+ if (skipTokenInstructions && entry.type === "assistant" && entry.message?.stop_reason === "end_turn") {
8088
+ settle({
8089
+ output: state.assistantText,
8090
+ tokenStatus: "none",
8091
+ tokenMessage: "",
8092
+ actualSessionId: sessionId
8093
+ });
8094
+ return;
8095
+ }
8079
8096
  if (!skipTokenInstructions) {
8080
8097
  const { status, message, branchChoice } = parseEndToken(state.assistantText);
8081
8098
  if (status !== "none") {
@@ -8094,11 +8111,21 @@ async function runClaudeViaPty(params) {
8094
8111
  cli: "claude",
8095
8112
  args,
8096
8113
  env: params.injectedEnv && Object.keys(params.injectedEnv).length > 0 ? params.injectedEnv : void 0,
8114
+ // Le répertoire que l'appelant a résolu — celui du projet de la
8115
+ // conversation. Sans lui, le PTY naissait dans le répertoire de l'agent,
8116
+ // c'est-à-dire là où Runeya a été lancé : une carte kanban du projet A
8117
+ // faisait travailler le CLI dans le code de Runeya.
8118
+ cwd: params.cwd || void 0,
8097
8119
  cols: 120,
8098
8120
  rows: 30,
8099
8121
  sessionId,
8100
8122
  enableMcp: !!params.mcpConfig,
8101
- mcpApiBaseUrl: params.apiBaseUrl || void 0
8123
+ mcpApiBaseUrl: params.apiBaseUrl || void 0,
8124
+ // Sans lui, l'agent lance le CLI et son serveur MCP sans RUNEYA_API_TOKEN :
8125
+ // toutes les tools répondent « No auth token available », et `$RUNEYA_API_TOKEN`
8126
+ // ne résout à rien dans le shell du CLI. Le jeton est déjà là — la file le
8127
+ // signe elle-même, faute de requête HTTP d'où le tirer.
8128
+ mcpApiToken: params.apiToken || void 0
8102
8129
  }).then(({ spawnId: id }) => {
8103
8130
  spawnId = id;
8104
8131
  if (signal.aborted) {
@@ -8259,6 +8286,7 @@ ${apiDocs}` : systemContext;
8259
8286
  ${TOKEN_INSTRUCTIONS}`;
8260
8287
  const inputArgs = hasImages ? ["--input-format", "stream-json"] : ["-p", prompt];
8261
8288
  const mcpArgs = mcpConfig && !opts?.excludeMcp ? ["--mcp-config", mcpConfig] : [];
8289
+ const modelArgs = model ? ["--model", model] : [];
8262
8290
  const args = [
8263
8291
  ...sessionArg,
8264
8292
  "--output-format",
@@ -8266,8 +8294,7 @@ ${TOKEN_INSTRUCTIONS}`;
8266
8294
  "--verbose",
8267
8295
  "--include-partial-messages",
8268
8296
  "--dangerously-skip-permissions",
8269
- "--model",
8270
- model,
8297
+ ...modelArgs,
8271
8298
  ...thinkingArgs,
8272
8299
  "--append-system-prompt",
8273
8300
  systemPrompt,
@@ -9359,7 +9386,10 @@ async function* drainSession(session, signal, isReconnect) {
9359
9386
  }
9360
9387
  async function buildCliSystemContext(conv, jwt) {
9361
9388
  const allServices = await serviceStore.list();
9362
- const resolvedServices = await Promise.all(allServices.map(resolveServiceVariables));
9389
+ const ref = conv ? await conversationStore.projectRefOf(conv) : null;
9390
+ const project = ref ? await projectStore.get(ref) : null;
9391
+ const ownServices = project ? allServices.filter((svc) => project.serviceIds.includes(svc.id)) : allServices;
9392
+ const resolvedServices = await Promise.all(ownServices.map(resolveServiceVariables));
9363
9393
  let systemContext = generateAdminLlmsTxt(resolvedServices, jwt);
9364
9394
  if (conv?.kanbanBoardId) {
9365
9395
  const kanbanBoard = await kanbanStore.get(conv.kanbanBoardId);
@@ -9529,9 +9559,7 @@ var StoredMessageSchema = z16.object({
9529
9559
  parts: z16.array(MessagePartSchema).max(200).optional()
9530
9560
  });
9531
9561
  async function conversationCwd(conv) {
9532
- const ref = await conversationStore.projectRefOf(conv);
9533
- if (!ref) return resolveAgentCwd({});
9534
- return resolveAgentCwd({ projectId: ref.id, orgId: ref.orgId });
9562
+ return resolveConversationCwd(conv.id);
9535
9563
  }
9536
9564
  var chatRouter = router({
9537
9565
  conversations: router({
@@ -10219,9 +10247,13 @@ ${apiDocs}` : systemContext;
10219
10247
  cli,
10220
10248
  args,
10221
10249
  env: injectedEnv,
10222
- // An explicit cwd wins; otherwise the service's directory, then the
10223
- // project's never the directory the server was launched in.
10224
- cwd: input.cwd ?? await resolveAgentCwd({
10250
+ // An explicit cwd wins; then le projet de la conversation, s'il y en
10251
+ // a une : c'est elle que ce terminal poursuit, et son projet n'est
10252
+ // pas forcément celui ouvert dans l'onglet — ouvrir le terminal
10253
+ // d'une carte kanban depuis ailleurs lançait le CLI dans le
10254
+ // répertoire de lancement de Runeya. Sinon le service, puis le
10255
+ // projet actif — jamais le répertoire du serveur.
10256
+ cwd: input.cwd ?? (input.sessionId ? await resolveConversationCwd(input.sessionId) : void 0) ?? await resolveAgentCwd({
10225
10257
  serviceId: input.serviceId,
10226
10258
  projectId: input.projectId,
10227
10259
  orgId: input.orgId
@@ -11588,9 +11620,11 @@ async function runNodeCall(conversationId, node, systemContext, userMessage, run
11588
11620
  const result = await state.runner.run({
11589
11621
  agentId: runOverrides?.agentId ?? agentManager.getDefaultAgentId() ?? void 0,
11590
11622
  // Without this the CLI would run wherever the server was launched, whatever
11591
- // project the scenario belongs to. No project: left unset, so the runner
11592
- // keeps its own default rather than being sent to this machine's path.
11593
- cwd: await resolveAgentCwd({ projectId: runOverrides?.projectId, orgId: runOverrides?.orgId }),
11623
+ // project the scenario belongs to. Read from the conversation, so un lancement
11624
+ // sans requête HTTP la file kanban est ancré comme les autres. No project:
11625
+ // left unset, so the runner keeps its own default rather than being sent to
11626
+ // this machine's path.
11627
+ cwd: await resolveConversationCwd(conversationId),
11594
11628
  sessionId: state.sessionId,
11595
11629
  prompt: currentPrompt,
11596
11630
  isResume: state.isResume,
@@ -12468,7 +12502,6 @@ var scenarioRouter = router({
12468
12502
  runnerOverride: input.runnerOverride,
12469
12503
  modelOverride: input.modelOverride,
12470
12504
  agentId: input.agentId,
12471
- projectId: input.projectId,
12472
12505
  orgId: input.orgId
12473
12506
  }, !!input.resume);
12474
12507
  await new Promise((r) => setTimeout(r, 10));
@@ -12917,7 +12950,8 @@ var KanbanQueueService = class extends EventEmitter5 {
12917
12950
  maxConcurrent: this.maxConcurrent
12918
12951
  };
12919
12952
  }
12920
- add(item) {
12953
+ add(rawItem) {
12954
+ const item = rawItem.scenarioId || rawItem.runner ? rawItem : { ...rawItem, runner: "claude-code" };
12921
12955
  const alreadyQueued = this.items.find((i) => i.boardId === item.boardId && i.cardId === item.cardId);
12922
12956
  if (alreadyQueued) return alreadyQueued;
12923
12957
  const alreadyRunning = this.running.find((r) => r.boardId === item.boardId && r.cardId === item.cardId);
@@ -13049,7 +13083,7 @@ var KanbanQueueService = class extends EventEmitter5 {
13049
13083
  }
13050
13084
  async runWithRunner(runItem, item, cardContent) {
13051
13085
  try {
13052
- const model = item.model || (item.runner === "codex" ? "" : "claude-opus-4-6");
13086
+ const model = item.model || "";
13053
13087
  const starter = item.runner === "codex" ? startCodexSession : startClaudeCodeSession;
13054
13088
  const jwt = await signJwt();
13055
13089
  const { done } = await starter(runItem.conversationId, cardContent, { model, jwt });
@@ -17555,4 +17589,4 @@ export {
17555
17589
  pullEnv,
17556
17590
  registerInstance
17557
17591
  };
17558
- //# sourceMappingURL=src-WT7HSY3I.js.map
17592
+ //# sourceMappingURL=src-6WVQ2FQV.js.map