@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.
- package/index.js +1 -1
- package/package.json +1 -1
- package/{src-WT7HSY3I.js → src-6WVQ2FQV.js} +1369 -1335
- package/src-6WVQ2FQV.js.map +1 -0
- package/src-WT7HSY3I.js.map +0 -1
|
@@ -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
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
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
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
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
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
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
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
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
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
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
|
-
|
|
2644
|
-
|
|
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
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
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
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
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
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
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
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
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
|
-
|
|
2731
|
-
|
|
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
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
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
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
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
|
|
2840
|
-
|
|
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
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
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
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
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
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
}
|
|
2904
|
-
return
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
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
|
-
|
|
2876
|
+
projectRoot2 = ref.orgId === void 0 ? rootOfProjectAmong(ref.id, null) : rootOfProjectRef({ id: ref.id, orgId: ref.orgId ?? null });
|
|
3000
2877
|
} catch {
|
|
3001
|
-
|
|
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
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
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
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
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
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
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
|
-
|
|
3079
|
-
agentManager.off(eventName, handler);
|
|
2931
|
+
return conv;
|
|
3080
2932
|
}
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
if (
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
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
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
const
|
|
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
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
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
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
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
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
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
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
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
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
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
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
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(
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
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
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
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
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
const
|
|
3390
|
-
if (
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
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/
|
|
3401
|
-
import { z as
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
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
|
-
|
|
3448
|
-
|
|
3449
|
-
return
|
|
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
|
-
|
|
3453
|
-
|
|
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
|
-
|
|
3456
|
-
this.session = JSON.parse(raw);
|
|
3420
|
+
return await agentManager.agentQuery(input.id, "config.getPassthroughEnv");
|
|
3457
3421
|
} catch {
|
|
3422
|
+
return {};
|
|
3458
3423
|
}
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
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
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
const
|
|
3491
|
-
|
|
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
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
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
|
-
|
|
3541
|
-
|
|
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/
|
|
3581
|
-
import {
|
|
3582
|
-
import {
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
const
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
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
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
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
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
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
|
|
3668
|
-
}
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
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
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
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
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
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
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
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
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
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
|
-
*
|
|
3774
|
-
*
|
|
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
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
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
|
-
*
|
|
3787
|
-
*
|
|
3788
|
-
*
|
|
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
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
const
|
|
3800
|
-
const
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
3836
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
3861
|
+
* Read the machine file, falling back to the launch directory of old.
|
|
3855
3862
|
*
|
|
3856
|
-
*
|
|
3857
|
-
*
|
|
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
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
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
|
|
3870
|
-
|
|
3871
|
-
|
|
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
|
-
*
|
|
3875
|
-
*
|
|
3876
|
-
*
|
|
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
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
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
|
-
*
|
|
3887
|
-
*
|
|
3888
|
-
*
|
|
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
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
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
|
|
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
|
-
|
|
3911
|
-
await this.load();
|
|
3910
|
+
sweepStates() {
|
|
3912
3911
|
const now = Date.now();
|
|
3913
|
-
const
|
|
3914
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3949
|
-
|
|
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
|
-
*
|
|
3955
|
-
*
|
|
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
|
-
|
|
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
|
|
3950
|
+
return this.session?.cloudUrl ?? null;
|
|
3971
3951
|
}
|
|
3972
|
-
async
|
|
3973
|
-
await this.
|
|
3974
|
-
return Array.from(this.conversations.values()).filter((c) => c.kanbanCardId === cardId);
|
|
3952
|
+
async isLinked() {
|
|
3953
|
+
return await this.getToken() !== null;
|
|
3975
3954
|
}
|
|
3976
|
-
|
|
3955
|
+
/** Forget the session (sign-out). */
|
|
3956
|
+
async clear() {
|
|
3977
3957
|
await this.load();
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
this.
|
|
3958
|
+
this.session = null;
|
|
3959
|
+
this.pending.clear();
|
|
3960
|
+
try {
|
|
3961
|
+
await unlink2(await this.getFilePath());
|
|
3962
|
+
} catch {
|
|
3982
3963
|
}
|
|
3983
3964
|
}
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
10223
|
-
//
|
|
10224
|
-
|
|
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.
|
|
11592
|
-
//
|
|
11593
|
-
|
|
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(
|
|
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 ||
|
|
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-
|
|
17592
|
+
//# sourceMappingURL=src-6WVQ2FQV.js.map
|