@agent-team-foundation/first-tree-hub 0.9.10 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{bootstrap-hh_PkTu6.mjs → bootstrap-CtVqQA8a.mjs} +1 -1
- package/dist/cli/index.mjs +42 -81
- package/dist/{core-BWaSYpXv.mjs → core-BgiFGT7Y.mjs} +1122 -834
- package/dist/drizzle/0024_display_name_not_null.sql +31 -0
- package/dist/drizzle/0025_inbox_silent_entries.sql +53 -0
- package/dist/drizzle/meta/_journal.json +14 -0
- package/dist/{feishu-BJaN64iR.mjs → feishu-DEmwoNn_.mjs} +70 -9
- package/dist/index.mjs +5 -5
- package/dist/{logger-core-BTmvdflj-DhdipBkV.mjs → logger-core-BTmvdflj-DjW8FM4T.mjs} +1 -1
- package/dist/{observability-hDEdrmMS.mjs → observability-DDkJwSKv.mjs} +2 -2
- package/dist/{observability-DV_fQKqV-CuLWzBxQ.mjs → observability-DV_fQKqV-oxfXX6Z2.mjs} +1 -1
- package/dist/web/assets/index-DStPeqrX.css +1 -0
- package/dist/web/assets/index-Wgxk3V_m.js +371 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Cyvhyw0R.js +0 -361
- package/dist/web/assets/index-DEwlT6PE.css +0 -1
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { m as __toESM } from "./esm-CYu4tXXn.mjs";
|
|
2
|
-
import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DV_fQKqV-
|
|
3
|
-
import {
|
|
4
|
-
import { C as
|
|
5
|
-
import { $ as sessionStateMessageSchema, A as createMemberSchema, B as loginSchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as extractMentions, G as runtimeStateMessageSchema, H as notificationQuerySchema, I as imageInlineContentSchema, J as sendToAgentSchema, K as selfServiceFeishuBotSchema, L as inboxPollQuerySchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as sessionReconcileRequestSchema, R as isRedactedEnvValue, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as paginationQuerySchema, V as messageSourceSchema$1, W as refreshTokenSchema, X as sessionEventMessageSchema, Y as sessionCompletionMessageSchema, Z as sessionEventSchema$1, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as updateMemberSchema, b as agentBindRequestSchema, c as AGENT_TYPES, ct as updateTaskStatusSchema, d as SYSTEM_CONFIG_DEFAULTS, et as taskListQuerySchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateChatSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, lt as wsAuthFrameSchema, m as TASK_STATUSES, nt as updateAgentRuntimeConfigSchema, o as AGENT_SOURCES, ot as updateOrganizationSchema, p as TASK_HEALTH_SIGNALS, q as sendMessageSchema, rt as updateAgentSchema, s as AGENT_STATUSES, st as updateSystemConfigSchema, tt as updateAdapterConfigSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as linkTaskChatSchema } from "./feishu-BJaN64iR.mjs";
|
|
2
|
+
import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DV_fQKqV-oxfXX6Z2.mjs";
|
|
3
|
+
import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue } from "./bootstrap-CtVqQA8a.mjs";
|
|
4
|
+
import { $ as sessionEventMessageSchema, A as createChatSchema, B as isReservedAgentName$1, C as agentRuntimeConfigPayloadSchema$1, D as createAdapterConfigSchema, E as connectTokenExchangeSchema, F as dryRunAgentRuntimeConfigSchema, G as paginationQuerySchema, H as loginSchema, I as extractMentions, J as scanMentionTokens, K as refreshTokenSchema, L as imageInlineContentSchema, M as createOrganizationSchema, N as createTaskSchema, O as createAdapterMappingSchema, P as delegateFeishuUserSchema, Q as sessionCompletionMessageSchema, R as inboxPollQuerySchema, S as agentPinnedMessageSchema$1, T as clientRegisterSchema, U as messageSourceSchema$1, V as linkTaskChatSchema, W as notificationQuerySchema, X as sendMessageSchema, Y as selfServiceFeishuBotSchema, Z as sendToAgentSchema, _ as WS_AUTH_FRAME_TIMEOUT_MS, a as AGENT_NAME_REGEX$1, at as updateAgentRuntimeConfigSchema, b as adminUpdateTaskSchema, c as AGENT_STATUSES, ct as updateMemberSchema, d as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, dt as updateTaskStatusSchema, et as sessionEventSchema$1, f as SYSTEM_CONFIG_DEFAULTS, ft as wsAuthFrameSchema, g as TASK_TERMINAL_STATUSES, h as TASK_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateAdapterConfigSchema, j as createMemberSchema, k as createAgentSchema, l as AGENT_TYPES, lt as updateOrganizationSchema, m as TASK_HEALTH_SIGNALS, nt as sessionStateMessageSchema, o as AGENT_SELECTOR_HEADER$1, ot as updateAgentSchema, p as TASK_CREATOR_TYPES, q as runtimeStateMessageSchema, rt as taskListQuerySchema, s as AGENT_SOURCES, st as updateChatSchema, tt as sessionReconcileRequestSchema, u as AGENT_VISIBILITY, ut as updateSystemConfigSchema, v as addParticipantSchema, w as agentTypeSchema$1, x as agentBindRequestSchema, y as adminCreateTaskSchema, z as isRedactedEnvValue } from "./feishu-DEmwoNn_.mjs";
|
|
6
5
|
import { createRequire } from "node:module";
|
|
7
6
|
import { ZodError, z } from "zod";
|
|
8
7
|
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
9
8
|
import { Writable } from "node:stream";
|
|
10
9
|
import { homedir, hostname, platform, tmpdir, userInfo } from "node:os";
|
|
11
10
|
import { EventEmitter } from "node:events";
|
|
12
|
-
import { closeSync, copyFileSync,
|
|
11
|
+
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
|
|
13
12
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
14
13
|
import WebSocket from "ws";
|
|
15
14
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
@@ -32,7 +31,6 @@ import Fastify from "fastify";
|
|
|
32
31
|
import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
|
|
33
32
|
import { SignJWT, jwtVerify } from "jose";
|
|
34
33
|
import { Client, EventDispatcher, LoggerLevel, WSClient } from "@larksuiteoapi/node-sdk";
|
|
35
|
-
import { createInterface } from "node:readline";
|
|
36
34
|
//#region ../client/dist/observability-B4kO005X.mjs
|
|
37
35
|
var import_pino = /* @__PURE__ */ __toESM(require_pino(), 1);
|
|
38
36
|
/**
|
|
@@ -430,10 +428,33 @@ const agentTypeSchema = z.enum([
|
|
|
430
428
|
const agentVisibilitySchema = z.enum(["private", "organization"]);
|
|
431
429
|
const agentSourceSchema = z.enum(["admin-api", "portal"]);
|
|
432
430
|
z.enum(["active", "suspended"]);
|
|
431
|
+
/**
|
|
432
|
+
* Agent-name rules (see docs/agent-naming-design.md §3.1):
|
|
433
|
+
* - Lowercase ASCII slug, hyphens + underscores allowed.
|
|
434
|
+
* - Must start with alphanumeric: `-` / `_` as first char collide with
|
|
435
|
+
* CLI flag parsing and markdown list syntax.
|
|
436
|
+
* - 1–64 chars — aligned with `MENTION_REGEX` so any valid name can be
|
|
437
|
+
* @-mentioned in chat. Older rows created under the previous 1–100
|
|
438
|
+
* regex are grandfathered; the tight rule only gates new creates.
|
|
439
|
+
*/
|
|
440
|
+
const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
441
|
+
const RESERVED_AGENT_NAMES_SET = new Set([
|
|
442
|
+
"admin",
|
|
443
|
+
"agent",
|
|
444
|
+
"first-tree",
|
|
445
|
+
"hub",
|
|
446
|
+
"me",
|
|
447
|
+
"null",
|
|
448
|
+
"system",
|
|
449
|
+
"undefined"
|
|
450
|
+
]);
|
|
451
|
+
function isReservedAgentName(name) {
|
|
452
|
+
return RESERVED_AGENT_NAMES_SET.has(name);
|
|
453
|
+
}
|
|
433
454
|
z.object({
|
|
434
|
-
name: z.string().min(1).max(
|
|
455
|
+
name: z.string().min(1).max(64).regex(AGENT_NAME_REGEX, "Must start with a letter or digit and contain only lowercase letters, digits, hyphens (-), and underscores (_). Max 64 chars.").refine((n) => !isReservedAgentName(n), { message: "That agent name is reserved — pick a different one." }).optional(),
|
|
435
456
|
type: agentTypeSchema,
|
|
436
|
-
displayName: z.string().max(200).optional(),
|
|
457
|
+
displayName: z.string().min(1).max(200).optional(),
|
|
437
458
|
delegateMention: z.string().max(100).optional(),
|
|
438
459
|
organizationId: z.string().max(100).optional(),
|
|
439
460
|
source: agentSourceSchema.optional(),
|
|
@@ -444,7 +465,7 @@ z.object({
|
|
|
444
465
|
});
|
|
445
466
|
z.object({
|
|
446
467
|
type: agentTypeSchema.optional(),
|
|
447
|
-
displayName: z.string().
|
|
468
|
+
displayName: z.string().min(1).max(200).optional(),
|
|
448
469
|
delegateMention: z.string().max(100).nullable().optional(),
|
|
449
470
|
visibility: agentVisibilitySchema.optional(),
|
|
450
471
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
@@ -456,7 +477,7 @@ z.object({
|
|
|
456
477
|
name: z.string().nullable(),
|
|
457
478
|
organizationId: z.string(),
|
|
458
479
|
type: agentTypeSchema,
|
|
459
|
-
displayName: z.string()
|
|
480
|
+
displayName: z.string(),
|
|
460
481
|
delegateMention: z.string().nullable(),
|
|
461
482
|
inboxId: z.string(),
|
|
462
483
|
status: z.string(),
|
|
@@ -483,7 +504,7 @@ const agentPinnedMessageSchema = z.object({
|
|
|
483
504
|
type: z.literal("agent:pinned"),
|
|
484
505
|
agentId: z.string(),
|
|
485
506
|
name: z.string().nullable(),
|
|
486
|
-
displayName: z.string()
|
|
507
|
+
displayName: z.string(),
|
|
487
508
|
agentType: agentTypeSchema
|
|
488
509
|
});
|
|
489
510
|
/**
|
|
@@ -661,7 +682,7 @@ const chatParticipantSchema = z.object({
|
|
|
661
682
|
});
|
|
662
683
|
chatParticipantSchema.extend({
|
|
663
684
|
name: z.string().nullable(),
|
|
664
|
-
displayName: z.string()
|
|
685
|
+
displayName: z.string(),
|
|
665
686
|
type: z.string()
|
|
666
687
|
});
|
|
667
688
|
z.object({
|
|
@@ -814,6 +835,23 @@ const inReplyToSnapshotSchema = z.object({
|
|
|
814
835
|
/** Per-chat participation mode exposed to the recipient runtime. */
|
|
815
836
|
const participantModeSchema = z.enum(["full", "mention_only"]);
|
|
816
837
|
/**
|
|
838
|
+
* Lightweight snapshot of an earlier message in the same chat that the
|
|
839
|
+
* recipient missed (because it was `mention_only` + not @mentioned). Server
|
|
840
|
+
* attaches a list of these to the next active delivery in the chat so the
|
|
841
|
+
* agent's prompt carries enough context to reply meaningfully.
|
|
842
|
+
*
|
|
843
|
+
* Smaller than `messageSchema` on purpose — drops fields that don't help the
|
|
844
|
+
* LLM (replyTo envelopes, source) and aren't safe to leak across recipients.
|
|
845
|
+
*/
|
|
846
|
+
const precedingMessageSchema = z.object({
|
|
847
|
+
id: z.string(),
|
|
848
|
+
senderId: z.string(),
|
|
849
|
+
format: z.string(),
|
|
850
|
+
content: z.unknown(),
|
|
851
|
+
metadata: z.record(z.string(), z.unknown()).default({}),
|
|
852
|
+
createdAt: z.string()
|
|
853
|
+
});
|
|
854
|
+
/**
|
|
817
855
|
* Wire format for messages routed FROM the Hub TO a client runtime.
|
|
818
856
|
*
|
|
819
857
|
* Adds `configVersion` so the client can compare against its locally cached
|
|
@@ -829,11 +867,17 @@ const participantModeSchema = z.enum(["full", "mention_only"]);
|
|
|
829
867
|
*
|
|
830
868
|
* `inReplyToSnapshot` is populated when `inReplyTo` resolves to an existing
|
|
831
869
|
* message; runtime uses it to suppress self-reply echo on direct chats.
|
|
870
|
+
*
|
|
871
|
+
* `precedingMessages` is a (possibly empty) list of older messages in the
|
|
872
|
+
* same chat that this recipient did not previously receive (silent inbox
|
|
873
|
+
* context). The runtime renders them as "earlier in chat" before the
|
|
874
|
+
* triggering message — see proposals/group-chat-ux-improvements §1.
|
|
832
875
|
*/
|
|
833
876
|
const clientMessageSchema = messageSchema.extend({
|
|
834
877
|
configVersion: z.number().int().positive(),
|
|
835
878
|
recipientMode: participantModeSchema.default("full"),
|
|
836
|
-
inReplyToSnapshot: inReplyToSnapshotSchema.default(null)
|
|
879
|
+
inReplyToSnapshot: inReplyToSnapshotSchema.default(null),
|
|
880
|
+
precedingMessages: z.array(precedingMessageSchema).default([])
|
|
837
881
|
});
|
|
838
882
|
z.enum([
|
|
839
883
|
"pending",
|
|
@@ -1875,7 +1919,7 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1875
1919
|
});
|
|
1876
1920
|
const agent = {
|
|
1877
1921
|
agentId,
|
|
1878
|
-
displayName: msg.displayName ??
|
|
1922
|
+
displayName: msg.displayName ?? agentId,
|
|
1879
1923
|
agentType: msg.agentType ?? "personal_assistant",
|
|
1880
1924
|
sdk
|
|
1881
1925
|
};
|
|
@@ -3659,13 +3703,32 @@ function resolveSenderLabel(senderId, participants) {
|
|
|
3659
3703
|
* content is serialised to JSON — handlers that want to feed structured
|
|
3660
3704
|
* content some other way should opt out and format themselves.
|
|
3661
3705
|
*
|
|
3706
|
+
* If the server attached `precedingMessages` (silent group-chat history the
|
|
3707
|
+
* recipient missed because it was `mention_only` and not @mentioned), prepend
|
|
3708
|
+
* them under an `[Earlier in chat]` block so the LLM sees what came before
|
|
3709
|
+
* the @mention that woke this turn — see proposals/group-chat-ux-improvements §1.
|
|
3710
|
+
*
|
|
3662
3711
|
* Async because the participant list may need a server round-trip on first
|
|
3663
3712
|
* use; subsequent messages in the same session hit the cache.
|
|
3664
3713
|
*/
|
|
3665
3714
|
async function formatInboundContent(message, participants) {
|
|
3666
3715
|
const rawContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
|
|
3667
|
-
|
|
3668
|
-
|
|
3716
|
+
const preceding = message.precedingMessages ?? [];
|
|
3717
|
+
let header = "";
|
|
3718
|
+
if (preceding.length > 0) {
|
|
3719
|
+
const ps = await participants.get();
|
|
3720
|
+
const lines = ["[Earlier in chat — context you missed]"];
|
|
3721
|
+
for (const p of preceding) {
|
|
3722
|
+
const label = resolveSenderLabel(p.senderId, ps);
|
|
3723
|
+
const text = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
|
|
3724
|
+
lines.push(`[From: ${label}] ${text}`);
|
|
3725
|
+
}
|
|
3726
|
+
lines.push("", "[Now — message that woke you]");
|
|
3727
|
+
header = `${lines.join("\n")}\n\n`;
|
|
3728
|
+
}
|
|
3729
|
+
if (!message.senderId) return `${header}${rawContent}`;
|
|
3730
|
+
const label = resolveSenderLabel(message.senderId, await participants.get());
|
|
3731
|
+
return `${header}[From: ${label}]\n\n${rawContent}`;
|
|
3669
3732
|
}
|
|
3670
3733
|
/**
|
|
3671
3734
|
* Deduplicator — bounded set of recently seen IDs.
|
|
@@ -4295,7 +4358,8 @@ var SessionManager = class {
|
|
|
4295
4358
|
senderId: msg.senderId,
|
|
4296
4359
|
format: msg.format,
|
|
4297
4360
|
content: msg.content,
|
|
4298
|
-
metadata: msg.metadata
|
|
4361
|
+
metadata: msg.metadata,
|
|
4362
|
+
precedingMessages: msg.precedingMessages ?? []
|
|
4299
4363
|
};
|
|
4300
4364
|
}
|
|
4301
4365
|
loadPersistedSessions() {
|
|
@@ -4386,7 +4450,7 @@ var AgentSlot = class {
|
|
|
4386
4450
|
const sdk = (await this.clientConnection.bindAgent(this.config.agentId, this.config.runtimeType ?? this.config.type, this.config.runtimeVersion)).sdk;
|
|
4387
4451
|
this.sdk = sdk;
|
|
4388
4452
|
const agent = await sdk.register();
|
|
4389
|
-
this.logger.info({ displayName: agent.displayName
|
|
4453
|
+
this.logger.info({ displayName: agent.displayName }, "agent bound");
|
|
4390
4454
|
if (agent.type === "human") {
|
|
4391
4455
|
this.logger.info("server reports type=human — message processing disabled");
|
|
4392
4456
|
return agent;
|
|
@@ -5373,696 +5437,881 @@ function getContainerPassword() {
|
|
|
5373
5437
|
throw new Error("Cannot determine PostgreSQL password from container");
|
|
5374
5438
|
}
|
|
5375
5439
|
//#endregion
|
|
5376
|
-
//#region src/core/
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5440
|
+
//#region src/core/service-install.ts
|
|
5441
|
+
/**
|
|
5442
|
+
* Run a subprocess capturing stderr so failures surface a meaningful error
|
|
5443
|
+
* instead of Node's opaque "Command failed". Used for launchctl/systemctl —
|
|
5444
|
+
* anywhere the stderr message is diagnostically crucial.
|
|
5445
|
+
*/
|
|
5446
|
+
function runCapture(program, args, timeoutMs) {
|
|
5447
|
+
const res = spawnSync(program, args, {
|
|
5448
|
+
encoding: "utf-8",
|
|
5449
|
+
timeout: timeoutMs,
|
|
5450
|
+
stdio: [
|
|
5451
|
+
"ignore",
|
|
5452
|
+
"pipe",
|
|
5453
|
+
"pipe"
|
|
5454
|
+
]
|
|
5387
5455
|
});
|
|
5388
|
-
}
|
|
5389
|
-
function get(obj, dotPath) {
|
|
5390
|
-
const parts = dotPath.split(".");
|
|
5391
|
-
let current = obj;
|
|
5392
|
-
for (const part of parts) {
|
|
5393
|
-
if (current === null || current === void 0 || typeof current !== "object") return void 0;
|
|
5394
|
-
current = current[part];
|
|
5395
|
-
}
|
|
5396
|
-
return current;
|
|
5397
|
-
}
|
|
5398
|
-
function checkNodeVersion() {
|
|
5399
|
-
const version = process.versions.node;
|
|
5400
|
-
const [major] = version.split(".").map(Number);
|
|
5401
|
-
const ok = major !== void 0 && major >= 22;
|
|
5456
|
+
if (res.status === 0) return { ok: true };
|
|
5402
5457
|
return {
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5458
|
+
ok: false,
|
|
5459
|
+
stderr: (res.stderr ?? "").trim(),
|
|
5460
|
+
code: res.status
|
|
5406
5461
|
};
|
|
5407
5462
|
}
|
|
5408
|
-
function
|
|
5463
|
+
function sleepSync(ms) {
|
|
5464
|
+
const shared = new Int32Array(new SharedArrayBuffer(4));
|
|
5465
|
+
Atomics.wait(shared, 0, 0, ms);
|
|
5466
|
+
}
|
|
5467
|
+
const LAUNCHD_LABEL = "dev.first-tree-hub.client";
|
|
5468
|
+
const SYSTEMD_UNIT = "first-tree-hub-client.service";
|
|
5469
|
+
const LOG_DIR = join(DEFAULT_HOME_DIR$1, "logs");
|
|
5470
|
+
function whichBin(name) {
|
|
5409
5471
|
try {
|
|
5472
|
+
return execFileSync(process.platform === "win32" ? "where" : "which", [name], {
|
|
5473
|
+
encoding: "utf-8",
|
|
5474
|
+
timeout: 3e3
|
|
5475
|
+
}).split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] ?? null;
|
|
5476
|
+
} catch {
|
|
5477
|
+
return null;
|
|
5478
|
+
}
|
|
5479
|
+
}
|
|
5480
|
+
/**
|
|
5481
|
+
* Resolve how the service should launch the CLI.
|
|
5482
|
+
*
|
|
5483
|
+
* Prefers the installed `first-tree-hub` bin on PATH (usually a shim under
|
|
5484
|
+
* /usr/local/bin or ~/.npm-global/bin). Falls back to invoking the current
|
|
5485
|
+
* Node interpreter against the running script (handles `pnpm dev`, tsx, and
|
|
5486
|
+
* dev-only global installs).
|
|
5487
|
+
*/
|
|
5488
|
+
function resolveCliInvocation() {
|
|
5489
|
+
const bin = whichBin("first-tree-hub");
|
|
5490
|
+
if (bin && isAbsolute(bin)) try {
|
|
5410
5491
|
return {
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
detail: execFileSync("docker", ["--version"], {
|
|
5414
|
-
encoding: "utf-8",
|
|
5415
|
-
timeout: 5e3
|
|
5416
|
-
}).trim().replace("Docker version ", "v").split(",")[0] ?? ""
|
|
5492
|
+
kind: "bin",
|
|
5493
|
+
program: realpathSync(bin)
|
|
5417
5494
|
};
|
|
5418
5495
|
} catch {
|
|
5419
5496
|
return {
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
detail: "not found (optional — needed for auto PG provisioning)"
|
|
5497
|
+
kind: "bin",
|
|
5498
|
+
program: bin
|
|
5423
5499
|
};
|
|
5424
5500
|
}
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
const
|
|
5428
|
-
const hasEnv = !!process.env.FIRST_TREE_HUB_DATABASE_URL;
|
|
5429
|
-
if (hasFile && hasEnv) return {
|
|
5430
|
-
label: "Config",
|
|
5431
|
-
ok: true,
|
|
5432
|
-
detail: "config file + env vars"
|
|
5433
|
-
};
|
|
5434
|
-
if (hasFile) return {
|
|
5435
|
-
label: "Config",
|
|
5436
|
-
ok: true,
|
|
5437
|
-
detail: join(DEFAULT_CONFIG_DIR, "server.yaml")
|
|
5438
|
-
};
|
|
5439
|
-
if (hasEnv) return {
|
|
5440
|
-
label: "Config",
|
|
5441
|
-
ok: true,
|
|
5442
|
-
detail: "via environment variables"
|
|
5443
|
-
};
|
|
5501
|
+
const script = process.argv[1];
|
|
5502
|
+
if (!script) throw new Error("Cannot resolve CLI entry point (process.argv[1] is empty).");
|
|
5503
|
+
const scriptAbs = isAbsolute(script) ? script : join(process.cwd(), script);
|
|
5444
5504
|
return {
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5505
|
+
kind: "node",
|
|
5506
|
+
program: process.execPath,
|
|
5507
|
+
args: [scriptAbs]
|
|
5448
5508
|
};
|
|
5449
5509
|
}
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5510
|
+
function ensureLogDir() {
|
|
5511
|
+
mkdirSync(LOG_DIR, {
|
|
5512
|
+
recursive: true,
|
|
5513
|
+
mode: 448
|
|
5514
|
+
});
|
|
5515
|
+
}
|
|
5516
|
+
function launchdPlistPath() {
|
|
5517
|
+
return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
5518
|
+
}
|
|
5519
|
+
function renderPlist(invocation) {
|
|
5520
|
+
const argsXml = (invocation.kind === "bin" ? [
|
|
5521
|
+
invocation.program,
|
|
5522
|
+
"client",
|
|
5523
|
+
"start",
|
|
5524
|
+
"--no-interactive"
|
|
5525
|
+
] : [
|
|
5526
|
+
invocation.program,
|
|
5527
|
+
...invocation.args,
|
|
5528
|
+
"client",
|
|
5529
|
+
"start",
|
|
5530
|
+
"--no-interactive"
|
|
5531
|
+
]).map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
5532
|
+
const stdoutFallback = join(LOG_DIR, "client.stdout.log");
|
|
5533
|
+
const stderrFallback = join(LOG_DIR, "client.stderr.log");
|
|
5534
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
5535
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
|
|
5536
|
+
<plist version="1.0">
|
|
5537
|
+
<dict>
|
|
5538
|
+
<key>Label</key>
|
|
5539
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
5540
|
+
<key>ProgramArguments</key>
|
|
5541
|
+
<array>
|
|
5542
|
+
${argsXml}
|
|
5543
|
+
</array>
|
|
5544
|
+
<key>EnvironmentVariables</key>
|
|
5545
|
+
<dict>
|
|
5546
|
+
<key>PATH</key>
|
|
5547
|
+
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
5548
|
+
<key>FIRST_TREE_HUB_SERVICE_MODE</key>
|
|
5549
|
+
<string>1</string>
|
|
5550
|
+
</dict>
|
|
5551
|
+
<key>RunAtLoad</key>
|
|
5552
|
+
<true/>
|
|
5553
|
+
<key>KeepAlive</key>
|
|
5554
|
+
<dict>
|
|
5555
|
+
<key>SuccessfulExit</key>
|
|
5556
|
+
<false/>
|
|
5557
|
+
</dict>
|
|
5558
|
+
<key>ThrottleInterval</key>
|
|
5559
|
+
<integer>10</integer>
|
|
5560
|
+
<key>StandardOutPath</key>
|
|
5561
|
+
<string>${escapeXml(stdoutFallback)}</string>
|
|
5562
|
+
<key>StandardErrorPath</key>
|
|
5563
|
+
<string>${escapeXml(stderrFallback)}</string>
|
|
5564
|
+
</dict>
|
|
5565
|
+
</plist>
|
|
5566
|
+
`;
|
|
5567
|
+
}
|
|
5568
|
+
function escapeXml(value) {
|
|
5569
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5570
|
+
}
|
|
5571
|
+
function launchctlDomainTarget() {
|
|
5572
|
+
return `gui/${userInfo().uid}`;
|
|
5573
|
+
}
|
|
5574
|
+
function launchdState() {
|
|
5575
|
+
if (!existsSync(launchdPlistPath())) return { state: "not-installed" };
|
|
5576
|
+
const res = spawnSync("launchctl", ["print", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], {
|
|
5577
|
+
encoding: "utf-8",
|
|
5578
|
+
timeout: 5e3,
|
|
5579
|
+
stdio: [
|
|
5580
|
+
"ignore",
|
|
5581
|
+
"pipe",
|
|
5582
|
+
"pipe"
|
|
5583
|
+
]
|
|
5584
|
+
});
|
|
5585
|
+
if (res.status !== 0) return {
|
|
5586
|
+
state: "inactive",
|
|
5587
|
+
detail: "plist present but not loaded"
|
|
5456
5588
|
};
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
idle_timeout: 1
|
|
5463
|
-
});
|
|
5464
|
-
await sql.unsafe("SELECT 1");
|
|
5465
|
-
await sql.end();
|
|
5466
|
-
return {
|
|
5467
|
-
label: "Database",
|
|
5468
|
-
ok: true,
|
|
5469
|
-
detail: "connected"
|
|
5470
|
-
};
|
|
5471
|
-
} catch (err) {
|
|
5589
|
+
const out = res.stdout ?? "";
|
|
5590
|
+
const stateLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("state ="));
|
|
5591
|
+
const pidLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("pid ="));
|
|
5592
|
+
if (stateLine?.includes("running")) {
|
|
5593
|
+
const pid = pidLine?.split("=")[1]?.trim();
|
|
5472
5594
|
return {
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
detail: `unreachable — ${(err instanceof Error ? err.message : String(err)).slice(0, 80)}`
|
|
5595
|
+
state: "active",
|
|
5596
|
+
detail: pid ? `pid ${pid}` : "running"
|
|
5476
5597
|
};
|
|
5477
5598
|
}
|
|
5599
|
+
return {
|
|
5600
|
+
state: "inactive",
|
|
5601
|
+
detail: stateLine?.trim() ?? "loaded"
|
|
5602
|
+
};
|
|
5478
5603
|
}
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
return
|
|
5498
|
-
|
|
5499
|
-
ok: false,
|
|
5500
|
-
detail: `not running at ${host}:${port}`
|
|
5501
|
-
};
|
|
5604
|
+
/**
|
|
5605
|
+
* Poll `launchctl print` until the label disappears, confirming launchd has
|
|
5606
|
+
* finished the async eviction kicked off by `bootout`. Required because
|
|
5607
|
+
* `bootout` returns before the actual unload completes when the service has
|
|
5608
|
+
* active WebSocket connections — a follow-up `bootstrap` against a still-
|
|
5609
|
+
* registered label fails with `Bootstrap failed: 5: Input/output error`.
|
|
5610
|
+
*/
|
|
5611
|
+
function waitForLabelEvicted(target, label, timeoutMs) {
|
|
5612
|
+
const deadline = Date.now() + timeoutMs;
|
|
5613
|
+
while (Date.now() < deadline) {
|
|
5614
|
+
if (spawnSync("launchctl", ["print", `${target}/${label}`], {
|
|
5615
|
+
encoding: "utf-8",
|
|
5616
|
+
timeout: 2e3,
|
|
5617
|
+
stdio: [
|
|
5618
|
+
"ignore",
|
|
5619
|
+
"ignore",
|
|
5620
|
+
"pipe"
|
|
5621
|
+
]
|
|
5622
|
+
}).status !== 0) return true;
|
|
5623
|
+
sleepSync(200);
|
|
5502
5624
|
}
|
|
5625
|
+
return false;
|
|
5503
5626
|
}
|
|
5504
|
-
function
|
|
5505
|
-
const
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
};
|
|
5512
|
-
if (
|
|
5513
|
-
|
|
5514
|
-
ok: true,
|
|
5515
|
-
detail: join(DEFAULT_CONFIG_DIR, "client.yaml")
|
|
5516
|
-
};
|
|
5517
|
-
if (hasEnv) return {
|
|
5518
|
-
label: "Config",
|
|
5519
|
-
ok: true,
|
|
5520
|
-
detail: "via environment variables"
|
|
5521
|
-
};
|
|
5522
|
-
return {
|
|
5523
|
-
label: "Config",
|
|
5524
|
-
ok: false,
|
|
5525
|
-
detail: "no config file or env vars found"
|
|
5526
|
-
};
|
|
5527
|
-
}
|
|
5528
|
-
async function checkServerReachable() {
|
|
5529
|
-
const serverUrl = get(getClientConfig(), "server.url");
|
|
5530
|
-
if (typeof serverUrl !== "string" || !serverUrl) return {
|
|
5531
|
-
label: "Server URL",
|
|
5532
|
-
ok: false,
|
|
5533
|
-
detail: "not configured (FIRST_TREE_HUB_SERVER_URL or config file)"
|
|
5534
|
-
};
|
|
5535
|
-
try {
|
|
5536
|
-
const res = await fetch(`${serverUrl}/healthz`, { signal: AbortSignal.timeout(5e3) });
|
|
5537
|
-
if (res.ok) return {
|
|
5538
|
-
label: "Server URL",
|
|
5539
|
-
ok: true,
|
|
5540
|
-
detail: serverUrl
|
|
5541
|
-
};
|
|
5542
|
-
return {
|
|
5543
|
-
label: "Server URL",
|
|
5544
|
-
ok: false,
|
|
5545
|
-
detail: `unhealthy (HTTP ${res.status}) at ${serverUrl}`
|
|
5546
|
-
};
|
|
5547
|
-
} catch {
|
|
5548
|
-
return {
|
|
5549
|
-
label: "Server URL",
|
|
5550
|
-
ok: false,
|
|
5551
|
-
detail: `unreachable at ${serverUrl}`
|
|
5552
|
-
};
|
|
5627
|
+
function installLaunchd() {
|
|
5628
|
+
const invocation = resolveCliInvocation();
|
|
5629
|
+
ensureLogDir();
|
|
5630
|
+
const plistPath = launchdPlistPath();
|
|
5631
|
+
mkdirSync(dirname(plistPath), { recursive: true });
|
|
5632
|
+
writeFileSync(plistPath, renderPlist(invocation), { mode: 420 });
|
|
5633
|
+
const target = launchctlDomainTarget();
|
|
5634
|
+
const bootoutRes = runCapture("launchctl", ["bootout", `${target}/${LAUNCHD_LABEL}`], 15e3);
|
|
5635
|
+
if (!bootoutRes.ok) {
|
|
5636
|
+
if (!/not find|no such|not loaded/i.test(bootoutRes.stderr)) print.line(` warning: launchctl bootout: ${bootoutRes.stderr || `exit ${bootoutRes.code ?? "unknown"}`}\n`);
|
|
5553
5637
|
}
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
if (
|
|
5568
|
-
label: "Agents",
|
|
5569
|
-
ok: false,
|
|
5570
|
-
detail: "no agents configured"
|
|
5571
|
-
};
|
|
5572
|
-
const names = [...agents.keys()].join(", ");
|
|
5573
|
-
return {
|
|
5574
|
-
label: "Agents",
|
|
5575
|
-
ok: true,
|
|
5576
|
-
detail: `${agents.size} configured (${names})`
|
|
5577
|
-
};
|
|
5578
|
-
} catch {
|
|
5579
|
-
return {
|
|
5580
|
-
label: "Agents",
|
|
5581
|
-
ok: false,
|
|
5582
|
-
detail: "error reading agent configs"
|
|
5583
|
-
};
|
|
5638
|
+
waitForLabelEvicted(target, LAUNCHD_LABEL, 1e4);
|
|
5639
|
+
let lastBootstrapErr = null;
|
|
5640
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
5641
|
+
const res = runCapture("launchctl", [
|
|
5642
|
+
"bootstrap",
|
|
5643
|
+
target,
|
|
5644
|
+
plistPath
|
|
5645
|
+
], 1e4);
|
|
5646
|
+
if (res.ok) {
|
|
5647
|
+
lastBootstrapErr = null;
|
|
5648
|
+
break;
|
|
5649
|
+
}
|
|
5650
|
+
lastBootstrapErr = res;
|
|
5651
|
+
if (attempt < 2) sleepSync(1e3);
|
|
5584
5652
|
}
|
|
5585
|
-
}
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5653
|
+
if (lastBootstrapErr) throw new Error(`launchctl bootstrap failed: ${lastBootstrapErr.stderr || `exit ${lastBootstrapErr.code ?? "unknown"}`}\n Command: launchctl bootstrap ${target} ${plistPath}\n Recovery: \`launchctl bootout ${target}/${LAUNCHD_LABEL}\` then \`first-tree-hub client connect <server-url>\`.`);
|
|
5654
|
+
const enableRes = runCapture("launchctl", ["enable", `${target}/${LAUNCHD_LABEL}`], 5e3);
|
|
5655
|
+
if (!enableRes.ok) print.line(` warning: launchctl enable: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n`);
|
|
5656
|
+
const { state, detail } = launchdState();
|
|
5657
|
+
return {
|
|
5658
|
+
platform: "launchd",
|
|
5659
|
+
label: LAUNCHD_LABEL,
|
|
5660
|
+
unitPath: plistPath,
|
|
5661
|
+
logDir: LOG_DIR,
|
|
5662
|
+
state,
|
|
5663
|
+
detail
|
|
5592
5664
|
};
|
|
5593
|
-
const wsUrl = serverUrl.replace(/^http/, "ws");
|
|
5594
|
-
try {
|
|
5595
|
-
if ((await fetch(`${serverUrl}/healthz`, { signal: AbortSignal.timeout(3e3) })).ok) return {
|
|
5596
|
-
label: "WebSocket",
|
|
5597
|
-
ok: true,
|
|
5598
|
-
detail: `${wsUrl} (server reachable)`
|
|
5599
|
-
};
|
|
5600
|
-
return {
|
|
5601
|
-
label: "WebSocket",
|
|
5602
|
-
ok: false,
|
|
5603
|
-
detail: "server not healthy"
|
|
5604
|
-
};
|
|
5605
|
-
} catch {
|
|
5606
|
-
return {
|
|
5607
|
-
label: "WebSocket",
|
|
5608
|
-
ok: false,
|
|
5609
|
-
detail: `server unreachable at ${serverUrl}`
|
|
5610
|
-
};
|
|
5611
|
-
}
|
|
5612
5665
|
}
|
|
5613
|
-
function
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5666
|
+
function uninstallLaunchd() {
|
|
5667
|
+
const plistPath = launchdPlistPath();
|
|
5668
|
+
const res = runCapture("launchctl", ["bootout", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], 15e3);
|
|
5669
|
+
if (!res.ok && !/not find|no such|not loaded/i.test(res.stderr)) print.line(` warning: bootout during uninstall: ${res.stderr || `exit ${res.code ?? "unknown"}`}\n`);
|
|
5670
|
+
if (existsSync(plistPath)) rmSync(plistPath);
|
|
5671
|
+
return {
|
|
5672
|
+
platform: "launchd",
|
|
5673
|
+
label: LAUNCHD_LABEL,
|
|
5674
|
+
unitPath: plistPath,
|
|
5675
|
+
logDir: LOG_DIR,
|
|
5676
|
+
state: "not-installed"
|
|
5677
|
+
};
|
|
5623
5678
|
}
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
/**
|
|
5627
|
-
* Resolve the drizzle migrations directory.
|
|
5628
|
-
* 1. npm install: embedded at dist/drizzle/ (relative to the built CLI)
|
|
5629
|
-
* 2. Monorepo dev: resolved from @first-tree-hub/server package
|
|
5630
|
-
*/
|
|
5631
|
-
function resolveMigrationsFolder() {
|
|
5632
|
-
const embeddedPath = join(dirname(fileURLToPath(import.meta.url)), "..", "drizzle");
|
|
5633
|
-
if (existsSync(embeddedPath)) return embeddedPath;
|
|
5634
|
-
return join(dirname(fileURLToPath(import.meta.resolve("@first-tree-hub/server/package.json"))), "drizzle");
|
|
5679
|
+
function systemdUnitPath() {
|
|
5680
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "systemd", "user", SYSTEMD_UNIT);
|
|
5635
5681
|
}
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5682
|
+
function renderSystemdUnit(invocation) {
|
|
5683
|
+
return `[Unit]
|
|
5684
|
+
Description=First Tree Hub Client
|
|
5685
|
+
After=network-online.target
|
|
5686
|
+
Wants=network-online.target
|
|
5687
|
+
|
|
5688
|
+
[Service]
|
|
5689
|
+
Type=simple
|
|
5690
|
+
ExecStart=${invocation.kind === "bin" ? `${shellQuote(invocation.program)} client start --no-interactive` : `${shellQuote(invocation.program)} ${invocation.args.map(shellQuote).join(" ")} client start --no-interactive`}
|
|
5691
|
+
Restart=always
|
|
5692
|
+
RestartSec=10
|
|
5693
|
+
StandardOutput=append:${join(LOG_DIR, "client.stdout.log")}
|
|
5694
|
+
StandardError=append:${join(LOG_DIR, "client.stderr.log")}
|
|
5695
|
+
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
5696
|
+
Environment=FIRST_TREE_HUB_SERVICE_MODE=1
|
|
5697
|
+
|
|
5698
|
+
[Install]
|
|
5699
|
+
WantedBy=default.target
|
|
5700
|
+
`;
|
|
5652
5701
|
}
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
async function runMigrations(databaseUrl) {
|
|
5657
|
-
const migrationsFolder = resolveMigrationsFolder();
|
|
5658
|
-
validateJournalOrder(migrationsFolder);
|
|
5659
|
-
const client = postgres(databaseUrl, { max: 1 });
|
|
5660
|
-
const db = drizzle(client);
|
|
5661
|
-
try {
|
|
5662
|
-
await migrate(db, { migrationsFolder });
|
|
5663
|
-
} finally {
|
|
5664
|
-
await client.end();
|
|
5665
|
-
}
|
|
5666
|
-
const countClient = postgres(databaseUrl, { max: 1 });
|
|
5667
|
-
try {
|
|
5668
|
-
return (await countClient`
|
|
5669
|
-
SELECT count(*)::int AS count
|
|
5670
|
-
FROM information_schema.tables
|
|
5671
|
-
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
5672
|
-
`)[0].count;
|
|
5673
|
-
} finally {
|
|
5674
|
-
await countClient.end();
|
|
5675
|
-
}
|
|
5702
|
+
function shellQuote(value) {
|
|
5703
|
+
if (/^[A-Za-z0-9_\-./:=]+$/.test(value)) return value;
|
|
5704
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
5676
5705
|
}
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
function runCapture(program, args, timeoutMs) {
|
|
5685
|
-
const res = spawnSync(program, args, {
|
|
5706
|
+
function systemdState() {
|
|
5707
|
+
if (!existsSync(systemdUnitPath())) return { state: "not-installed" };
|
|
5708
|
+
const res = spawnSync("systemctl", [
|
|
5709
|
+
"--user",
|
|
5710
|
+
"is-active",
|
|
5711
|
+
SYSTEMD_UNIT
|
|
5712
|
+
], {
|
|
5686
5713
|
encoding: "utf-8",
|
|
5687
|
-
timeout:
|
|
5714
|
+
timeout: 5e3,
|
|
5688
5715
|
stdio: [
|
|
5689
5716
|
"ignore",
|
|
5690
5717
|
"pipe",
|
|
5691
5718
|
"pipe"
|
|
5692
5719
|
]
|
|
5693
5720
|
});
|
|
5694
|
-
|
|
5721
|
+
const out = (res.stdout ?? "").trim();
|
|
5722
|
+
if (res.status === 0 && out === "active") return {
|
|
5723
|
+
state: "active",
|
|
5724
|
+
detail: "running"
|
|
5725
|
+
};
|
|
5695
5726
|
return {
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5727
|
+
state: "inactive",
|
|
5728
|
+
detail: out || "unit present but not active"
|
|
5729
|
+
};
|
|
5730
|
+
}
|
|
5731
|
+
function installSystemd() {
|
|
5732
|
+
const invocation = resolveCliInvocation();
|
|
5733
|
+
ensureLogDir();
|
|
5734
|
+
const unitPath = systemdUnitPath();
|
|
5735
|
+
mkdirSync(dirname(unitPath), { recursive: true });
|
|
5736
|
+
writeFileSync(unitPath, renderSystemdUnit(invocation), { mode: 420 });
|
|
5737
|
+
const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
|
|
5738
|
+
if (!reloadRes.ok) throw new Error(`systemctl --user daemon-reload failed: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}`);
|
|
5739
|
+
const enableRes = runCapture("systemctl", [
|
|
5740
|
+
"--user",
|
|
5741
|
+
"enable",
|
|
5742
|
+
"--now",
|
|
5743
|
+
SYSTEMD_UNIT
|
|
5744
|
+
], 1e4);
|
|
5745
|
+
if (!enableRes.ok) throw new Error(`systemctl --user enable --now ${SYSTEMD_UNIT} failed: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n Recovery: \`systemctl --user stop ${SYSTEMD_UNIT}\` then \`first-tree-hub client connect <server-url>\`.`);
|
|
5746
|
+
const { state, detail } = systemdState();
|
|
5747
|
+
return {
|
|
5748
|
+
platform: "systemd",
|
|
5749
|
+
label: SYSTEMD_UNIT,
|
|
5750
|
+
unitPath,
|
|
5751
|
+
logDir: LOG_DIR,
|
|
5752
|
+
state,
|
|
5753
|
+
detail
|
|
5754
|
+
};
|
|
5755
|
+
}
|
|
5756
|
+
function uninstallSystemd() {
|
|
5757
|
+
const unitPath = systemdUnitPath();
|
|
5758
|
+
const disableRes = runCapture("systemctl", [
|
|
5759
|
+
"--user",
|
|
5760
|
+
"disable",
|
|
5761
|
+
"--now",
|
|
5762
|
+
SYSTEMD_UNIT
|
|
5763
|
+
], 1e4);
|
|
5764
|
+
if (!disableRes.ok && !/not found|no such|not loaded/i.test(disableRes.stderr)) print.line(` warning: systemctl disable during uninstall: ${disableRes.stderr || `exit ${disableRes.code ?? "unknown"}`}\n`);
|
|
5765
|
+
if (existsSync(unitPath)) rmSync(unitPath);
|
|
5766
|
+
const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
|
|
5767
|
+
if (!reloadRes.ok) print.line(` warning: systemctl daemon-reload during uninstall: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}\n`);
|
|
5768
|
+
return {
|
|
5769
|
+
platform: "systemd",
|
|
5770
|
+
label: SYSTEMD_UNIT,
|
|
5771
|
+
unitPath,
|
|
5772
|
+
logDir: LOG_DIR,
|
|
5773
|
+
state: "not-installed"
|
|
5699
5774
|
};
|
|
5700
5775
|
}
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
}
|
|
5705
|
-
const LAUNCHD_LABEL = "dev.first-tree-hub.client";
|
|
5706
|
-
const SYSTEMD_UNIT = "first-tree-hub-client.service";
|
|
5707
|
-
const LOG_DIR$1 = join(DEFAULT_HOME_DIR$1, "logs");
|
|
5708
|
-
function whichBin(name) {
|
|
5709
|
-
try {
|
|
5710
|
-
return execFileSync(process.platform === "win32" ? "where" : "which", [name], {
|
|
5711
|
-
encoding: "utf-8",
|
|
5712
|
-
timeout: 3e3
|
|
5713
|
-
}).split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] ?? null;
|
|
5714
|
-
} catch {
|
|
5715
|
-
return null;
|
|
5716
|
-
}
|
|
5776
|
+
/** Is background-service install supported on the current platform? */
|
|
5777
|
+
function isServiceSupported() {
|
|
5778
|
+
return process.platform === "darwin" || process.platform === "linux";
|
|
5717
5779
|
}
|
|
5718
5780
|
/**
|
|
5719
|
-
*
|
|
5781
|
+
* Install the background service for the current platform.
|
|
5720
5782
|
*
|
|
5721
|
-
*
|
|
5722
|
-
* /usr/local/bin or ~/.npm-global/bin). Falls back to invoking the current
|
|
5723
|
-
* Node interpreter against the running script (handles `pnpm dev`, tsx, and
|
|
5724
|
-
* dev-only global installs).
|
|
5783
|
+
* @throws {Error} if the platform is not supported or the service manager fails.
|
|
5725
5784
|
*/
|
|
5726
|
-
function
|
|
5727
|
-
|
|
5728
|
-
if (
|
|
5785
|
+
function installClientService() {
|
|
5786
|
+
if (process.platform === "darwin") return installLaunchd();
|
|
5787
|
+
if (process.platform === "linux") return installSystemd();
|
|
5788
|
+
throw new Error(`Background service install is not supported on ${process.platform}. Run \`first-tree-hub client start\` manually to keep the computer online.`);
|
|
5789
|
+
}
|
|
5790
|
+
/** Report the current service state without modifying anything. */
|
|
5791
|
+
function getClientServiceStatus() {
|
|
5792
|
+
if (process.platform === "darwin") {
|
|
5793
|
+
const { state, detail } = launchdState();
|
|
5729
5794
|
return {
|
|
5730
|
-
|
|
5731
|
-
|
|
5795
|
+
platform: "launchd",
|
|
5796
|
+
label: LAUNCHD_LABEL,
|
|
5797
|
+
unitPath: launchdPlistPath(),
|
|
5798
|
+
logDir: LOG_DIR,
|
|
5799
|
+
state,
|
|
5800
|
+
detail
|
|
5732
5801
|
};
|
|
5733
|
-
}
|
|
5802
|
+
}
|
|
5803
|
+
if (process.platform === "linux") {
|
|
5804
|
+
const { state, detail } = systemdState();
|
|
5734
5805
|
return {
|
|
5735
|
-
|
|
5736
|
-
|
|
5806
|
+
platform: "systemd",
|
|
5807
|
+
label: SYSTEMD_UNIT,
|
|
5808
|
+
unitPath: systemdUnitPath(),
|
|
5809
|
+
logDir: LOG_DIR,
|
|
5810
|
+
state,
|
|
5811
|
+
detail
|
|
5737
5812
|
};
|
|
5738
5813
|
}
|
|
5739
|
-
const script = process.argv[1];
|
|
5740
|
-
if (!script) throw new Error("Cannot resolve CLI entry point (process.argv[1] is empty).");
|
|
5741
|
-
const scriptAbs = isAbsolute(script) ? script : join(process.cwd(), script);
|
|
5742
5814
|
return {
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5815
|
+
platform: "unsupported",
|
|
5816
|
+
label: "",
|
|
5817
|
+
unitPath: "",
|
|
5818
|
+
logDir: LOG_DIR,
|
|
5819
|
+
state: "not-installed",
|
|
5820
|
+
detail: `platform ${process.platform} not supported`
|
|
5746
5821
|
};
|
|
5747
5822
|
}
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5823
|
+
/** Uninstall the background service. No-op if not installed. */
|
|
5824
|
+
function uninstallClientService() {
|
|
5825
|
+
if (process.platform === "darwin") return uninstallLaunchd();
|
|
5826
|
+
if (process.platform === "linux") return uninstallSystemd();
|
|
5827
|
+
return getClientServiceStatus();
|
|
5828
|
+
}
|
|
5829
|
+
//#endregion
|
|
5830
|
+
//#region src/core/doctor.ts
|
|
5831
|
+
function getServerConfig() {
|
|
5832
|
+
return resolveConfigReadonly({
|
|
5833
|
+
schema: serverConfigSchema,
|
|
5834
|
+
role: "server"
|
|
5752
5835
|
});
|
|
5753
5836
|
}
|
|
5754
|
-
function
|
|
5755
|
-
return
|
|
5837
|
+
function getClientConfig() {
|
|
5838
|
+
return resolveConfigReadonly({
|
|
5839
|
+
schema: clientConfigSchema,
|
|
5840
|
+
role: "client"
|
|
5841
|
+
});
|
|
5756
5842
|
}
|
|
5757
|
-
function
|
|
5758
|
-
const
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
"
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
...invocation.args,
|
|
5766
|
-
"client",
|
|
5767
|
-
"start",
|
|
5768
|
-
"--no-interactive"
|
|
5769
|
-
]).map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
5770
|
-
const stdoutFallback = join(LOG_DIR$1, "client.stdout.log");
|
|
5771
|
-
const stderrFallback = join(LOG_DIR$1, "client.stderr.log");
|
|
5772
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
5773
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
|
|
5774
|
-
<plist version="1.0">
|
|
5775
|
-
<dict>
|
|
5776
|
-
<key>Label</key>
|
|
5777
|
-
<string>${LAUNCHD_LABEL}</string>
|
|
5778
|
-
<key>ProgramArguments</key>
|
|
5779
|
-
<array>
|
|
5780
|
-
${argsXml}
|
|
5781
|
-
</array>
|
|
5782
|
-
<key>EnvironmentVariables</key>
|
|
5783
|
-
<dict>
|
|
5784
|
-
<key>PATH</key>
|
|
5785
|
-
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
5786
|
-
<key>FIRST_TREE_HUB_SERVICE_MODE</key>
|
|
5787
|
-
<string>1</string>
|
|
5788
|
-
</dict>
|
|
5789
|
-
<key>RunAtLoad</key>
|
|
5790
|
-
<true/>
|
|
5791
|
-
<key>KeepAlive</key>
|
|
5792
|
-
<dict>
|
|
5793
|
-
<key>SuccessfulExit</key>
|
|
5794
|
-
<false/>
|
|
5795
|
-
</dict>
|
|
5796
|
-
<key>ThrottleInterval</key>
|
|
5797
|
-
<integer>10</integer>
|
|
5798
|
-
<key>StandardOutPath</key>
|
|
5799
|
-
<string>${escapeXml(stdoutFallback)}</string>
|
|
5800
|
-
<key>StandardErrorPath</key>
|
|
5801
|
-
<string>${escapeXml(stderrFallback)}</string>
|
|
5802
|
-
</dict>
|
|
5803
|
-
</plist>
|
|
5804
|
-
`;
|
|
5843
|
+
function get(obj, dotPath) {
|
|
5844
|
+
const parts = dotPath.split(".");
|
|
5845
|
+
let current = obj;
|
|
5846
|
+
for (const part of parts) {
|
|
5847
|
+
if (current === null || current === void 0 || typeof current !== "object") return void 0;
|
|
5848
|
+
current = current[part];
|
|
5849
|
+
}
|
|
5850
|
+
return current;
|
|
5805
5851
|
}
|
|
5806
|
-
function
|
|
5807
|
-
|
|
5852
|
+
function checkNodeVersion() {
|
|
5853
|
+
const version = process.versions.node;
|
|
5854
|
+
const [major] = version.split(".").map(Number);
|
|
5855
|
+
const ok = major !== void 0 && major >= 22;
|
|
5856
|
+
return {
|
|
5857
|
+
label: "Node.js",
|
|
5858
|
+
ok,
|
|
5859
|
+
detail: ok ? `v${version}` : `v${version} (requires >= 22.16)`
|
|
5860
|
+
};
|
|
5808
5861
|
}
|
|
5809
|
-
function
|
|
5810
|
-
|
|
5862
|
+
function checkDocker() {
|
|
5863
|
+
try {
|
|
5864
|
+
return {
|
|
5865
|
+
label: "Docker",
|
|
5866
|
+
ok: true,
|
|
5867
|
+
detail: execFileSync("docker", ["--version"], {
|
|
5868
|
+
encoding: "utf-8",
|
|
5869
|
+
timeout: 5e3
|
|
5870
|
+
}).trim().replace("Docker version ", "v").split(",")[0] ?? ""
|
|
5871
|
+
};
|
|
5872
|
+
} catch {
|
|
5873
|
+
return {
|
|
5874
|
+
label: "Docker",
|
|
5875
|
+
ok: false,
|
|
5876
|
+
detail: "not found (optional — needed for auto PG provisioning)"
|
|
5877
|
+
};
|
|
5878
|
+
}
|
|
5811
5879
|
}
|
|
5812
|
-
function
|
|
5813
|
-
|
|
5814
|
-
const
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
"pipe",
|
|
5820
|
-
"pipe"
|
|
5821
|
-
]
|
|
5822
|
-
});
|
|
5823
|
-
if (res.status !== 0) return {
|
|
5824
|
-
state: "inactive",
|
|
5825
|
-
detail: "plist present but not loaded"
|
|
5880
|
+
function checkServerConfig() {
|
|
5881
|
+
const hasFile = existsSync(join(DEFAULT_CONFIG_DIR, "server.yaml"));
|
|
5882
|
+
const hasEnv = !!process.env.FIRST_TREE_HUB_DATABASE_URL;
|
|
5883
|
+
if (hasFile && hasEnv) return {
|
|
5884
|
+
label: "Config",
|
|
5885
|
+
ok: true,
|
|
5886
|
+
detail: "config file + env vars"
|
|
5826
5887
|
};
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5888
|
+
if (hasFile) return {
|
|
5889
|
+
label: "Config",
|
|
5890
|
+
ok: true,
|
|
5891
|
+
detail: join(DEFAULT_CONFIG_DIR, "server.yaml")
|
|
5892
|
+
};
|
|
5893
|
+
if (hasEnv) return {
|
|
5894
|
+
label: "Config",
|
|
5895
|
+
ok: true,
|
|
5896
|
+
detail: "via environment variables"
|
|
5897
|
+
};
|
|
5898
|
+
return {
|
|
5899
|
+
label: "Config",
|
|
5900
|
+
ok: false,
|
|
5901
|
+
detail: "no config file or env vars found"
|
|
5902
|
+
};
|
|
5903
|
+
}
|
|
5904
|
+
async function checkDatabase() {
|
|
5905
|
+
const dbUrl = get(getServerConfig(), "database.url");
|
|
5906
|
+
if (typeof dbUrl !== "string" || !dbUrl) return {
|
|
5907
|
+
label: "Database",
|
|
5908
|
+
ok: false,
|
|
5909
|
+
detail: "not configured (FIRST_TREE_HUB_DATABASE_URL or config file)"
|
|
5910
|
+
};
|
|
5911
|
+
try {
|
|
5912
|
+
const { default: pg } = await import("postgres");
|
|
5913
|
+
const sql = pg(dbUrl, {
|
|
5914
|
+
max: 1,
|
|
5915
|
+
connect_timeout: 5,
|
|
5916
|
+
idle_timeout: 1
|
|
5917
|
+
});
|
|
5918
|
+
await sql.unsafe("SELECT 1");
|
|
5919
|
+
await sql.end();
|
|
5920
|
+
return {
|
|
5921
|
+
label: "Database",
|
|
5922
|
+
ok: true,
|
|
5923
|
+
detail: "connected"
|
|
5924
|
+
};
|
|
5925
|
+
} catch (err) {
|
|
5926
|
+
return {
|
|
5927
|
+
label: "Database",
|
|
5928
|
+
ok: false,
|
|
5929
|
+
detail: `unreachable — ${(err instanceof Error ? err.message : String(err)).slice(0, 80)}`
|
|
5930
|
+
};
|
|
5931
|
+
}
|
|
5932
|
+
}
|
|
5933
|
+
async function checkServerHealth() {
|
|
5934
|
+
const config = getServerConfig();
|
|
5935
|
+
const host = get(config, "server.host") ?? "127.0.0.1";
|
|
5936
|
+
const port = get(config, "server.port") ?? 8e3;
|
|
5937
|
+
const url = `http://${host}:${port}/healthz`;
|
|
5938
|
+
try {
|
|
5939
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(3e3) });
|
|
5940
|
+
if (res.ok) return {
|
|
5941
|
+
label: "Server Health",
|
|
5942
|
+
ok: true,
|
|
5943
|
+
detail: `running at ${host}:${port}`
|
|
5944
|
+
};
|
|
5832
5945
|
return {
|
|
5833
|
-
|
|
5834
|
-
|
|
5946
|
+
label: "Server Health",
|
|
5947
|
+
ok: false,
|
|
5948
|
+
detail: `unhealthy (HTTP ${res.status}) at ${host}:${port}`
|
|
5949
|
+
};
|
|
5950
|
+
} catch {
|
|
5951
|
+
return {
|
|
5952
|
+
label: "Server Health",
|
|
5953
|
+
ok: false,
|
|
5954
|
+
detail: `not running at ${host}:${port}`
|
|
5835
5955
|
};
|
|
5836
5956
|
}
|
|
5957
|
+
}
|
|
5958
|
+
function checkClientConfig() {
|
|
5959
|
+
const hasFile = existsSync(join(DEFAULT_CONFIG_DIR, "client.yaml"));
|
|
5960
|
+
const hasEnv = !!process.env.FIRST_TREE_HUB_SERVER_URL;
|
|
5961
|
+
if (hasFile && hasEnv) return {
|
|
5962
|
+
label: "Config",
|
|
5963
|
+
ok: true,
|
|
5964
|
+
detail: "config file + env vars"
|
|
5965
|
+
};
|
|
5966
|
+
if (hasFile) return {
|
|
5967
|
+
label: "Config",
|
|
5968
|
+
ok: true,
|
|
5969
|
+
detail: join(DEFAULT_CONFIG_DIR, "client.yaml")
|
|
5970
|
+
};
|
|
5971
|
+
if (hasEnv) return {
|
|
5972
|
+
label: "Config",
|
|
5973
|
+
ok: true,
|
|
5974
|
+
detail: "via environment variables"
|
|
5975
|
+
};
|
|
5837
5976
|
return {
|
|
5838
|
-
|
|
5839
|
-
|
|
5977
|
+
label: "Config",
|
|
5978
|
+
ok: false,
|
|
5979
|
+
detail: "no config file or env vars found"
|
|
5840
5980
|
};
|
|
5841
5981
|
}
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
}
|
|
5861
|
-
|
|
5982
|
+
async function checkServerReachable() {
|
|
5983
|
+
const serverUrl = get(getClientConfig(), "server.url");
|
|
5984
|
+
if (typeof serverUrl !== "string" || !serverUrl) return {
|
|
5985
|
+
label: "Server URL",
|
|
5986
|
+
ok: false,
|
|
5987
|
+
detail: "not configured (FIRST_TREE_HUB_SERVER_URL or config file)"
|
|
5988
|
+
};
|
|
5989
|
+
try {
|
|
5990
|
+
const res = await fetch(`${serverUrl}/healthz`, { signal: AbortSignal.timeout(5e3) });
|
|
5991
|
+
if (res.ok) return {
|
|
5992
|
+
label: "Server URL",
|
|
5993
|
+
ok: true,
|
|
5994
|
+
detail: serverUrl
|
|
5995
|
+
};
|
|
5996
|
+
return {
|
|
5997
|
+
label: "Server URL",
|
|
5998
|
+
ok: false,
|
|
5999
|
+
detail: `unhealthy (HTTP ${res.status}) at ${serverUrl}`
|
|
6000
|
+
};
|
|
6001
|
+
} catch {
|
|
6002
|
+
return {
|
|
6003
|
+
label: "Server URL",
|
|
6004
|
+
ok: false,
|
|
6005
|
+
detail: `unreachable at ${serverUrl}`
|
|
6006
|
+
};
|
|
5862
6007
|
}
|
|
5863
|
-
return false;
|
|
5864
6008
|
}
|
|
5865
|
-
function
|
|
5866
|
-
const
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
const target = launchctlDomainTarget();
|
|
5872
|
-
const bootoutRes = runCapture("launchctl", ["bootout", `${target}/${LAUNCHD_LABEL}`], 15e3);
|
|
5873
|
-
if (!bootoutRes.ok) {
|
|
5874
|
-
if (!/not find|no such|not loaded/i.test(bootoutRes.stderr)) print.line(` warning: launchctl bootout: ${bootoutRes.stderr || `exit ${bootoutRes.code ?? "unknown"}`}\n`);
|
|
5875
|
-
}
|
|
5876
|
-
waitForLabelEvicted(target, LAUNCHD_LABEL, 1e4);
|
|
5877
|
-
let lastBootstrapErr = null;
|
|
5878
|
-
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
5879
|
-
const res = runCapture("launchctl", [
|
|
5880
|
-
"bootstrap",
|
|
5881
|
-
target,
|
|
5882
|
-
plistPath
|
|
5883
|
-
], 1e4);
|
|
5884
|
-
if (res.ok) {
|
|
5885
|
-
lastBootstrapErr = null;
|
|
5886
|
-
break;
|
|
5887
|
-
}
|
|
5888
|
-
lastBootstrapErr = res;
|
|
5889
|
-
if (attempt < 2) sleepSync(1e3);
|
|
5890
|
-
}
|
|
5891
|
-
if (lastBootstrapErr) throw new Error(`launchctl bootstrap failed: ${lastBootstrapErr.stderr || `exit ${lastBootstrapErr.code ?? "unknown"}`}\n Command: launchctl bootstrap ${target} ${plistPath}\n Recovery: \`launchctl bootout ${target}/${LAUNCHD_LABEL}\` then \`first-tree-hub service install\`.`);
|
|
5892
|
-
const enableRes = runCapture("launchctl", ["enable", `${target}/${LAUNCHD_LABEL}`], 5e3);
|
|
5893
|
-
if (!enableRes.ok) print.line(` warning: launchctl enable: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n`);
|
|
5894
|
-
const { state, detail } = launchdState();
|
|
5895
|
-
return {
|
|
5896
|
-
platform: "launchd",
|
|
5897
|
-
label: LAUNCHD_LABEL,
|
|
5898
|
-
unitPath: plistPath,
|
|
5899
|
-
logDir: LOG_DIR$1,
|
|
5900
|
-
state,
|
|
5901
|
-
detail
|
|
6009
|
+
function checkAgentConfigs() {
|
|
6010
|
+
const agentsDir = join(DEFAULT_CONFIG_DIR, "agents");
|
|
6011
|
+
if (!existsSync(agentsDir)) return {
|
|
6012
|
+
label: "Agents",
|
|
6013
|
+
ok: false,
|
|
6014
|
+
detail: "no agents configured"
|
|
5902
6015
|
};
|
|
6016
|
+
try {
|
|
6017
|
+
const agents = loadAgents({
|
|
6018
|
+
schema: agentConfigSchema,
|
|
6019
|
+
agentsDir
|
|
6020
|
+
});
|
|
6021
|
+
if (agents.size === 0) return {
|
|
6022
|
+
label: "Agents",
|
|
6023
|
+
ok: false,
|
|
6024
|
+
detail: "no agents configured"
|
|
6025
|
+
};
|
|
6026
|
+
const names = [...agents.keys()].join(", ");
|
|
6027
|
+
return {
|
|
6028
|
+
label: "Agents",
|
|
6029
|
+
ok: true,
|
|
6030
|
+
detail: `${agents.size} configured (${names})`
|
|
6031
|
+
};
|
|
6032
|
+
} catch {
|
|
6033
|
+
return {
|
|
6034
|
+
label: "Agents",
|
|
6035
|
+
ok: false,
|
|
6036
|
+
detail: "error reading agent configs"
|
|
6037
|
+
};
|
|
6038
|
+
}
|
|
5903
6039
|
}
|
|
5904
|
-
function
|
|
5905
|
-
const
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
6040
|
+
function checkBackgroundService() {
|
|
6041
|
+
const info = getClientServiceStatus();
|
|
6042
|
+
if (info.platform === "unsupported") return {
|
|
6043
|
+
label: "Background service",
|
|
6044
|
+
ok: true,
|
|
6045
|
+
detail: `not supported on ${process.platform} — runs inline`
|
|
6046
|
+
};
|
|
6047
|
+
if (info.state === "active") return {
|
|
6048
|
+
label: "Background service",
|
|
6049
|
+
ok: true,
|
|
6050
|
+
detail: `running (${info.platform}${info.detail ? `, ${info.detail}` : ""}); logs at ${info.logDir}`
|
|
6051
|
+
};
|
|
6052
|
+
if (info.state === "inactive") return {
|
|
6053
|
+
label: "Background service",
|
|
6054
|
+
ok: false,
|
|
6055
|
+
detail: `installed but not running${info.detail ? ` — ${info.detail}` : ""}; unit at ${info.unitPath}`
|
|
6056
|
+
};
|
|
5909
6057
|
return {
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
logDir: LOG_DIR$1,
|
|
5914
|
-
state: "not-installed"
|
|
6058
|
+
label: "Background service",
|
|
6059
|
+
ok: false,
|
|
6060
|
+
detail: "not installed — re-run `first-tree-hub client connect <url>` to install"
|
|
5915
6061
|
};
|
|
5916
6062
|
}
|
|
5917
|
-
function
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
6063
|
+
async function checkWebSocket() {
|
|
6064
|
+
const serverUrl = get(getClientConfig(), "server.url");
|
|
6065
|
+
if (typeof serverUrl !== "string" || !serverUrl) return {
|
|
6066
|
+
label: "WebSocket",
|
|
6067
|
+
ok: false,
|
|
6068
|
+
detail: "cannot check (no server URL)"
|
|
6069
|
+
};
|
|
6070
|
+
const wsUrl = serverUrl.replace(/^http/, "ws");
|
|
6071
|
+
try {
|
|
6072
|
+
if ((await fetch(`${serverUrl}/healthz`, { signal: AbortSignal.timeout(3e3) })).ok) return {
|
|
6073
|
+
label: "WebSocket",
|
|
6074
|
+
ok: true,
|
|
6075
|
+
detail: `${wsUrl} (server reachable)`
|
|
6076
|
+
};
|
|
6077
|
+
return {
|
|
6078
|
+
label: "WebSocket",
|
|
6079
|
+
ok: false,
|
|
6080
|
+
detail: "server not healthy"
|
|
6081
|
+
};
|
|
6082
|
+
} catch {
|
|
6083
|
+
return {
|
|
6084
|
+
label: "WebSocket",
|
|
6085
|
+
ok: false,
|
|
6086
|
+
detail: `server unreachable at ${serverUrl}`
|
|
6087
|
+
};
|
|
6088
|
+
}
|
|
5939
6089
|
}
|
|
5940
|
-
function
|
|
5941
|
-
|
|
5942
|
-
|
|
6090
|
+
function printResults(results) {
|
|
6091
|
+
for (const r of results) {
|
|
6092
|
+
const icon = r.ok ? "✓" : "✗";
|
|
6093
|
+
print.line(` ${icon} ${r.label.padEnd(22)} ${r.detail}\n`);
|
|
6094
|
+
}
|
|
6095
|
+
blank();
|
|
6096
|
+
const failures = results.filter((r) => !r.ok);
|
|
6097
|
+
if (failures.length === 0) print.line(" All checks passed.\n");
|
|
6098
|
+
else print.line(` ${failures.length} issue(s) found.\n`);
|
|
6099
|
+
blank();
|
|
5943
6100
|
}
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
"pipe",
|
|
5956
|
-
"pipe"
|
|
5957
|
-
]
|
|
5958
|
-
});
|
|
5959
|
-
const out = (res.stdout ?? "").trim();
|
|
5960
|
-
if (res.status === 0 && out === "active") return {
|
|
5961
|
-
state: "active",
|
|
5962
|
-
detail: "running"
|
|
5963
|
-
};
|
|
5964
|
-
return {
|
|
5965
|
-
state: "inactive",
|
|
5966
|
-
detail: out || "unit present but not active"
|
|
5967
|
-
};
|
|
6101
|
+
//#endregion
|
|
6102
|
+
//#region src/core/migrate.ts
|
|
6103
|
+
/**
|
|
6104
|
+
* Resolve the drizzle migrations directory.
|
|
6105
|
+
* 1. npm install: embedded at dist/drizzle/ (relative to the built CLI)
|
|
6106
|
+
* 2. Monorepo dev: resolved from @first-tree-hub/server package
|
|
6107
|
+
*/
|
|
6108
|
+
function resolveMigrationsFolder() {
|
|
6109
|
+
const embeddedPath = join(dirname(fileURLToPath(import.meta.url)), "..", "drizzle");
|
|
6110
|
+
if (existsSync(embeddedPath)) return embeddedPath;
|
|
6111
|
+
return join(dirname(fileURLToPath(import.meta.resolve("@first-tree-hub/server/package.json"))), "drizzle");
|
|
5968
6112
|
}
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
const
|
|
5976
|
-
if (!
|
|
5977
|
-
const
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
return {
|
|
5986
|
-
platform: "systemd",
|
|
5987
|
-
label: SYSTEMD_UNIT,
|
|
5988
|
-
unitPath,
|
|
5989
|
-
logDir: LOG_DIR$1,
|
|
5990
|
-
state,
|
|
5991
|
-
detail
|
|
5992
|
-
};
|
|
6113
|
+
/**
|
|
6114
|
+
* Validate that migration journal timestamps are strictly increasing.
|
|
6115
|
+
* Drizzle silently skips migrations whose `when` is <= the last applied
|
|
6116
|
+
* timestamp, which causes missing columns/tables with no error.
|
|
6117
|
+
*/
|
|
6118
|
+
function validateJournalOrder(migrationsFolder) {
|
|
6119
|
+
const journalPath = join(migrationsFolder, "meta", "_journal.json");
|
|
6120
|
+
if (!existsSync(journalPath)) return;
|
|
6121
|
+
const journal = JSON.parse(readFileSync(journalPath, "utf-8"));
|
|
6122
|
+
let prevWhen = 0;
|
|
6123
|
+
let prevTag = "";
|
|
6124
|
+
for (const entry of journal.entries) {
|
|
6125
|
+
if (entry.when <= prevWhen) throw new Error(`Migration journal timestamps are not monotonically increasing:\n "${prevTag}" (when: ${prevWhen}) >= "${entry.tag}" (when: ${entry.when})\n Drizzle will silently skip "${entry.tag}". Fix the 'when' values in:\n ${journalPath}`);
|
|
6126
|
+
prevWhen = entry.when;
|
|
6127
|
+
prevTag = entry.tag;
|
|
6128
|
+
}
|
|
5993
6129
|
}
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6130
|
+
/**
|
|
6131
|
+
* Run Drizzle database migrations.
|
|
6132
|
+
*/
|
|
6133
|
+
async function runMigrations(databaseUrl) {
|
|
6134
|
+
const migrationsFolder = resolveMigrationsFolder();
|
|
6135
|
+
validateJournalOrder(migrationsFolder);
|
|
6136
|
+
const client = postgres(databaseUrl, { max: 1 });
|
|
6137
|
+
const db = drizzle(client);
|
|
6138
|
+
try {
|
|
6139
|
+
await migrate(db, { migrationsFolder });
|
|
6140
|
+
} finally {
|
|
6141
|
+
await client.end();
|
|
6142
|
+
}
|
|
6143
|
+
const countClient = postgres(databaseUrl, { max: 1 });
|
|
6144
|
+
try {
|
|
6145
|
+
return (await countClient`
|
|
6146
|
+
SELECT count(*)::int AS count
|
|
6147
|
+
FROM information_schema.tables
|
|
6148
|
+
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
6149
|
+
`)[0].count;
|
|
6150
|
+
} finally {
|
|
6151
|
+
await countClient.end();
|
|
6152
|
+
}
|
|
6013
6153
|
}
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6154
|
+
//#endregion
|
|
6155
|
+
//#region src/core/migrate-agent-dirs.ts
|
|
6156
|
+
function createApiNameResolver(serverUrl, getAccessToken) {
|
|
6157
|
+
let cache = null;
|
|
6158
|
+
const PAGE_SIZE = 100;
|
|
6159
|
+
const MAX_PAGES = 50;
|
|
6160
|
+
async function ensureCache() {
|
|
6161
|
+
if (cache) return cache;
|
|
6162
|
+
const token = await getAccessToken();
|
|
6163
|
+
const map = /* @__PURE__ */ new Map();
|
|
6164
|
+
let cursor = null;
|
|
6165
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
6166
|
+
const qs = new URLSearchParams({ limit: String(PAGE_SIZE) });
|
|
6167
|
+
if (cursor) qs.set("cursor", cursor);
|
|
6168
|
+
const res = await fetch(`${serverUrl}/api/v1/admin/agents?${qs.toString()}`, {
|
|
6169
|
+
method: "GET",
|
|
6170
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
6171
|
+
signal: AbortSignal.timeout(1e4)
|
|
6172
|
+
});
|
|
6173
|
+
if (!res.ok) throw new Error(`admin agents list returned HTTP ${res.status}`);
|
|
6174
|
+
const body = await res.json();
|
|
6175
|
+
for (const row of body.items) map.set(row.uuid, row.name);
|
|
6176
|
+
if (!body.nextCursor) break;
|
|
6177
|
+
cursor = body.nextCursor;
|
|
6178
|
+
}
|
|
6179
|
+
cache = map;
|
|
6180
|
+
return map;
|
|
6181
|
+
}
|
|
6182
|
+
return { async resolveName(agentId) {
|
|
6183
|
+
return (await ensureCache()).get(agentId) ?? null;
|
|
6184
|
+
} };
|
|
6017
6185
|
}
|
|
6018
6186
|
/**
|
|
6019
|
-
*
|
|
6020
|
-
*
|
|
6021
|
-
*
|
|
6187
|
+
* Read the `agentId` field out of a single `agent.yaml` with minimal
|
|
6188
|
+
* parsing. Unlike `loadAgents`, which Zod-validates every entry and
|
|
6189
|
+
* throws on the first malformed file (aborting migration for *every*
|
|
6190
|
+
* other agent below it), this helper scopes failures per-dir: a broken
|
|
6191
|
+
* file returns `null` and the caller logs + skips that dir only.
|
|
6022
6192
|
*/
|
|
6023
|
-
function
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
6193
|
+
function readAgentId(agentYamlPath) {
|
|
6194
|
+
try {
|
|
6195
|
+
const parsed = parse(readFileSync(agentYamlPath, "utf-8"));
|
|
6196
|
+
if (parsed && typeof parsed === "object" && "agentId" in parsed) {
|
|
6197
|
+
const id = parsed.agentId;
|
|
6198
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
6199
|
+
}
|
|
6200
|
+
return null;
|
|
6201
|
+
} catch {
|
|
6202
|
+
return null;
|
|
6203
|
+
}
|
|
6027
6204
|
}
|
|
6028
|
-
/**
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6205
|
+
/**
|
|
6206
|
+
* Walk `agentsDir`, for each local agent compare the dir name to the
|
|
6207
|
+
* server's canonical `name`, and rename the dir + workspaces/sessions
|
|
6208
|
+
* entries when they differ. Returns a summary so the caller can decide
|
|
6209
|
+
* whether to print additional context.
|
|
6210
|
+
*/
|
|
6211
|
+
async function migrateLocalAgentDirs(opts) {
|
|
6212
|
+
const { agentsDir, workspacesDir, sessionsDir, resolver } = opts;
|
|
6213
|
+
const result = {
|
|
6214
|
+
scanned: 0,
|
|
6215
|
+
renamed: 0,
|
|
6216
|
+
skipped: 0,
|
|
6217
|
+
errors: 0
|
|
6218
|
+
};
|
|
6219
|
+
if (!existsSync(agentsDir)) return result;
|
|
6220
|
+
let dirNames;
|
|
6221
|
+
try {
|
|
6222
|
+
dirNames = readdirSync(agentsDir).filter((name) => {
|
|
6223
|
+
try {
|
|
6224
|
+
return statSync(join(agentsDir, name)).isDirectory();
|
|
6225
|
+
} catch {
|
|
6226
|
+
return false;
|
|
6227
|
+
}
|
|
6228
|
+
});
|
|
6229
|
+
} catch (err) {
|
|
6230
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6231
|
+
print.status("⚠️", `agent-dir migration: unable to enumerate ${agentsDir}: ${msg}`);
|
|
6032
6232
|
return {
|
|
6033
|
-
|
|
6034
|
-
|
|
6035
|
-
unitPath: launchdPlistPath(),
|
|
6036
|
-
logDir: LOG_DIR$1,
|
|
6037
|
-
state,
|
|
6038
|
-
detail
|
|
6233
|
+
...result,
|
|
6234
|
+
errors: result.errors + 1
|
|
6039
6235
|
};
|
|
6040
6236
|
}
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6237
|
+
const finalDirNames = new Set(dirNames);
|
|
6238
|
+
for (const dirName of dirNames) {
|
|
6239
|
+
const agentYamlPath = join(agentsDir, dirName, "agent.yaml");
|
|
6240
|
+
const agentId = readAgentId(agentYamlPath);
|
|
6241
|
+
if (!agentId) {
|
|
6242
|
+
if (existsSync(agentYamlPath)) {
|
|
6243
|
+
print.status("⚠️", `agent-dir migration: unreadable ${agentYamlPath}; skipping this dir.`);
|
|
6244
|
+
result.errors += 1;
|
|
6245
|
+
}
|
|
6246
|
+
continue;
|
|
6247
|
+
}
|
|
6248
|
+
result.scanned += 1;
|
|
6249
|
+
let serverName;
|
|
6250
|
+
try {
|
|
6251
|
+
serverName = await resolver.resolveName(agentId);
|
|
6252
|
+
} catch (err) {
|
|
6253
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6254
|
+
const hint = msg.includes("403") ? " (likely a non-admin account — migration skipped)" : "";
|
|
6255
|
+
print.status("⚠️", `agent-dir migration: failed to resolve "${dirName}" (${agentId}): ${msg}${hint}`);
|
|
6256
|
+
result.errors += 1;
|
|
6257
|
+
return result;
|
|
6258
|
+
}
|
|
6259
|
+
if (!serverName) {
|
|
6260
|
+
result.skipped += 1;
|
|
6261
|
+
continue;
|
|
6262
|
+
}
|
|
6263
|
+
if (serverName === dirName) continue;
|
|
6264
|
+
const oldDir = join(agentsDir, dirName);
|
|
6265
|
+
const newDir = join(agentsDir, serverName);
|
|
6266
|
+
if (existsSync(newDir)) {
|
|
6267
|
+
print.status("⚠️", `agent-dir migration: cannot rename "${dirName}" → "${serverName}" — target already exists. Skipping.`);
|
|
6268
|
+
result.skipped += 1;
|
|
6269
|
+
continue;
|
|
6270
|
+
}
|
|
6271
|
+
try {
|
|
6272
|
+
renameSync(oldDir, newDir);
|
|
6273
|
+
finalDirNames.delete(dirName);
|
|
6274
|
+
finalDirNames.add(serverName);
|
|
6275
|
+
} catch (err) {
|
|
6276
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6277
|
+
print.status("⚠️", `agent-dir migration: config dir rename failed for "${dirName}": ${msg}`);
|
|
6278
|
+
result.errors += 1;
|
|
6279
|
+
continue;
|
|
6280
|
+
}
|
|
6281
|
+
const oldWorkspace = join(workspacesDir, dirName);
|
|
6282
|
+
const newWorkspace = join(workspacesDir, serverName);
|
|
6283
|
+
if (existsSync(oldWorkspace)) try {
|
|
6284
|
+
if (existsSync(newWorkspace)) print.status("⚠️", `agent-dir migration: workspace target "${serverName}" already exists; leaving old "${dirName}" in place for manual cleanup.`);
|
|
6285
|
+
else if (statSync(oldWorkspace).isDirectory()) renameSync(oldWorkspace, newWorkspace);
|
|
6286
|
+
} catch (err) {
|
|
6287
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6288
|
+
print.status("⚠️", `agent-dir migration: workspace rename failed for "${dirName}": ${msg}`);
|
|
6289
|
+
result.errors += 1;
|
|
6290
|
+
}
|
|
6291
|
+
const oldSessions = join(sessionsDir, `${dirName}.json`);
|
|
6292
|
+
const newSessions = join(sessionsDir, `${serverName}.json`);
|
|
6293
|
+
if (existsSync(oldSessions)) try {
|
|
6294
|
+
if (existsSync(newSessions)) print.status("⚠️", `agent-dir migration: sessions target "${serverName}.json" already exists; leaving old "${dirName}.json" in place for manual cleanup.`);
|
|
6295
|
+
else renameSync(oldSessions, newSessions);
|
|
6296
|
+
} catch (err) {
|
|
6297
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6298
|
+
print.status("⚠️", `agent-dir migration: sessions rename failed for "${dirName}": ${msg}`);
|
|
6299
|
+
result.errors += 1;
|
|
6300
|
+
}
|
|
6301
|
+
print.status("", `agent "${dirName}" renamed to "${serverName}" to match hub`);
|
|
6302
|
+
result.renamed += 1;
|
|
6051
6303
|
}
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
6059
|
-
|
|
6060
|
-
}
|
|
6061
|
-
|
|
6062
|
-
|
|
6063
|
-
if (process.platform === "darwin") return uninstallLaunchd();
|
|
6064
|
-
if (process.platform === "linux") return uninstallSystemd();
|
|
6065
|
-
return getClientServiceStatus();
|
|
6304
|
+
try {
|
|
6305
|
+
const orphanWs = existsSync(workspacesDir) ? readdirSync(workspacesDir).filter((d) => !finalDirNames.has(d)) : [];
|
|
6306
|
+
const orphanSessions = existsSync(sessionsDir) ? readdirSync(sessionsDir).filter((f) => f.endsWith(".json") && !finalDirNames.has(f.slice(0, -5))) : [];
|
|
6307
|
+
if (orphanWs.length > 0 || orphanSessions.length > 0) {
|
|
6308
|
+
const parts = [];
|
|
6309
|
+
if (orphanWs.length > 0) parts.push(`workspaces: ${orphanWs.join(", ")}`);
|
|
6310
|
+
if (orphanSessions.length > 0) parts.push(`sessions: ${orphanSessions.join(", ")}`);
|
|
6311
|
+
print.status("", `orphaned local state detected (${parts.join("; ")}). Run \`first-tree-hub agent workspace clean\` to reclaim disk.`);
|
|
6312
|
+
}
|
|
6313
|
+
} catch {}
|
|
6314
|
+
return result;
|
|
6066
6315
|
}
|
|
6067
6316
|
//#endregion
|
|
6068
6317
|
//#region src/core/migrate-home.ts
|
|
@@ -6098,7 +6347,7 @@ function runHomeMigration() {
|
|
|
6098
6347
|
}
|
|
6099
6348
|
print.line(`[first-tree-hub] Copied client home to new layout: ${result.from} → ${result.to}\n (Legacy directory preserved as a backup — delete it manually once you've verified the new location works.)\n`);
|
|
6100
6349
|
if (process.argv.includes("--no-interactive")) {
|
|
6101
|
-
print.line("[first-tree-hub] Note: running as background service — skipped auto re-register to avoid self-termination.\n
|
|
6350
|
+
print.line("[first-tree-hub] Note: running as background service — skipped auto re-register to avoid self-termination.\n Service paths will refresh on the next `first-tree-hub client connect <url>`.\n");
|
|
6102
6351
|
return;
|
|
6103
6352
|
}
|
|
6104
6353
|
const status = getClientServiceStatus();
|
|
@@ -6108,7 +6357,7 @@ function runHomeMigration() {
|
|
|
6108
6357
|
print.line(`[first-tree-hub] Re-registered background service with new home paths.\n`);
|
|
6109
6358
|
} catch (err) {
|
|
6110
6359
|
const msg = err instanceof Error ? err.message : String(err);
|
|
6111
|
-
print.line(`[first-tree-hub] WARNING: home migration succeeded but re-registering the background service failed: ${msg}\n
|
|
6360
|
+
print.line(`[first-tree-hub] WARNING: home migration succeeded but re-registering the background service failed: ${msg}\n Re-run \`first-tree-hub client connect <url>\` to refresh service paths.\n`);
|
|
6112
6361
|
}
|
|
6113
6362
|
}
|
|
6114
6363
|
//#endregion
|
|
@@ -6253,8 +6502,9 @@ async function onboardCreate(args) {
|
|
|
6253
6502
|
metadata: Object.keys(metadata).length > 0 ? metadata : void 0,
|
|
6254
6503
|
clientId: args.type === "human" ? void 0 : args.clientId
|
|
6255
6504
|
});
|
|
6256
|
-
|
|
6257
|
-
|
|
6505
|
+
const primaryLocalName = primary.name ?? args.id;
|
|
6506
|
+
print.line(`Agent "${primaryLocalName}" created (uuid ${primary.uuid}).\n`);
|
|
6507
|
+
if (args.type !== "human") saveAgentConfig(primaryLocalName, primary.uuid, "claude-code");
|
|
6258
6508
|
let assistantUuid = null;
|
|
6259
6509
|
if (args.assistant) {
|
|
6260
6510
|
print.line(`Creating assistant "${args.assistant}"...\n`);
|
|
@@ -6270,8 +6520,9 @@ async function onboardCreate(args) {
|
|
|
6270
6520
|
clientId: args.clientId
|
|
6271
6521
|
});
|
|
6272
6522
|
assistantUuid = assistant.uuid;
|
|
6273
|
-
|
|
6274
|
-
|
|
6523
|
+
const assistantLocalName = assistant.name ?? args.assistant;
|
|
6524
|
+
saveAgentConfig(assistantLocalName, assistant.uuid, "claude-code");
|
|
6525
|
+
print.line(`Assistant "${assistantLocalName}" ready.\n`);
|
|
6275
6526
|
} catch (err) {
|
|
6276
6527
|
const msg = err instanceof Error ? err.message : String(err);
|
|
6277
6528
|
print.line(`Warning: Failed to create assistant "${args.assistant}": ${msg}\n`);
|
|
@@ -6279,7 +6530,7 @@ async function onboardCreate(args) {
|
|
|
6279
6530
|
}
|
|
6280
6531
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
6281
6532
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
6282
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
6533
|
+
const { bindFeishuBot } = await import("./feishu-DEmwoNn_.mjs").then((n) => n.r);
|
|
6283
6534
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
6284
6535
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
6285
6536
|
else {
|
|
@@ -6355,18 +6606,40 @@ async function promptMissingFields(options) {
|
|
|
6355
6606
|
return results;
|
|
6356
6607
|
}
|
|
6357
6608
|
/**
|
|
6358
|
-
* Interactive add agent
|
|
6359
|
-
|
|
6360
|
-
|
|
6609
|
+
* Interactive / scripted "add this agent to the local client".
|
|
6610
|
+
*
|
|
6611
|
+
* Phase 3 of the agent-naming refactor removed the free-form local
|
|
6612
|
+
* alias — the local config dir is keyed by the server-authoritative
|
|
6613
|
+
* `agent.name` slug. This helper only asks the user for the agent UUID
|
|
6614
|
+
* (or takes it via `opts.agentId`), then fetches the canonical name
|
|
6615
|
+
* from the Hub. A `name` comes back null only if the agent was
|
|
6616
|
+
* tombstoned server-side, in which case the caller must refuse the
|
|
6617
|
+
* add (there's nothing sensible to key the local dir on).
|
|
6618
|
+
*/
|
|
6619
|
+
async function promptAddAgent(opts = {}) {
|
|
6620
|
+
if (loadCredentials() === null) throw new Error("Not connected. Run `first-tree-hub client connect <server-url>` first.");
|
|
6621
|
+
let serverUrl;
|
|
6622
|
+
try {
|
|
6623
|
+
serverUrl = resolveServerUrl(process.env.FIRST_TREE_HUB_SERVER_URL);
|
|
6624
|
+
} catch (err) {
|
|
6625
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6626
|
+
throw new Error(`${msg} Run \`first-tree-hub client connect\` or set FIRST_TREE_HUB_SERVER_URL.`);
|
|
6627
|
+
}
|
|
6628
|
+
const agentId = opts.agentId ?? await input({
|
|
6629
|
+
message: "Agent UUID on the Hub:",
|
|
6630
|
+
validate: (v) => v.length > 0 ? true : "Agent UUID is required"
|
|
6631
|
+
});
|
|
6632
|
+
const token = await ensureFreshAccessToken();
|
|
6633
|
+
const res = await fetch(`${serverUrl}/api/v1/admin/agents/${encodeURIComponent(agentId)}`, {
|
|
6634
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
6635
|
+
signal: AbortSignal.timeout(1e4)
|
|
6636
|
+
});
|
|
6637
|
+
if (!res.ok) throw new Error(`Failed to look up agent ${agentId}: HTTP ${res.status}`);
|
|
6638
|
+
const body = await res.json();
|
|
6639
|
+
if (!body.name) throw new Error(`Agent ${agentId} has no hub name (tombstoned or never named). Cannot add a local config without a name.`);
|
|
6361
6640
|
return {
|
|
6362
|
-
name:
|
|
6363
|
-
|
|
6364
|
-
validate: (v) => /^[a-z0-9][a-z0-9-]*$/.test(v) ? true : "Lowercase alphanumeric and hyphens only"
|
|
6365
|
-
}),
|
|
6366
|
-
agentId: await input({
|
|
6367
|
-
message: "Agent UUID on the Hub:",
|
|
6368
|
-
validate: (v) => v.length > 0 ? true : "Agent UUID is required"
|
|
6369
|
-
})
|
|
6641
|
+
name: body.name,
|
|
6642
|
+
agentId
|
|
6370
6643
|
};
|
|
6371
6644
|
}
|
|
6372
6645
|
async function askPrompt(dotPath, prompt) {
|
|
@@ -7177,7 +7450,7 @@ function createFeedbackHandler(config) {
|
|
|
7177
7450
|
return { handle };
|
|
7178
7451
|
}
|
|
7179
7452
|
//#endregion
|
|
7180
|
-
//#region ../server/dist/app-
|
|
7453
|
+
//#region ../server/dist/app-DugUZNsw.mjs
|
|
7181
7454
|
var __defProp = Object.defineProperty;
|
|
7182
7455
|
var __exportAll = (all, no_symbols) => {
|
|
7183
7456
|
let target = {};
|
|
@@ -7244,7 +7517,7 @@ const agents = pgTable("agents", {
|
|
|
7244
7517
|
name: text("name"),
|
|
7245
7518
|
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
7246
7519
|
type: text("type").notNull(),
|
|
7247
|
-
displayName: text("display_name"),
|
|
7520
|
+
displayName: text("display_name").notNull(),
|
|
7248
7521
|
delegateMention: text("delegate_mention"),
|
|
7249
7522
|
inboxId: text("inbox_id").unique().notNull(),
|
|
7250
7523
|
status: text("status").notNull().default("active"),
|
|
@@ -8207,6 +8480,7 @@ async function createAgent(db, data) {
|
|
|
8207
8480
|
const uuid = uuidv7();
|
|
8208
8481
|
const name = data.name ?? null;
|
|
8209
8482
|
if (name?.startsWith(RESERVED_AGENT_NAME_PREFIX)) throw new BadRequestError(`Agent name "${name}" is reserved — names starting with "${RESERVED_AGENT_NAME_PREFIX}" are Hub-internal`);
|
|
8483
|
+
if (name && isReservedAgentName$1(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
|
|
8210
8484
|
const inboxId = `inbox_${uuid}`;
|
|
8211
8485
|
let orgId;
|
|
8212
8486
|
let managerId;
|
|
@@ -8234,13 +8508,14 @@ async function createAgent(db, data) {
|
|
|
8234
8508
|
if (org && org.maxAgents > 0) {
|
|
8235
8509
|
if (((await db.select({ value: count() }).from(agents).where(and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED))))[0]?.value ?? 0) >= org.maxAgents) throw new ForbiddenError(`Organization "${orgId}" has reached its agent limit (${org.maxAgents}). Upgrade your plan or delete unused agents.`);
|
|
8236
8510
|
}
|
|
8511
|
+
const resolvedDisplayName = data.displayName?.trim() || name || "Unnamed Agent";
|
|
8237
8512
|
try {
|
|
8238
8513
|
const [agent] = await db.insert(agents).values({
|
|
8239
8514
|
uuid,
|
|
8240
8515
|
name,
|
|
8241
8516
|
organizationId: orgId,
|
|
8242
8517
|
type: data.type,
|
|
8243
|
-
displayName:
|
|
8518
|
+
displayName: resolvedDisplayName,
|
|
8244
8519
|
delegateMention: data.delegateMention ?? null,
|
|
8245
8520
|
inboxId,
|
|
8246
8521
|
source: data.source ?? null,
|
|
@@ -8262,6 +8537,21 @@ async function createAgent(db, data) {
|
|
|
8262
8537
|
throw err;
|
|
8263
8538
|
}
|
|
8264
8539
|
}
|
|
8540
|
+
async function checkAgentNameAvailability(db, orgId, name) {
|
|
8541
|
+
if (!AGENT_NAME_REGEX$1.test(name)) return {
|
|
8542
|
+
available: false,
|
|
8543
|
+
reason: "invalid"
|
|
8544
|
+
};
|
|
8545
|
+
if (isReservedAgentName$1(name) || name.startsWith(RESERVED_AGENT_NAME_PREFIX)) return {
|
|
8546
|
+
available: false,
|
|
8547
|
+
reason: "reserved"
|
|
8548
|
+
};
|
|
8549
|
+
const [existing] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, orgId), eq(agents.name, name), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
|
|
8550
|
+
return existing ? {
|
|
8551
|
+
available: false,
|
|
8552
|
+
reason: "taken"
|
|
8553
|
+
} : { available: true };
|
|
8554
|
+
}
|
|
8265
8555
|
async function getAgent(db, uuid) {
|
|
8266
8556
|
const [agent] = await db.select().from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
|
|
8267
8557
|
if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
@@ -9163,26 +9453,33 @@ const inboxEntries = pgTable("inbox_entries", {
|
|
|
9163
9453
|
messageId: text("message_id").notNull().references(() => messages.id),
|
|
9164
9454
|
chatId: text("chat_id"),
|
|
9165
9455
|
status: text("status").notNull().default("pending"),
|
|
9456
|
+
notify: boolean("notify").notNull().default(true),
|
|
9166
9457
|
retryCount: integer("retry_count").notNull().default(0),
|
|
9167
9458
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
9168
9459
|
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
|
|
9169
9460
|
ackedAt: timestamp("acked_at", { withTimezone: true })
|
|
9170
|
-
}, (table) => [
|
|
9171
|
-
|
|
9461
|
+
}, (table) => [
|
|
9462
|
+
unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
|
|
9463
|
+
index("idx_inbox_pending").on(table.inboxId, table.createdAt),
|
|
9464
|
+
index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
|
|
9465
|
+
index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
|
|
9466
|
+
]);
|
|
9467
|
+
async function sendMessage(db, chatId, senderId, data, options = {}) {
|
|
9172
9468
|
return withSpan("inbox.enqueue", messageAttrs({
|
|
9173
9469
|
chatId,
|
|
9174
9470
|
senderAgentId: senderId,
|
|
9175
9471
|
source: data.source ?? void 0
|
|
9176
|
-
}), () => sendMessageInner(db, chatId, senderId, data));
|
|
9472
|
+
}), () => sendMessageInner(db, chatId, senderId, data, options));
|
|
9177
9473
|
}
|
|
9178
|
-
async function sendMessageInner(db, chatId, senderId, data) {
|
|
9474
|
+
async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
9179
9475
|
return db.transaction(async (tx) => {
|
|
9180
|
-
const participants = await tx.select({
|
|
9476
|
+
const [participants, [chatRow]] = await Promise.all([tx.select({
|
|
9181
9477
|
agentId: chatParticipants.agentId,
|
|
9182
9478
|
inboxId: agents.inboxId,
|
|
9183
9479
|
mode: chatParticipants.mode,
|
|
9184
9480
|
name: agents.name
|
|
9185
|
-
}).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
|
|
9481
|
+
}).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)), tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1)]);
|
|
9482
|
+
const chatType = chatRow?.type ?? null;
|
|
9186
9483
|
if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
|
|
9187
9484
|
const [senderRow] = await tx.select({ inboxId: agents.inboxId }).from(agents).where(eq(agents.uuid, senderId)).limit(1);
|
|
9188
9485
|
if (!senderRow || senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
|
|
@@ -9197,13 +9494,32 @@ async function sendMessageInner(db, chatId, senderId, data) {
|
|
|
9197
9494
|
...incomingMeta,
|
|
9198
9495
|
mentions: mergedMentions
|
|
9199
9496
|
} : incomingMeta;
|
|
9497
|
+
if (options.enforceGroupMention && chatType === "group") {
|
|
9498
|
+
if (mergedMentions.filter((id) => id !== senderId).length === 0) throw new BadRequestError("Sending to a group chat requires an explicit @mention. Use `agent send <name>` to message a single agent, or @<name> in the content to address one or more group members.");
|
|
9499
|
+
}
|
|
9500
|
+
let outboundContent = data.content;
|
|
9501
|
+
if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
|
|
9502
|
+
const present = new Set(scanMentionTokens(outboundContent));
|
|
9503
|
+
const missingNames = [];
|
|
9504
|
+
for (const id of mergedMentions) {
|
|
9505
|
+
if (id === senderId) continue;
|
|
9506
|
+
const p = participants.find((q) => q.agentId === id);
|
|
9507
|
+
if (!p?.name) continue;
|
|
9508
|
+
if (present.has(p.name.toLowerCase())) continue;
|
|
9509
|
+
missingNames.push(p.name);
|
|
9510
|
+
}
|
|
9511
|
+
if (missingNames.length > 0) {
|
|
9512
|
+
const prefix = missingNames.map((n) => `@${n}`).join(" ");
|
|
9513
|
+
outboundContent = outboundContent.length > 0 ? `${prefix} ${outboundContent}` : prefix;
|
|
9514
|
+
}
|
|
9515
|
+
}
|
|
9200
9516
|
const messageId = randomUUID();
|
|
9201
9517
|
const [msg] = await tx.insert(messages).values({
|
|
9202
9518
|
id: messageId,
|
|
9203
9519
|
chatId,
|
|
9204
9520
|
senderId,
|
|
9205
9521
|
format: data.format,
|
|
9206
|
-
content:
|
|
9522
|
+
content: outboundContent,
|
|
9207
9523
|
metadata: metadataToStore,
|
|
9208
9524
|
replyToInbox: data.replyToInbox ?? null,
|
|
9209
9525
|
replyToChat: data.replyToChat ?? null,
|
|
@@ -9211,13 +9527,14 @@ async function sendMessageInner(db, chatId, senderId, data) {
|
|
|
9211
9527
|
source: data.source ?? null
|
|
9212
9528
|
}).returning();
|
|
9213
9529
|
const mentionSet = new Set(mergedMentions);
|
|
9214
|
-
const entries = participants.filter((p) => p.agentId !== senderId).
|
|
9530
|
+
const entries = participants.filter((p) => p.agentId !== senderId).map((p) => ({
|
|
9215
9531
|
inboxId: p.inboxId,
|
|
9216
9532
|
messageId,
|
|
9217
|
-
chatId
|
|
9533
|
+
chatId,
|
|
9534
|
+
notify: p.mode !== "mention_only" || mentionSet.has(p.agentId)
|
|
9218
9535
|
}));
|
|
9219
9536
|
if (entries.length > 0) await tx.insert(inboxEntries).values(entries);
|
|
9220
|
-
const recipients = entries.map((e) => e.inboxId);
|
|
9537
|
+
const recipients = entries.filter((e) => e.notify).map((e) => e.inboxId);
|
|
9221
9538
|
if (data.inReplyTo) {
|
|
9222
9539
|
const [original] = await tx.select({
|
|
9223
9540
|
replyToInbox: messages.replyToInbox,
|
|
@@ -9248,14 +9565,23 @@ async function sendToAgent(db, senderUuid, targetName, data) {
|
|
|
9248
9565
|
if (!sender) throw new NotFoundError(`Agent "${senderUuid}" not found`);
|
|
9249
9566
|
const [target] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, sender.organizationId), eq(agents.name, targetName), ne(agents.status, "deleted"))).limit(1);
|
|
9250
9567
|
if (!target) throw new NotFoundError(`Agent "${targetName}" not found${/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(targetName) ? " — `agent send` expects an agent NAME, not a uuid. Run `first-tree-hub agent list` to see available names." : ""}`);
|
|
9251
|
-
|
|
9568
|
+
const chat = await findOrCreateDirectChat(db, senderUuid, target.uuid);
|
|
9569
|
+
const incomingMeta = data.metadata ?? {};
|
|
9570
|
+
const existingMentionsRaw = incomingMeta.mentions;
|
|
9571
|
+
const existingMentions = Array.isArray(existingMentionsRaw) ? existingMentionsRaw.filter((m) => typeof m === "string") : [];
|
|
9572
|
+
const mergedMentions = existingMentions.includes(target.uuid) ? existingMentions : [...existingMentions, target.uuid];
|
|
9573
|
+
const metadata = {
|
|
9574
|
+
...incomingMeta,
|
|
9575
|
+
mentions: mergedMentions
|
|
9576
|
+
};
|
|
9577
|
+
return sendMessage(db, chat.id, senderUuid, {
|
|
9252
9578
|
format: data.format,
|
|
9253
9579
|
content: data.content,
|
|
9254
|
-
metadata
|
|
9580
|
+
metadata,
|
|
9255
9581
|
replyToInbox: data.replyToInbox,
|
|
9256
9582
|
replyToChat: data.replyToChat,
|
|
9257
9583
|
source: data.source
|
|
9258
|
-
});
|
|
9584
|
+
}, { normalizeMentionsInContent: true });
|
|
9259
9585
|
}
|
|
9260
9586
|
async function editMessage(db, chatId, messageId, senderId, data) {
|
|
9261
9587
|
const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
|
|
@@ -9516,6 +9842,18 @@ async function adminAgentRoutes(app) {
|
|
|
9516
9842
|
nextCursor: result.nextCursor
|
|
9517
9843
|
};
|
|
9518
9844
|
});
|
|
9845
|
+
/**
|
|
9846
|
+
* Pre-create availability probe for the web creation form. The caller types
|
|
9847
|
+
* an agent name; we answer whether the POST would succeed (`available: true`)
|
|
9848
|
+
* or why it would fail (`invalid` / `reserved` / `taken`) without actually
|
|
9849
|
+
* inserting a row. The regular POST still validates authoritatively — this is
|
|
9850
|
+
* a pure UX convenience. Scoped to the caller's org, so two orgs can each
|
|
9851
|
+
* have a `coder` without one blocking the other.
|
|
9852
|
+
*/
|
|
9853
|
+
app.get("/names/:name/availability", async (request) => {
|
|
9854
|
+
const scope = memberScope(request);
|
|
9855
|
+
return await checkAgentNameAvailability(app.db, scope.organizationId, request.params.name);
|
|
9856
|
+
});
|
|
9519
9857
|
app.post("/", async (request, reply) => {
|
|
9520
9858
|
const scope = memberScope(request);
|
|
9521
9859
|
const body = createAgentSchema.parse(request.body);
|
|
@@ -9987,7 +10325,7 @@ async function adminChatRoutes(app) {
|
|
|
9987
10325
|
...body,
|
|
9988
10326
|
source: "hub_ui"
|
|
9989
10327
|
});
|
|
9990
|
-
const result = await sendMessage(app.db, chatId, member.agentId, prepared);
|
|
10328
|
+
const result = await sendMessage(app.db, chatId, member.agentId, prepared, { enforceGroupMention: true });
|
|
9991
10329
|
notifyRecipients(app.notifier, result.recipients, result.message.id);
|
|
9992
10330
|
return reply.status(201).send({
|
|
9993
10331
|
id: result.message.id,
|
|
@@ -11810,7 +12148,7 @@ async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
|
|
|
11810
12148
|
replyToChat: o.replyToChat
|
|
11811
12149
|
});
|
|
11812
12150
|
}
|
|
11813
|
-
return items.map(({ entryChatId, message: m }) => ({
|
|
12151
|
+
return items.map(({ entryChatId, message: m, precedingMessages = [] }) => ({
|
|
11814
12152
|
id: m.id,
|
|
11815
12153
|
chatId: m.chatId,
|
|
11816
12154
|
senderId: m.senderId,
|
|
@@ -11824,7 +12162,8 @@ async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
|
|
|
11824
12162
|
createdAt: m.createdAt,
|
|
11825
12163
|
configVersion: version,
|
|
11826
12164
|
recipientMode: modeByChat.get(entryChatId ?? m.chatId) ?? "full",
|
|
11827
|
-
inReplyToSnapshot: m.inReplyTo ? snapshotById.get(m.inReplyTo) ?? null : null
|
|
12165
|
+
inReplyToSnapshot: m.inReplyTo ? snapshotById.get(m.inReplyTo) ?? null : null,
|
|
12166
|
+
precedingMessages
|
|
11828
12167
|
}));
|
|
11829
12168
|
}
|
|
11830
12169
|
async function resolveAgentId(db, source) {
|
|
@@ -11835,6 +12174,7 @@ async function resolveAgentId(db, source) {
|
|
|
11835
12174
|
}
|
|
11836
12175
|
const DEFAULT_INBOX_TIMEOUT_SECONDS = 300;
|
|
11837
12176
|
const DEFAULT_MAX_RETRY_COUNT = 3;
|
|
12177
|
+
const PRECEDING_CONTEXT_WINDOW_SECONDS = 1440 * 60;
|
|
11838
12178
|
async function pollInbox(db, inboxId, limit) {
|
|
11839
12179
|
return withSpan("inbox.deliver", {
|
|
11840
12180
|
"inbox.id": inboxId,
|
|
@@ -11848,7 +12188,7 @@ async function pollInboxInner(db, inboxId, limit) {
|
|
|
11848
12188
|
SET status = 'delivered', delivered_at = NOW()
|
|
11849
12189
|
WHERE id IN (
|
|
11850
12190
|
SELECT id FROM inbox_entries
|
|
11851
|
-
WHERE inbox_id = ${inboxId} AND status = 'pending'
|
|
12191
|
+
WHERE inbox_id = ${inboxId} AND status = 'pending' AND notify = true
|
|
11852
12192
|
ORDER BY created_at
|
|
11853
12193
|
LIMIT ${limit}
|
|
11854
12194
|
FOR UPDATE SKIP LOCKED
|
|
@@ -11856,6 +12196,8 @@ async function pollInboxInner(db, inboxId, limit) {
|
|
|
11856
12196
|
RETURNING *
|
|
11857
12197
|
`);
|
|
11858
12198
|
if (claimed.length === 0) return [];
|
|
12199
|
+
claimed.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
12200
|
+
const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
|
|
11859
12201
|
const messageIds = claimed.map((e) => e.message_id);
|
|
11860
12202
|
const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
|
|
11861
12203
|
const msgMap = new Map(msgs.map((m) => [m.id, m]));
|
|
@@ -11864,6 +12206,7 @@ async function pollInboxInner(db, inboxId, limit) {
|
|
|
11864
12206
|
if (!msg) throw new Error(`Unexpected: message ${entry.message_id} not found`);
|
|
11865
12207
|
return {
|
|
11866
12208
|
entryChatId: entry.chat_id,
|
|
12209
|
+
precedingMessages: precedingByEntryId.get(entry.id) ?? [],
|
|
11867
12210
|
message: {
|
|
11868
12211
|
id: msg.id,
|
|
11869
12212
|
chatId: msg.chatId,
|
|
@@ -11897,6 +12240,69 @@ async function pollInboxInner(db, inboxId, limit) {
|
|
|
11897
12240
|
});
|
|
11898
12241
|
});
|
|
11899
12242
|
}
|
|
12243
|
+
/**
|
|
12244
|
+
* Per claimed trigger: SELECT silent (notify=false) pending rows in the same
|
|
12245
|
+
* chat that occurred between the previous trigger in this batch (or beginning
|
|
12246
|
+
* of time) and this trigger, capped by `PRECEDING_CONTEXT_MAX_ENTRIES` and
|
|
12247
|
+
* `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
|
|
12248
|
+
*
|
|
12249
|
+
* Side effect: bulk-ack ALL silent pending rows in each chat with
|
|
12250
|
+
* created_at < latest_trigger.created_at — including ones that fell outside
|
|
12251
|
+
* the window/cap. Otherwise stale silent rows would accumulate and re-load
|
|
12252
|
+
* on every poll.
|
|
12253
|
+
*/
|
|
12254
|
+
async function collectPrecedingContext(tx, inboxId, triggers) {
|
|
12255
|
+
const result = /* @__PURE__ */ new Map();
|
|
12256
|
+
const byChat = /* @__PURE__ */ new Map();
|
|
12257
|
+
for (const t of triggers) {
|
|
12258
|
+
if (t.chat_id === null) continue;
|
|
12259
|
+
const list = byChat.get(t.chat_id) ?? [];
|
|
12260
|
+
list.push(t);
|
|
12261
|
+
byChat.set(t.chat_id, list);
|
|
12262
|
+
}
|
|
12263
|
+
for (const [chatId, chatTriggers] of byChat) {
|
|
12264
|
+
chatTriggers.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
12265
|
+
let prevCreatedAt = null;
|
|
12266
|
+
for (const trigger of chatTriggers) {
|
|
12267
|
+
const preceding = (await tx.execute(sql`
|
|
12268
|
+
SELECT ie.id, m.id AS message_id, m.sender_id, m.format, m.content, m.metadata,
|
|
12269
|
+
m.created_at
|
|
12270
|
+
FROM inbox_entries ie
|
|
12271
|
+
JOIN messages m ON m.id = ie.message_id
|
|
12272
|
+
WHERE ie.inbox_id = ${inboxId}
|
|
12273
|
+
AND ie.chat_id = ${chatId}
|
|
12274
|
+
AND ie.status = 'pending'
|
|
12275
|
+
AND ie.notify = false
|
|
12276
|
+
AND ie.created_at < ${trigger.created_at}
|
|
12277
|
+
${prevCreatedAt === null ? sql`` : sql`AND ie.created_at > ${prevCreatedAt}`}
|
|
12278
|
+
AND ie.created_at > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})
|
|
12279
|
+
ORDER BY ie.created_at DESC
|
|
12280
|
+
LIMIT ${50}
|
|
12281
|
+
FOR UPDATE OF ie SKIP LOCKED
|
|
12282
|
+
`)).map((r) => ({
|
|
12283
|
+
id: r.message_id,
|
|
12284
|
+
senderId: r.sender_id,
|
|
12285
|
+
format: r.format,
|
|
12286
|
+
content: r.content,
|
|
12287
|
+
metadata: r.metadata ?? {},
|
|
12288
|
+
createdAt: r.created_at
|
|
12289
|
+
})).reverse();
|
|
12290
|
+
result.set(trigger.id, preceding);
|
|
12291
|
+
prevCreatedAt = trigger.created_at;
|
|
12292
|
+
}
|
|
12293
|
+
const latestTrigger = chatTriggers[chatTriggers.length - 1];
|
|
12294
|
+
if (latestTrigger) await tx.execute(sql`
|
|
12295
|
+
UPDATE inbox_entries
|
|
12296
|
+
SET status = 'acked', acked_at = NOW()
|
|
12297
|
+
WHERE inbox_id = ${inboxId}
|
|
12298
|
+
AND chat_id = ${chatId}
|
|
12299
|
+
AND status = 'pending'
|
|
12300
|
+
AND notify = false
|
|
12301
|
+
AND created_at < ${latestTrigger.created_at}
|
|
12302
|
+
`);
|
|
12303
|
+
}
|
|
12304
|
+
return result;
|
|
12305
|
+
}
|
|
11900
12306
|
async function ackEntry$2(db, entryId, inboxId) {
|
|
11901
12307
|
return withSpan("inbox.ack", {
|
|
11902
12308
|
[FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId),
|
|
@@ -11935,6 +12341,49 @@ async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_S
|
|
|
11935
12341
|
failed: failedResult.length
|
|
11936
12342
|
};
|
|
11937
12343
|
}
|
|
12344
|
+
/** Default age (30 days) past which silent rows that no notify-true delivery
|
|
12345
|
+
* ever picked up are physically deleted. */
|
|
12346
|
+
const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
|
|
12347
|
+
/**
|
|
12348
|
+
* Garbage-collect silent inbox rows so the table doesn't grow forever in
|
|
12349
|
+
* chats where a `mention_only` agent is never @mentioned.
|
|
12350
|
+
*
|
|
12351
|
+
* Two cleanup paths:
|
|
12352
|
+
*
|
|
12353
|
+
* 1. `notify=false AND status='acked'` of any age — these are fully
|
|
12354
|
+
* consumed (either bundled into a previous trigger or aged out via the
|
|
12355
|
+
* bulk-ack in `collectPrecedingContext`); keep them only as long as
|
|
12356
|
+
* the corresponding message rows we link to. The unique constraint
|
|
12357
|
+
* `(inbox_id, message_id, chat_id)` means leaving them around blocks
|
|
12358
|
+
* legitimate retries with the same key.
|
|
12359
|
+
*
|
|
12360
|
+
* 2. `notify=false AND status='pending' AND created_at < NOW() - maxAge` —
|
|
12361
|
+
* stale silent rows that no trigger ever caught up with. After 30
|
|
12362
|
+
* days they're useless as preceding context (the @mention almost
|
|
12363
|
+
* certainly already happened or the chat went dormant).
|
|
12364
|
+
*
|
|
12365
|
+
* Returns the number of rows deleted in each bucket so the background task
|
|
12366
|
+
* can log meaningful counts.
|
|
12367
|
+
*/
|
|
12368
|
+
async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
|
|
12369
|
+
const ackedResult = await db.execute(sql`
|
|
12370
|
+
DELETE FROM inbox_entries
|
|
12371
|
+
WHERE notify = false
|
|
12372
|
+
AND status = 'acked'
|
|
12373
|
+
RETURNING id
|
|
12374
|
+
`);
|
|
12375
|
+
const staleResult = await db.execute(sql`
|
|
12376
|
+
DELETE FROM inbox_entries
|
|
12377
|
+
WHERE notify = false
|
|
12378
|
+
AND status = 'pending'
|
|
12379
|
+
AND created_at < NOW() - make_interval(secs => ${maxAgeSeconds})
|
|
12380
|
+
RETURNING id
|
|
12381
|
+
`);
|
|
12382
|
+
return {
|
|
12383
|
+
ackedDeleted: ackedResult.length,
|
|
12384
|
+
stalePendingDeleted: staleResult.length
|
|
12385
|
+
};
|
|
12386
|
+
}
|
|
11938
12387
|
async function agentInboxRoutes(app) {
|
|
11939
12388
|
app.get("/", async (request) => {
|
|
11940
12389
|
const identity = requireAgent(request);
|
|
@@ -11982,7 +12431,10 @@ async function agentMessageRoutes(app) {
|
|
|
11982
12431
|
await assertParticipant(app.db, request.params.chatId, identity.uuid);
|
|
11983
12432
|
const body = sendMessageSchema.parse(request.body);
|
|
11984
12433
|
const prepared = await prepareImageOutbound(app.db, app.notifier, request.params.chatId, body);
|
|
11985
|
-
const { message: msg, recipients } = await sendMessage(app.db, request.params.chatId, identity.uuid, prepared
|
|
12434
|
+
const { message: msg, recipients } = await sendMessage(app.db, request.params.chatId, identity.uuid, prepared, {
|
|
12435
|
+
enforceGroupMention: true,
|
|
12436
|
+
normalizeMentionsInContent: true
|
|
12437
|
+
});
|
|
11986
12438
|
notifyRecipients(app.notifier, recipients, msg.id);
|
|
11987
12439
|
return reply.status(201).send({
|
|
11988
12440
|
...msg,
|
|
@@ -14095,6 +14547,11 @@ function createBackgroundTasks(app, instanceId, adapterManager, kaelRuntime) {
|
|
|
14095
14547
|
const timeoutSeconds = configs.inbox_timeout_seconds ?? 300;
|
|
14096
14548
|
const maxRetries = configs.max_retry_count ?? 3;
|
|
14097
14549
|
await resetTimedOutEntries(app.db, timeoutSeconds, maxRetries);
|
|
14550
|
+
const pruned = await pruneStaleSilentEntries(app.db);
|
|
14551
|
+
if (pruned.ackedDeleted > 0 || pruned.stalePendingDeleted > 0) log.debug({
|
|
14552
|
+
ackedDeleted: pruned.ackedDeleted,
|
|
14553
|
+
stalePendingDeleted: pruned.stalePendingDeleted
|
|
14554
|
+
}, "pruned silent inbox rows");
|
|
14098
14555
|
} catch (err) {
|
|
14099
14556
|
log.error({ err }, "failed to reset timed-out inbox entries");
|
|
14100
14557
|
}
|
|
@@ -15031,7 +15488,7 @@ async function startServer(options) {
|
|
|
15031
15488
|
instanceId: `srv_${randomUUID().slice(0, 8)}`,
|
|
15032
15489
|
commandVersion: COMMAND_VERSION
|
|
15033
15490
|
};
|
|
15034
|
-
const { initTelemetry, shutdownTelemetry } = await import("./observability-
|
|
15491
|
+
const { initTelemetry, shutdownTelemetry } = await import("./observability-DDkJwSKv.mjs");
|
|
15035
15492
|
await initTelemetry(serverConfig.observability.tracing, config.instanceId);
|
|
15036
15493
|
const app = await buildApp(config);
|
|
15037
15494
|
const shutdown = async () => {
|
|
@@ -15081,175 +15538,6 @@ function resolveWebDist() {
|
|
|
15081
15538
|
} catch {}
|
|
15082
15539
|
}
|
|
15083
15540
|
//#endregion
|
|
15084
|
-
//#region src/core/service-logs.ts
|
|
15085
|
-
const LOG_DIR = join(DEFAULT_HOME_DIR$1, "logs");
|
|
15086
|
-
const PRIMARY_LOG = join(LOG_DIR, "client.log");
|
|
15087
|
-
const FALLBACK_STDOUT = join(LOG_DIR, "client.stdout.log");
|
|
15088
|
-
const FALLBACK_STDERR = join(LOG_DIR, "client.stderr.log");
|
|
15089
|
-
/**
|
|
15090
|
-
* Duration string → milliseconds. Accepts `10s`, `5m`, `2h`, `1d`; rejects
|
|
15091
|
-
* everything else. Keeps the parser tiny rather than pulling in a library —
|
|
15092
|
-
* the `--since` flag is the only consumer.
|
|
15093
|
-
*/
|
|
15094
|
-
function parseDuration(input) {
|
|
15095
|
-
const match = /^(\d+)\s*(s|m|h|d)$/.exec(input.trim());
|
|
15096
|
-
if (!match) throw new Error(`invalid duration "${input}" (expected e.g. 30s, 5m, 2h, 1d)`);
|
|
15097
|
-
return Number(match[1]) * ({
|
|
15098
|
-
s: 1e3,
|
|
15099
|
-
m: 6e4,
|
|
15100
|
-
h: 36e5,
|
|
15101
|
-
d: 864e5
|
|
15102
|
-
}[match[2]] ?? 0);
|
|
15103
|
-
}
|
|
15104
|
-
const LEVEL_RANK = {
|
|
15105
|
-
trace: 10,
|
|
15106
|
-
debug: 20,
|
|
15107
|
-
info: 30,
|
|
15108
|
-
warn: 40,
|
|
15109
|
-
error: 50,
|
|
15110
|
-
fatal: 60
|
|
15111
|
-
};
|
|
15112
|
-
/** Rotated log files, newest-first. Missing files are silently skipped. */
|
|
15113
|
-
function listLogFilesNewestFirst() {
|
|
15114
|
-
const files = [];
|
|
15115
|
-
if (existsSync(PRIMARY_LOG)) files.push(PRIMARY_LOG);
|
|
15116
|
-
for (let i = 1;; i++) {
|
|
15117
|
-
const p = `${PRIMARY_LOG}.${i}`;
|
|
15118
|
-
if (!existsSync(p)) break;
|
|
15119
|
-
files.push(p);
|
|
15120
|
-
}
|
|
15121
|
-
return files;
|
|
15122
|
-
}
|
|
15123
|
-
/** Supervisor fallback files (raw stdout/stderr, not NDJSON). Missing files skipped. */
|
|
15124
|
-
function listFallbackFiles() {
|
|
15125
|
-
const files = [];
|
|
15126
|
-
if (existsSync(FALLBACK_STDERR)) files.push(FALLBACK_STDERR);
|
|
15127
|
-
if (existsSync(FALLBACK_STDOUT)) files.push(FALLBACK_STDOUT);
|
|
15128
|
-
return files;
|
|
15129
|
-
}
|
|
15130
|
-
function matchesFilters(obj, minLevel, cutoffMs) {
|
|
15131
|
-
if (minLevel !== void 0) {
|
|
15132
|
-
const lvl = typeof obj.level === "number" ? obj.level : NaN;
|
|
15133
|
-
if (!Number.isFinite(lvl) || lvl < minLevel) return false;
|
|
15134
|
-
}
|
|
15135
|
-
if (cutoffMs !== void 0) {
|
|
15136
|
-
const t = parseLogTime(obj.time);
|
|
15137
|
-
if (t === null || t < cutoffMs) return false;
|
|
15138
|
-
}
|
|
15139
|
-
return true;
|
|
15140
|
-
}
|
|
15141
|
-
/** Logger writes `time` as a local-ish string (`YYYY-MM-DD HH:mm:ss`). */
|
|
15142
|
-
function parseLogTime(value) {
|
|
15143
|
-
if (typeof value === "number") return value;
|
|
15144
|
-
if (typeof value !== "string") return null;
|
|
15145
|
-
const iso = value.replace(" ", "T");
|
|
15146
|
-
const ms = Date.parse(iso);
|
|
15147
|
-
return Number.isFinite(ms) ? ms : null;
|
|
15148
|
-
}
|
|
15149
|
-
function renderLine(line, json) {
|
|
15150
|
-
if (!line.trim()) return null;
|
|
15151
|
-
if (json) return `${line}\n`;
|
|
15152
|
-
try {
|
|
15153
|
-
return formatPrettyEntry$1(line);
|
|
15154
|
-
} catch {
|
|
15155
|
-
return `${line}\n`;
|
|
15156
|
-
}
|
|
15157
|
-
}
|
|
15158
|
-
function processLogLine(line, minLevel, cutoffMs, json) {
|
|
15159
|
-
let obj;
|
|
15160
|
-
try {
|
|
15161
|
-
obj = JSON.parse(line);
|
|
15162
|
-
} catch {
|
|
15163
|
-
return json ? null : `${line}\n`;
|
|
15164
|
-
}
|
|
15165
|
-
if (!matchesFilters(obj, minLevel, cutoffMs)) return null;
|
|
15166
|
-
return renderLine(line, json);
|
|
15167
|
-
}
|
|
15168
|
-
async function readFileLines(path, minLevel, cutoffMs, json) {
|
|
15169
|
-
const rl = createInterface({ input: createReadStream(path, { encoding: "utf8" }) });
|
|
15170
|
-
for await (const line of rl) {
|
|
15171
|
-
const rendered = processLogLine(line, minLevel, cutoffMs, json);
|
|
15172
|
-
if (rendered) process.stdout.write(rendered);
|
|
15173
|
-
}
|
|
15174
|
-
}
|
|
15175
|
-
/**
|
|
15176
|
-
* Read a supervisor fallback file (launchd / systemd stdout/stderr capture).
|
|
15177
|
-
* These are plain text, not NDJSON: level and time filters don't apply, so we
|
|
15178
|
-
* honour `--since` by dropping the whole file when its mtime predates the
|
|
15179
|
-
* cutoff and otherwise pass every line through. In pretty mode each line is
|
|
15180
|
-
* tagged with the source so operators can tell it apart from pino output; in
|
|
15181
|
-
* `--json` mode we emit a synthetic record so NDJSON consumers keep one
|
|
15182
|
-
* object per line.
|
|
15183
|
-
*/
|
|
15184
|
-
async function readFallbackFile(path, cutoffMs, json) {
|
|
15185
|
-
try {
|
|
15186
|
-
const mtime = statSync(path).mtimeMs;
|
|
15187
|
-
if (cutoffMs !== void 0 && mtime < cutoffMs) return;
|
|
15188
|
-
} catch {
|
|
15189
|
-
return;
|
|
15190
|
-
}
|
|
15191
|
-
const source = path.endsWith(".stderr.log") ? "supervisor:stderr" : "supervisor:stdout";
|
|
15192
|
-
const rl = createInterface({ input: createReadStream(path, { encoding: "utf8" }) });
|
|
15193
|
-
for await (const line of rl) {
|
|
15194
|
-
if (!line.trim()) continue;
|
|
15195
|
-
if (json) process.stdout.write(`${JSON.stringify({
|
|
15196
|
-
source,
|
|
15197
|
-
line
|
|
15198
|
-
})}\n`);
|
|
15199
|
-
else process.stdout.write(`[${source}] ${line}\n`);
|
|
15200
|
-
}
|
|
15201
|
-
}
|
|
15202
|
-
/**
|
|
15203
|
-
* Print existing log history, applying filters. `--tail` then switches to
|
|
15204
|
-
* follow mode and keeps printing new lines as the active file grows; rotation
|
|
15205
|
-
* is not handled during the tail (a follow-up rotation will simply stop
|
|
15206
|
-
* emitting new lines — operator can re-run the command).
|
|
15207
|
-
*/
|
|
15208
|
-
async function showServiceLogs(options) {
|
|
15209
|
-
if (!existsSync(LOG_DIR)) {
|
|
15210
|
-
print.status("logs", `directory not found: ${LOG_DIR}`);
|
|
15211
|
-
return;
|
|
15212
|
-
}
|
|
15213
|
-
const minLevel = options.level ? LEVEL_RANK[options.level] : void 0;
|
|
15214
|
-
const cutoffMs = options.sinceMs !== void 0 ? Date.now() - options.sinceMs : void 0;
|
|
15215
|
-
for (const f of listFallbackFiles()) await readFallbackFile(f, cutoffMs, options.json);
|
|
15216
|
-
const files = listLogFilesNewestFirst().reverse();
|
|
15217
|
-
for (const f of files) await readFileLines(f, minLevel, cutoffMs, options.json);
|
|
15218
|
-
if (!options.tail) return;
|
|
15219
|
-
if (!existsSync(PRIMARY_LOG)) print.status("tail", "waiting for client.log to appear...");
|
|
15220
|
-
await new Promise((resolve) => {
|
|
15221
|
-
let position = existsSync(PRIMARY_LOG) ? statSync(PRIMARY_LOG).size : 0;
|
|
15222
|
-
const onChange = () => {
|
|
15223
|
-
if (!existsSync(PRIMARY_LOG)) return;
|
|
15224
|
-
const current = statSync(PRIMARY_LOG).size;
|
|
15225
|
-
if (current < position) position = 0;
|
|
15226
|
-
if (current <= position) return;
|
|
15227
|
-
const stream = createReadStream(PRIMARY_LOG, {
|
|
15228
|
-
start: position,
|
|
15229
|
-
end: current - 1,
|
|
15230
|
-
encoding: "utf8"
|
|
15231
|
-
});
|
|
15232
|
-
position = current;
|
|
15233
|
-
createInterface({ input: stream }).on("line", (line) => {
|
|
15234
|
-
const rendered = processLogLine(line, minLevel, cutoffMs, options.json);
|
|
15235
|
-
if (rendered) process.stdout.write(rendered);
|
|
15236
|
-
});
|
|
15237
|
-
};
|
|
15238
|
-
watchFile(PRIMARY_LOG, { interval: 500 }, onChange);
|
|
15239
|
-
process.once("SIGINT", () => {
|
|
15240
|
-
unwatchFile(PRIMARY_LOG, onChange);
|
|
15241
|
-
resolve();
|
|
15242
|
-
});
|
|
15243
|
-
});
|
|
15244
|
-
}
|
|
15245
|
-
/** Validated flag parsers the CLI layer can reuse without re-doing the work. */
|
|
15246
|
-
function validateLevel(value) {
|
|
15247
|
-
if (value === void 0) return void 0;
|
|
15248
|
-
const parsed = parseLogLevel$1(value);
|
|
15249
|
-
if (parsed.fellBack) throw new Error(`invalid --level "${value}" (expected one of ${LOG_LEVELS$1.join(", ")})`);
|
|
15250
|
-
return parsed.level;
|
|
15251
|
-
}
|
|
15252
|
-
//#endregion
|
|
15253
15541
|
//#region src/core/update.ts
|
|
15254
15542
|
const PACKAGE_NAME = "@agent-team-foundation/first-tree-hub";
|
|
15255
15543
|
/**
|
|
@@ -15426,4 +15714,4 @@ function createExecuteUpdate({ managed }) {
|
|
|
15426
15714
|
};
|
|
15427
15715
|
}
|
|
15428
15716
|
//#endregion
|
|
15429
|
-
export { configureClientLoggerForService as $,
|
|
15717
|
+
export { configureClientLoggerForService as $, installClientService as A, createOwner as B, checkNodeVersion as C, checkWebSocket as D, checkServerReachable as E, isDockerAvailable as F, setJsonMode as G, resolveReplyToFromEnv as H, stopPostgres as I, FirstTreeHubSDK as J, status as K, ClientRuntime as L, resolveCliInvocation as M, uninstallClientService as N, printResults as O, ensurePostgres as P, applyClientLoggerConfig as Q, handleClientOrgMismatch as R, checkDocker as S, checkServerHealth as T, blank as U, hasUser as V, print as W, SessionRegistry as X, SdkError as Y, cleanWorkspaces as Z, runMigrations as _, COMMAND_VERSION as a, checkClientConfig as b, promptMissingFields as c, onboardCheck as d, onboardCreate as f, migrateLocalAgentDirs as g, createApiNameResolver as h, startServer as i, isServiceSupported as j, getClientServiceStatus as k, formatCheckReport as l, runHomeMigration as m, declineUpdate as n, isInteractive as o, saveOnboardState as p, ClientOrgMismatchError$1 as q, promptUpdate as r, promptAddAgent as s, createExecuteUpdate as t, loadOnboardState as u, checkAgentConfigs as v, checkServerConfig as w, checkDatabase as x, checkBackgroundService as y, rotateClientIdWithBackup as z };
|