@agent-team-foundation/first-tree-hub 0.11.4 → 0.11.5
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-D-Yf8yOc.mjs → bootstrap-C_K2CKXC.mjs} +7 -0
- package/dist/cli/index.mjs +71 -9
- package/dist/client-D_TRJFZY-LbgJF47t.mjs +4 -0
- package/dist/{client-CLdRbuml-svTO0Eat.mjs → client-DqdGiggm-NQoGZ2vM.mjs} +3 -3
- package/dist/{dist-ClFs4WMj.mjs → dist-CfvCT4E0.mjs} +266 -194
- package/dist/drizzle/0033_onboarding_dismissed_at.sql +13 -0
- package/dist/drizzle/meta/_journal.json +7 -0
- package/dist/{errors-BmyRwN0Y-Dad3eV8F.mjs → errors-CF5evtJt-B0NTIVPt.mjs} +2 -1
- package/dist/{feishu-AI3pwmqN.mjs → feishu-DbSvp9UH.mjs} +1 -1
- package/dist/index.mjs +7 -7
- package/dist/{invitation-Dnn5gGGX-DXryyvRG.mjs → invitation-Bg0TRiyx-BsZH4GCS.mjs} +2 -2
- package/dist/invitation-C299fxkP-BR-niZyp.mjs +4 -0
- package/dist/{saas-connect-CVoRK0Ex.mjs → saas-connect-CO554S-V.mjs} +737 -254
- package/dist/web/assets/{index-Bm6hgcvt.js → index-B7noAoV-.js} +1 -1
- package/dist/web/assets/index-DPLa60vJ.css +1 -0
- package/dist/web/assets/index-DvGkka4N.js +390 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/client-By1K4VVT-DuI6EnSh.mjs +0 -4
- package/dist/invitation-DWlyNb8x-BvXubk24.mjs +0 -4
- package/dist/web/assets/index-fNb_M0nL.css +0 -1
- package/dist/web/assets/index-k2bWRKc-.js +0 -388
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw8zbkd.mjs";
|
|
2
2
|
import { A as FIRST_TREE_HUB_ATTR, C as stampOrgScope, D as untrustedAttrs, E as startWsConnectionSpan, M as require_pino, O as withSpan, S as stampChatResource, _ as rootLogger$1, a as buildRateLimitError, c as currentTraceId, f as messageAttrs, g as reportErrorToRoot, i as bodyCaptureOnSendHook, j as redactUrl, k as withWsMessageSpan, l as decodeJwtForTrace, m as observabilityPlugin, n as applyLoggerConfig, o as classifyJoseError, r as attachRequestContext, s as createLogger$1, t as adapterAttrs, u as endWsConnectionSpan, x as stampAgentResource, y as setWsConnectionAttrs } from "./observability-BAScT_5S-gw1ODB_o.mjs";
|
|
3
|
-
import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-
|
|
3
|
+
import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-C_K2CKXC.mjs";
|
|
4
4
|
import { a as print, i as blank, n as CLI_USER_AGENT, r as COMMAND_VERSION, s as status, t as cliFetch } from "./cli-fetch--tiwKm5S.mjs";
|
|
5
|
-
import { $ as loginSchema, A as createAgentSchema, B as githubCallbackQuerySchema, C as agentTypeSchema$1, Ct as
|
|
6
|
-
import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-
|
|
7
|
-
import { C as retireClient, D as touchAgent, E as setRuntimeState, O as unbindAgent, S as registerClient, T as setOffline, _ as listClients, a as claimClient, b as markStaleAgents, c as clients, d as getClient, f as getOnlineCount, g as listActiveAgentsPinnedToClient, h as heartbeatInstance, i as bindAgent, k as updateClientCapabilities, l as deriveAuthState, m as heartbeatClient, n as agents, o as cleanupStaleClients, p as getPresence, r as assertClientOwner, s as cleanupStalePresence, t as agentPresence, u as disconnectClient, v as listClientsForOrgAdmin, w as serverInstances, x as members } from "./client-
|
|
5
|
+
import { $ as loginSchema, A as createAgentSchema, B as githubCallbackQuerySchema, C as agentTypeSchema$1, Ct as updateChatSchema, D as contextTreeSnapshotSchema, Dt as updateTaskStatusSchema, E as connectTokenExchangeSchema, Et as updateOrganizationSchema, F as createTaskSchema, G as inboxDeliverFrameSchema$1, H as githubStartQuerySchema, I as defaultRuntimeConfigPayload, J as isRedactedEnvValue, K as inboxPollQuerySchema, L as delegateFeishuUserSchema, M as createMeChatSchema, N as createMemberSchema, O as createAdapterConfigSchema, Ot as wsAuthFrameSchema, P as createOrgFromMeSchema, Q as listMeChatsQuerySchema, R as dryRunAgentRuntimeConfigSchema, S as agentRuntimeConfigPayloadSchema$1, St as updateAgentSchema, T as clientRegisterSchema, Tt as updateMemberSchema, U as imageInlineContentSchema, V as githubDevCallbackQuerySchema, W as inboxAckFrameSchema, X as joinByInvitationSchema, Y as isReservedAgentName$1, Z as linkTaskChatSchema, _ as addParticipantSchema, _t as sessionStateMessageSchema, a as AGENT_STATUSES, at as rebindAgentSchema, b as agentBindRequestSchema, bt as updateAdapterConfigSchema, ct as safeRedirectPath, d as TASK_CREATOR_TYPES, dt as sendMessageSchema, et as messageSourceSchema$1, f as TASK_HEALTH_SIGNALS, ft as sendToAgentSchema, g as addMeChatParticipantsSchema, gt as sessionReconcileRequestSchema, h as WS_AUTH_FRAME_TIMEOUT_MS, ht as sessionEventSchema$1, i as AGENT_SOURCES, it as patchOnboardingSchema, j as createChatSchema, k as createAdapterMappingSchema, l as MENTION_REGEX, lt as scanMentionTokens, m as TASK_TERMINAL_STATUSES, mt as sessionEventMessageSchema, n as AGENT_NAME_REGEX$1, nt as onboardingEventSchema, o as AGENT_TYPES, ot as refreshTokenSchema, p as TASK_STATUSES, pt as sessionCompletionMessageSchema, q as isOrgSettingNamespace, r as AGENT_SELECTOR_HEADER$1, rt as paginationQuerySchema, s as AGENT_VISIBILITY, st as runtimeStateMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as notificationQuerySchema, u as ORG_SETTINGS_NAMESPACES$1, ut as selfServiceFeishuBotSchema, v as adminCreateTaskSchema, vt as stripCode, wt as updateClientCapabilitiesSchema, x as agentPinnedMessageSchema$1, xt as updateAgentRuntimeConfigSchema, y as adminUpdateTaskSchema, yt as taskListQuerySchema, z as extractMentions } from "./dist-CfvCT4E0.mjs";
|
|
6
|
+
import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
|
|
7
|
+
import { C as retireClient, D as touchAgent, E as setRuntimeState, O as unbindAgent, S as registerClient, T as setOffline, _ as listClients, a as claimClient, b as markStaleAgents, c as clients, d as getClient, f as getOnlineCount, g as listActiveAgentsPinnedToClient, h as heartbeatInstance, i as bindAgent, k as updateClientCapabilities, l as deriveAuthState, m as heartbeatClient, n as agents, o as cleanupStaleClients, p as getPresence, r as assertClientOwner, s as cleanupStalePresence, t as agentPresence, u as disconnectClient, v as listClientsForOrgAdmin, w as serverInstances, x as members } from "./client-DqdGiggm-NQoGZ2vM.mjs";
|
|
8
8
|
import { n as init_esm, r as trace, t as esm_exports } from "./esm-Ci8E1Gtj.mjs";
|
|
9
|
-
import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-
|
|
9
|
+
import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-Bg0TRiyx-BsZH4GCS.mjs";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import { ZodError, z } from "zod";
|
|
12
12
|
import { basename, delimiter, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
@@ -16,7 +16,7 @@ import { EventEmitter } from "node:events";
|
|
|
16
16
|
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
|
|
17
17
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
18
18
|
import WebSocket from "ws";
|
|
19
|
-
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
19
|
+
import { chmod, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
20
20
|
import { parse, stringify } from "yaml";
|
|
21
21
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
22
22
|
import { execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
@@ -371,160 +371,6 @@ z.object({
|
|
|
371
371
|
connected: z.boolean(),
|
|
372
372
|
lastActiveAt: z.string().nullable()
|
|
373
373
|
});
|
|
374
|
-
const presenceStatusSchema = z.enum(["online", "offline"]);
|
|
375
|
-
const runtimeStateSchema = z.enum([
|
|
376
|
-
"idle",
|
|
377
|
-
"working",
|
|
378
|
-
"blocked",
|
|
379
|
-
"error"
|
|
380
|
-
]);
|
|
381
|
-
z.enum([
|
|
382
|
-
"active",
|
|
383
|
-
"suspended",
|
|
384
|
-
"evicted"
|
|
385
|
-
]);
|
|
386
|
-
/** Wire-level states a client may report. `evicted` from a stale client is rejected. */
|
|
387
|
-
const clientSessionStateSchema = z.enum(["active", "suspended"]);
|
|
388
|
-
z.object({
|
|
389
|
-
chatId: z.string().min(1),
|
|
390
|
-
state: clientSessionStateSchema
|
|
391
|
-
});
|
|
392
|
-
z.object({ runtimeState: runtimeStateSchema });
|
|
393
|
-
z.object({
|
|
394
|
-
agentId: z.string().min(1),
|
|
395
|
-
runtimeType: z.string().max(50),
|
|
396
|
-
runtimeVersion: z.string().max(50).optional()
|
|
397
|
-
});
|
|
398
|
-
z.enum([
|
|
399
|
-
"wrong_client",
|
|
400
|
-
"not_owned",
|
|
401
|
-
"agent_suspended",
|
|
402
|
-
"wrong_org",
|
|
403
|
-
"unknown_agent",
|
|
404
|
-
"runtime_provider_mismatch"
|
|
405
|
-
]);
|
|
406
|
-
/** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
|
|
407
|
-
const AGENT_SELECTOR_HEADER = "x-agent-id";
|
|
408
|
-
z.object({
|
|
409
|
-
agentId: z.string(),
|
|
410
|
-
status: presenceStatusSchema,
|
|
411
|
-
connectedAt: z.string().nullable(),
|
|
412
|
-
lastSeenAt: z.string(),
|
|
413
|
-
clientId: z.string().nullable().optional(),
|
|
414
|
-
runtimeType: z.string().nullable().optional(),
|
|
415
|
-
runtimeVersion: z.string().nullable().optional(),
|
|
416
|
-
runtimeState: runtimeStateSchema.nullable().optional(),
|
|
417
|
-
activeSessions: z.number().int().nullable().optional(),
|
|
418
|
-
totalSessions: z.number().int().nullable().optional(),
|
|
419
|
-
runtimeUpdatedAt: z.string().nullable().optional()
|
|
420
|
-
});
|
|
421
|
-
z.object({
|
|
422
|
-
total: z.number().int(),
|
|
423
|
-
running: z.number().int(),
|
|
424
|
-
byState: z.object({
|
|
425
|
-
idle: z.number().int(),
|
|
426
|
-
working: z.number().int(),
|
|
427
|
-
blocked: z.number().int(),
|
|
428
|
-
error: z.number().int()
|
|
429
|
-
}),
|
|
430
|
-
clients: z.number().int()
|
|
431
|
-
});
|
|
432
|
-
const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
|
|
433
|
-
const agentTypeSchema = z.enum([
|
|
434
|
-
"human",
|
|
435
|
-
"personal_assistant",
|
|
436
|
-
"autonomous_agent"
|
|
437
|
-
]);
|
|
438
|
-
const agentVisibilitySchema = z.enum(["private", "organization"]);
|
|
439
|
-
const agentSourceSchema = z.enum(["admin-api", "portal"]);
|
|
440
|
-
z.enum(["active", "suspended"]);
|
|
441
|
-
/**
|
|
442
|
-
* Agent-name rules (see docs/agent-naming-design.md §3.1):
|
|
443
|
-
* - Lowercase ASCII slug, hyphens + underscores allowed.
|
|
444
|
-
* - Must start with alphanumeric: `-` / `_` as first char collide with
|
|
445
|
-
* CLI flag parsing and markdown list syntax.
|
|
446
|
-
* - 1–64 chars — aligned with `MENTION_REGEX` so any valid name can be
|
|
447
|
-
* @-mentioned in chat. Older rows created under the previous 1–100
|
|
448
|
-
* regex are grandfathered; the tight rule only gates new creates.
|
|
449
|
-
*/
|
|
450
|
-
const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
451
|
-
const RESERVED_AGENT_NAMES_SET = new Set([
|
|
452
|
-
"admin",
|
|
453
|
-
"agent",
|
|
454
|
-
"first-tree",
|
|
455
|
-
"hub",
|
|
456
|
-
"me",
|
|
457
|
-
"null",
|
|
458
|
-
"system",
|
|
459
|
-
"undefined"
|
|
460
|
-
]);
|
|
461
|
-
function isReservedAgentName(name) {
|
|
462
|
-
return RESERVED_AGENT_NAMES_SET.has(name);
|
|
463
|
-
}
|
|
464
|
-
z.object({
|
|
465
|
-
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(),
|
|
466
|
-
type: agentTypeSchema,
|
|
467
|
-
displayName: z.string().min(1).max(200).optional(),
|
|
468
|
-
delegateMention: z.string().max(100).optional(),
|
|
469
|
-
organizationId: z.string().min(1).max(100).optional(),
|
|
470
|
-
source: agentSourceSchema.optional(),
|
|
471
|
-
visibility: agentVisibilitySchema.optional(),
|
|
472
|
-
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
473
|
-
managerId: z.string().optional(),
|
|
474
|
-
clientId: z.string().min(1).max(100).optional(),
|
|
475
|
-
runtimeProvider: runtimeProviderSchema.optional()
|
|
476
|
-
});
|
|
477
|
-
z.object({
|
|
478
|
-
type: agentTypeSchema.optional(),
|
|
479
|
-
displayName: z.string().min(1).max(200).optional(),
|
|
480
|
-
delegateMention: z.string().max(100).nullable().optional(),
|
|
481
|
-
visibility: agentVisibilitySchema.optional(),
|
|
482
|
-
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
483
|
-
managerId: z.string().nullable().optional(),
|
|
484
|
-
clientId: z.string().min(1).max(100).nullable().optional()
|
|
485
|
-
});
|
|
486
|
-
z.object({
|
|
487
|
-
clientId: z.string().min(1).max(100),
|
|
488
|
-
runtimeProvider: runtimeProviderSchema,
|
|
489
|
-
force: z.boolean().optional()
|
|
490
|
-
});
|
|
491
|
-
z.object({
|
|
492
|
-
uuid: z.string(),
|
|
493
|
-
name: z.string().nullable(),
|
|
494
|
-
organizationId: z.string(),
|
|
495
|
-
type: agentTypeSchema,
|
|
496
|
-
displayName: z.string(),
|
|
497
|
-
delegateMention: z.string().nullable(),
|
|
498
|
-
inboxId: z.string(),
|
|
499
|
-
status: z.string(),
|
|
500
|
-
source: z.string().nullable().optional(),
|
|
501
|
-
visibility: agentVisibilitySchema,
|
|
502
|
-
metadata: z.record(z.string(), z.unknown()),
|
|
503
|
-
managerId: z.string().nullable(),
|
|
504
|
-
clientId: z.string().nullable(),
|
|
505
|
-
runtimeProvider: runtimeProviderSchema,
|
|
506
|
-
presenceStatus: presenceStatusSchema.optional(),
|
|
507
|
-
createdAt: z.string(),
|
|
508
|
-
updatedAt: z.string()
|
|
509
|
-
});
|
|
510
|
-
z.object({
|
|
511
|
-
repo: z.string().nullable(),
|
|
512
|
-
branch: z.string().nullable()
|
|
513
|
-
});
|
|
514
|
-
/**
|
|
515
|
-
* Server → client WebSocket frame announcing that an agent has just been
|
|
516
|
-
* pinned to the connected client (either created with `clientId` or bound via
|
|
517
|
-
* PATCH NULL → ID). The client can auto-register a local config from this so
|
|
518
|
-
* the operator doesn't have to run `first-tree-hub agent add` manually.
|
|
519
|
-
*/
|
|
520
|
-
const agentPinnedMessageSchema = z.object({
|
|
521
|
-
type: z.literal("agent:pinned"),
|
|
522
|
-
agentId: z.string(),
|
|
523
|
-
name: z.string().nullable(),
|
|
524
|
-
displayName: z.string(),
|
|
525
|
-
agentType: agentTypeSchema,
|
|
526
|
-
runtimeProvider: runtimeProviderSchema
|
|
527
|
-
});
|
|
528
374
|
/**
|
|
529
375
|
* Agent runtime configuration.
|
|
530
376
|
*
|
|
@@ -665,11 +511,31 @@ const agentRuntimeConfigSchema = z.object({
|
|
|
665
511
|
updatedAt: z.string(),
|
|
666
512
|
updatedBy: z.string()
|
|
667
513
|
});
|
|
514
|
+
/**
|
|
515
|
+
* Write-side shape with no `.default()` per field.
|
|
516
|
+
*
|
|
517
|
+
* `agentRuntimeConfigPayloadShape` carries `.default()` on every field for the
|
|
518
|
+
* read path (so legacy DB rows parse cleanly). On the PATCH side those defaults
|
|
519
|
+
* are actively harmful: Zod 4's `.partial()` makes a field optional but keeps
|
|
520
|
+
* the inner `ZodDefault`, so a body like `{ mcpServers: [...] }` parses to a
|
|
521
|
+
* fully-populated patch where the omitted fields are filled with their
|
|
522
|
+
* defaults — the service layer's `patch.x ?? current.x` then sees a truthy
|
|
523
|
+
* default and *replaces* the user's saved value with empty. Mirroring the 5
|
|
524
|
+
* fields here without defaults keeps "field absent" → `undefined` in the
|
|
525
|
+
* parsed patch, which is what the merge logic expects.
|
|
526
|
+
*/
|
|
527
|
+
const agentRuntimeConfigPatchShape = z.object({
|
|
528
|
+
prompt: promptConfigSchema,
|
|
529
|
+
model: z.string(),
|
|
530
|
+
mcpServers: z.array(mcpServerSchema),
|
|
531
|
+
env: z.array(envEntrySchema),
|
|
532
|
+
gitRepos: z.array(gitRepoSchema)
|
|
533
|
+
}).partial();
|
|
668
534
|
z.object({
|
|
669
535
|
expectedVersion: z.number().int().positive(),
|
|
670
|
-
payload:
|
|
536
|
+
payload: agentRuntimeConfigPatchShape
|
|
671
537
|
});
|
|
672
|
-
z.object({ payload:
|
|
538
|
+
z.object({ payload: agentRuntimeConfigPatchShape });
|
|
673
539
|
z.object({
|
|
674
540
|
current: agentRuntimeConfigSchema,
|
|
675
541
|
next: agentRuntimeConfigPayloadSchema,
|
|
@@ -694,6 +560,161 @@ function deriveRepoLocalPath(url) {
|
|
|
694
560
|
if (!trimmed) return "";
|
|
695
561
|
return ((trimmed.split(/[?#]/)[0] ?? "").split(/[/:]/).filter(Boolean).pop() ?? "").replace(/\.git$/i, "");
|
|
696
562
|
}
|
|
563
|
+
const presenceStatusSchema = z.enum(["online", "offline"]);
|
|
564
|
+
const runtimeStateSchema = z.enum([
|
|
565
|
+
"idle",
|
|
566
|
+
"working",
|
|
567
|
+
"blocked",
|
|
568
|
+
"error"
|
|
569
|
+
]);
|
|
570
|
+
z.enum([
|
|
571
|
+
"active",
|
|
572
|
+
"suspended",
|
|
573
|
+
"evicted"
|
|
574
|
+
]);
|
|
575
|
+
/** Wire-level states a client may report. `evicted` from a stale client is rejected. */
|
|
576
|
+
const clientSessionStateSchema = z.enum(["active", "suspended"]);
|
|
577
|
+
z.object({
|
|
578
|
+
chatId: z.string().min(1),
|
|
579
|
+
state: clientSessionStateSchema
|
|
580
|
+
});
|
|
581
|
+
z.object({ runtimeState: runtimeStateSchema });
|
|
582
|
+
z.object({
|
|
583
|
+
agentId: z.string().min(1),
|
|
584
|
+
runtimeType: z.string().max(50),
|
|
585
|
+
runtimeVersion: z.string().max(50).optional()
|
|
586
|
+
});
|
|
587
|
+
z.enum([
|
|
588
|
+
"wrong_client",
|
|
589
|
+
"not_owned",
|
|
590
|
+
"agent_suspended",
|
|
591
|
+
"wrong_org",
|
|
592
|
+
"unknown_agent",
|
|
593
|
+
"runtime_provider_mismatch"
|
|
594
|
+
]);
|
|
595
|
+
/** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
|
|
596
|
+
const AGENT_SELECTOR_HEADER = "x-agent-id";
|
|
597
|
+
z.object({
|
|
598
|
+
agentId: z.string(),
|
|
599
|
+
status: presenceStatusSchema,
|
|
600
|
+
connectedAt: z.string().nullable(),
|
|
601
|
+
lastSeenAt: z.string(),
|
|
602
|
+
clientId: z.string().nullable().optional(),
|
|
603
|
+
runtimeType: z.string().nullable().optional(),
|
|
604
|
+
runtimeVersion: z.string().nullable().optional(),
|
|
605
|
+
runtimeState: runtimeStateSchema.nullable().optional(),
|
|
606
|
+
activeSessions: z.number().int().nullable().optional(),
|
|
607
|
+
totalSessions: z.number().int().nullable().optional(),
|
|
608
|
+
runtimeUpdatedAt: z.string().nullable().optional()
|
|
609
|
+
});
|
|
610
|
+
z.object({
|
|
611
|
+
total: z.number().int(),
|
|
612
|
+
running: z.number().int(),
|
|
613
|
+
byState: z.object({
|
|
614
|
+
idle: z.number().int(),
|
|
615
|
+
working: z.number().int(),
|
|
616
|
+
blocked: z.number().int(),
|
|
617
|
+
error: z.number().int()
|
|
618
|
+
}),
|
|
619
|
+
clients: z.number().int()
|
|
620
|
+
});
|
|
621
|
+
const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
|
|
622
|
+
const agentTypeSchema = z.enum([
|
|
623
|
+
"human",
|
|
624
|
+
"personal_assistant",
|
|
625
|
+
"autonomous_agent"
|
|
626
|
+
]);
|
|
627
|
+
const agentVisibilitySchema = z.enum(["private", "organization"]);
|
|
628
|
+
const agentSourceSchema = z.enum(["admin-api", "portal"]);
|
|
629
|
+
z.enum(["active", "suspended"]);
|
|
630
|
+
/**
|
|
631
|
+
* Agent-name rules (see docs/agent-naming-design.md §3.1):
|
|
632
|
+
* - Lowercase ASCII slug, hyphens + underscores allowed.
|
|
633
|
+
* - Must start with alphanumeric: `-` / `_` as first char collide with
|
|
634
|
+
* CLI flag parsing and markdown list syntax.
|
|
635
|
+
* - 1–64 chars — aligned with `MENTION_REGEX` so any valid name can be
|
|
636
|
+
* @-mentioned in chat. Older rows created under the previous 1–100
|
|
637
|
+
* regex are grandfathered; the tight rule only gates new creates.
|
|
638
|
+
*/
|
|
639
|
+
const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
640
|
+
const RESERVED_AGENT_NAMES_SET = new Set([
|
|
641
|
+
"admin",
|
|
642
|
+
"agent",
|
|
643
|
+
"first-tree",
|
|
644
|
+
"hub",
|
|
645
|
+
"me",
|
|
646
|
+
"null",
|
|
647
|
+
"system",
|
|
648
|
+
"undefined"
|
|
649
|
+
]);
|
|
650
|
+
function isReservedAgentName(name) {
|
|
651
|
+
return RESERVED_AGENT_NAMES_SET.has(name);
|
|
652
|
+
}
|
|
653
|
+
z.object({
|
|
654
|
+
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(),
|
|
655
|
+
type: agentTypeSchema,
|
|
656
|
+
displayName: z.string().min(1).max(200).optional(),
|
|
657
|
+
delegateMention: z.string().max(100).optional(),
|
|
658
|
+
organizationId: z.string().min(1).max(100).optional(),
|
|
659
|
+
source: agentSourceSchema.optional(),
|
|
660
|
+
visibility: agentVisibilitySchema.optional(),
|
|
661
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
662
|
+
managerId: z.string().optional(),
|
|
663
|
+
clientId: z.string().min(1).max(100).optional(),
|
|
664
|
+
runtimeProvider: runtimeProviderSchema.optional(),
|
|
665
|
+
gitRepos: z.array(gitRepoSchema).optional()
|
|
666
|
+
});
|
|
667
|
+
z.object({
|
|
668
|
+
type: agentTypeSchema.optional(),
|
|
669
|
+
displayName: z.string().min(1).max(200).optional(),
|
|
670
|
+
delegateMention: z.string().max(100).nullable().optional(),
|
|
671
|
+
visibility: agentVisibilitySchema.optional(),
|
|
672
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
673
|
+
managerId: z.string().nullable().optional(),
|
|
674
|
+
clientId: z.string().min(1).max(100).nullable().optional()
|
|
675
|
+
});
|
|
676
|
+
z.object({
|
|
677
|
+
clientId: z.string().min(1).max(100),
|
|
678
|
+
runtimeProvider: runtimeProviderSchema,
|
|
679
|
+
force: z.boolean().optional()
|
|
680
|
+
});
|
|
681
|
+
z.object({
|
|
682
|
+
uuid: z.string(),
|
|
683
|
+
name: z.string().nullable(),
|
|
684
|
+
organizationId: z.string(),
|
|
685
|
+
type: agentTypeSchema,
|
|
686
|
+
displayName: z.string(),
|
|
687
|
+
delegateMention: z.string().nullable(),
|
|
688
|
+
inboxId: z.string(),
|
|
689
|
+
status: z.string(),
|
|
690
|
+
source: z.string().nullable().optional(),
|
|
691
|
+
visibility: agentVisibilitySchema,
|
|
692
|
+
metadata: z.record(z.string(), z.unknown()),
|
|
693
|
+
managerId: z.string().nullable(),
|
|
694
|
+
clientId: z.string().nullable(),
|
|
695
|
+
runtimeProvider: runtimeProviderSchema,
|
|
696
|
+
presenceStatus: presenceStatusSchema.optional(),
|
|
697
|
+
createdAt: z.string(),
|
|
698
|
+
updatedAt: z.string()
|
|
699
|
+
});
|
|
700
|
+
z.object({
|
|
701
|
+
repo: z.string().nullable(),
|
|
702
|
+
branch: z.string().nullable()
|
|
703
|
+
});
|
|
704
|
+
/**
|
|
705
|
+
* Server → client WebSocket frame announcing that an agent has just been
|
|
706
|
+
* pinned to the connected client (either created with `clientId` or bound via
|
|
707
|
+
* PATCH NULL → ID). The client can auto-register a local config from this so
|
|
708
|
+
* the operator doesn't have to run `first-tree-hub agent add` manually.
|
|
709
|
+
*/
|
|
710
|
+
const agentPinnedMessageSchema = z.object({
|
|
711
|
+
type: z.literal("agent:pinned"),
|
|
712
|
+
agentId: z.string(),
|
|
713
|
+
name: z.string().nullable(),
|
|
714
|
+
displayName: z.string(),
|
|
715
|
+
agentType: agentTypeSchema,
|
|
716
|
+
runtimeProvider: runtimeProviderSchema
|
|
717
|
+
});
|
|
697
718
|
z.object({
|
|
698
719
|
username: z.string().min(1),
|
|
699
720
|
password: z.string().min(1)
|
|
@@ -1217,6 +1238,37 @@ z.object({
|
|
|
1217
1238
|
name: z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
1218
1239
|
displayName: z.string().min(1).max(200)
|
|
1219
1240
|
});
|
|
1241
|
+
z.object({ dismissed: z.boolean().optional() });
|
|
1242
|
+
/**
|
|
1243
|
+
* Body for `POST /me/onboarding/events`. The web SPA reports key
|
|
1244
|
+
* milestones so the server can log them as a single funnel-trackable
|
|
1245
|
+
* stream alongside server-emitted events (`team_created`, `dismissed`).
|
|
1246
|
+
*
|
|
1247
|
+
* Server emits:
|
|
1248
|
+
* - `team_created` — at OAuth callback when joinPath === "solo"
|
|
1249
|
+
* - `dismissed` — when PATCH /me/onboarding flips dismissed
|
|
1250
|
+
*
|
|
1251
|
+
* Web reports:
|
|
1252
|
+
* - `team_renamed` — Step 1 user changed the auto-named team
|
|
1253
|
+
* - `agent_created` — Step 2 successfully created the agent
|
|
1254
|
+
* - `tree_chat_started` — Step 3 [Yes, set it up] succeeded
|
|
1255
|
+
* - `tree_intro_dismissed` — Step 3 [I'll do it later] clicked
|
|
1256
|
+
*/
|
|
1257
|
+
const onboardingEventNameSchema = z.enum([
|
|
1258
|
+
"team_renamed",
|
|
1259
|
+
"agent_created",
|
|
1260
|
+
"tree_chat_started",
|
|
1261
|
+
"tree_intro_dismissed"
|
|
1262
|
+
]);
|
|
1263
|
+
z.object({
|
|
1264
|
+
event: onboardingEventNameSchema,
|
|
1265
|
+
attrs: z.record(z.string(), z.union([
|
|
1266
|
+
z.string(),
|
|
1267
|
+
z.number(),
|
|
1268
|
+
z.boolean(),
|
|
1269
|
+
z.null()
|
|
1270
|
+
])).optional()
|
|
1271
|
+
});
|
|
1220
1272
|
z.object({
|
|
1221
1273
|
id: z.string(),
|
|
1222
1274
|
organizationId: z.string(),
|
|
@@ -1311,12 +1363,26 @@ z.object({
|
|
|
1311
1363
|
* 2. Add a key to `ORG_SETTINGS_NAMESPACES`.
|
|
1312
1364
|
* 3. Done. No DB migration, no new API route.
|
|
1313
1365
|
*/
|
|
1366
|
+
const orgContextTreeRepoUrlSchema = z.string().url().refine((value) => {
|
|
1367
|
+
try {
|
|
1368
|
+
return new URL(value).protocol === "https:";
|
|
1369
|
+
} catch {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
}, { message: "Context Tree repo URL must use HTTPS." }).refine((value) => {
|
|
1373
|
+
try {
|
|
1374
|
+
const url = new URL(value);
|
|
1375
|
+
return url.username.length === 0 && url.password.length === 0;
|
|
1376
|
+
} catch {
|
|
1377
|
+
return false;
|
|
1378
|
+
}
|
|
1379
|
+
}, { message: "Context Tree repo URL must not include credentials." });
|
|
1314
1380
|
const orgContextTreeStorageSchema = z.object({
|
|
1315
|
-
repo:
|
|
1381
|
+
repo: orgContextTreeRepoUrlSchema.optional(),
|
|
1316
1382
|
branch: z.string().default("main")
|
|
1317
1383
|
});
|
|
1318
1384
|
const orgContextTreeInputSchema = z.object({
|
|
1319
|
-
repo:
|
|
1385
|
+
repo: orgContextTreeRepoUrlSchema.nullish(),
|
|
1320
1386
|
branch: z.string().min(1).nullish()
|
|
1321
1387
|
});
|
|
1322
1388
|
const orgContextTreeOutputSchema = z.object({
|
|
@@ -1735,6 +1801,13 @@ defineConfig({
|
|
|
1735
1801
|
refreshTokenExpiry: field(z.string().default("30d"), { env: "FIRST_TREE_HUB_AUTH_REFRESH_TOKEN_EXPIRY" }),
|
|
1736
1802
|
connectTokenExpiry: field(z.string().default("10m"), { env: "FIRST_TREE_HUB_AUTH_CONNECT_TOKEN_EXPIRY" })
|
|
1737
1803
|
},
|
|
1804
|
+
contextTreeSync: optional({
|
|
1805
|
+
githubToken: field(z.string(), {
|
|
1806
|
+
env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN",
|
|
1807
|
+
secret: true
|
|
1808
|
+
}),
|
|
1809
|
+
githubTokenRepos: field(z.string().optional(), { env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN_REPOS" })
|
|
1810
|
+
}),
|
|
1738
1811
|
oauth: optional({ github: optional({
|
|
1739
1812
|
clientId: field(z.string(), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID" }),
|
|
1740
1813
|
clientSecret: field(z.string(), {
|
|
@@ -8303,7 +8376,7 @@ async function onboardCreate(args) {
|
|
|
8303
8376
|
}
|
|
8304
8377
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
8305
8378
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
8306
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
8379
|
+
const { bindFeishuBot } = await import("./feishu-DbSvp9UH.mjs").then((n) => n.r);
|
|
8307
8380
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
8308
8381
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
8309
8382
|
else {
|
|
@@ -9516,7 +9589,7 @@ function createFeedbackHandler(config) {
|
|
|
9516
9589
|
return { handle };
|
|
9517
9590
|
}
|
|
9518
9591
|
//#endregion
|
|
9519
|
-
//#region ../server/dist/app
|
|
9592
|
+
//#region ../server/dist/app--DB1keQE.mjs
|
|
9520
9593
|
var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
|
|
9521
9594
|
init_esm();
|
|
9522
9595
|
var __defProp = Object.defineProperty;
|
|
@@ -11026,29 +11099,33 @@ async function createAgent(db, data, options = {}) {
|
|
|
11026
11099
|
}
|
|
11027
11100
|
const resolvedDisplayName = data.displayName?.trim() || name || "Unnamed Agent";
|
|
11028
11101
|
try {
|
|
11029
|
-
|
|
11030
|
-
|
|
11031
|
-
|
|
11032
|
-
|
|
11033
|
-
|
|
11034
|
-
|
|
11035
|
-
|
|
11036
|
-
|
|
11037
|
-
|
|
11038
|
-
|
|
11039
|
-
|
|
11040
|
-
|
|
11041
|
-
|
|
11042
|
-
|
|
11043
|
-
|
|
11044
|
-
|
|
11045
|
-
|
|
11046
|
-
|
|
11047
|
-
|
|
11048
|
-
|
|
11049
|
-
|
|
11050
|
-
|
|
11051
|
-
|
|
11102
|
+
return await db.transaction(async (tx) => {
|
|
11103
|
+
const [row] = await tx.insert(agents).values({
|
|
11104
|
+
uuid,
|
|
11105
|
+
name,
|
|
11106
|
+
organizationId: orgId,
|
|
11107
|
+
type: data.type,
|
|
11108
|
+
displayName: resolvedDisplayName,
|
|
11109
|
+
delegateMention: data.delegateMention ?? null,
|
|
11110
|
+
inboxId,
|
|
11111
|
+
source: data.source ?? null,
|
|
11112
|
+
visibility: data.visibility ?? defaultVisibility(data.type),
|
|
11113
|
+
metadata: data.metadata ?? {},
|
|
11114
|
+
managerId,
|
|
11115
|
+
clientId,
|
|
11116
|
+
runtimeProvider
|
|
11117
|
+
}).returning();
|
|
11118
|
+
if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
11119
|
+
const initialPayload = defaultRuntimeConfigPayload(runtimeProvider);
|
|
11120
|
+
if (data.gitRepos && data.gitRepos.length > 0) initialPayload.gitRepos = data.gitRepos;
|
|
11121
|
+
await tx.insert(agentConfigs).values({
|
|
11122
|
+
agentId: row.uuid,
|
|
11123
|
+
version: 1,
|
|
11124
|
+
payload: initialPayload,
|
|
11125
|
+
updatedBy: "system"
|
|
11126
|
+
}).onConflictDoNothing();
|
|
11127
|
+
return row;
|
|
11128
|
+
});
|
|
11052
11129
|
} catch (err) {
|
|
11053
11130
|
if ((err?.code ?? err?.cause?.code ?? "") === "23505" && name) throw new ConflictError(`Agent name "${name}" already exists in organization "${orgId}"`);
|
|
11054
11131
|
throw err;
|
|
@@ -14704,11 +14781,21 @@ const authIdentities = pgTable("auth_identities", {
|
|
|
14704
14781
|
* treats it as a plain string and rejects every password — that's the
|
|
14705
14782
|
* intended behaviour: SaaS users cannot fall back to password login.
|
|
14706
14783
|
*/
|
|
14707
|
-
async function findOrCreateUserFromGithub(db, profile) {
|
|
14708
|
-
const [existing] = await db.select({
|
|
14784
|
+
async function findOrCreateUserFromGithub(db, profile, opts = {}) {
|
|
14785
|
+
const [existing] = await db.select({
|
|
14786
|
+
userId: authIdentities.userId,
|
|
14787
|
+
metadata: authIdentities.metadata
|
|
14788
|
+
}).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId))).limit(1);
|
|
14709
14789
|
if (existing) {
|
|
14710
|
-
|
|
14711
|
-
|
|
14790
|
+
const patch = {};
|
|
14791
|
+
if (profile.email) patch.email = profile.email;
|
|
14792
|
+
if (opts.encryptedAccessToken) patch.metadata = {
|
|
14793
|
+
...existing.metadata ?? {},
|
|
14794
|
+
accessToken: opts.encryptedAccessToken,
|
|
14795
|
+
login: profile.login
|
|
14796
|
+
};
|
|
14797
|
+
if (Object.keys(patch).length > 0) await db.update(authIdentities).set({
|
|
14798
|
+
...patch,
|
|
14712
14799
|
updatedAt: /* @__PURE__ */ new Date()
|
|
14713
14800
|
}).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId)));
|
|
14714
14801
|
return { userId: existing.userId };
|
|
@@ -14724,6 +14811,8 @@ async function findOrCreateUserFromGithub(db, profile) {
|
|
|
14724
14811
|
displayName: profile.displayName?.trim() || profile.login,
|
|
14725
14812
|
avatarUrl: profile.avatarUrl ?? null
|
|
14726
14813
|
});
|
|
14814
|
+
const metadata = { login: profile.login };
|
|
14815
|
+
if (opts.encryptedAccessToken) metadata.accessToken = opts.encryptedAccessToken;
|
|
14727
14816
|
await tx.insert(authIdentities).values({
|
|
14728
14817
|
id: uuidv7(),
|
|
14729
14818
|
userId,
|
|
@@ -14731,7 +14820,7 @@ async function findOrCreateUserFromGithub(db, profile) {
|
|
|
14731
14820
|
identifier: profile.githubId,
|
|
14732
14821
|
email: profile.email,
|
|
14733
14822
|
verifiedAt: /* @__PURE__ */ new Date(),
|
|
14734
|
-
metadata
|
|
14823
|
+
metadata
|
|
14735
14824
|
});
|
|
14736
14825
|
});
|
|
14737
14826
|
return { userId };
|
|
@@ -14813,13 +14902,57 @@ async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
|
|
|
14813
14902
|
}
|
|
14814
14903
|
}
|
|
14815
14904
|
return {
|
|
14816
|
-
|
|
14817
|
-
|
|
14818
|
-
|
|
14819
|
-
|
|
14820
|
-
|
|
14905
|
+
profile: {
|
|
14906
|
+
githubId: String(user.id),
|
|
14907
|
+
login: user.login,
|
|
14908
|
+
email,
|
|
14909
|
+
displayName: user.name ?? null,
|
|
14910
|
+
avatarUrl: user.avatar_url ?? null
|
|
14911
|
+
},
|
|
14912
|
+
accessToken: tokenJson.access_token
|
|
14821
14913
|
};
|
|
14822
14914
|
}
|
|
14915
|
+
/**
|
|
14916
|
+
* Thrown when GitHub's API returns a non-2xx for a token-scoped call.
|
|
14917
|
+
* Carries the HTTP status so callers can distinguish auth failures (401 /
|
|
14918
|
+
* 403 — typically a stale token or a missing scope after we expanded to
|
|
14919
|
+
* `repo`) from transient upstream errors.
|
|
14920
|
+
*/
|
|
14921
|
+
var GithubApiError = class extends Error {
|
|
14922
|
+
constructor(status, message) {
|
|
14923
|
+
super(message);
|
|
14924
|
+
this.status = status;
|
|
14925
|
+
this.name = "GithubApiError";
|
|
14926
|
+
}
|
|
14927
|
+
};
|
|
14928
|
+
/**
|
|
14929
|
+
* Fetch the authenticated user's accessible repositories. Used by the
|
|
14930
|
+
* Step 2 repo picker. Walks paginated GitHub API responses up to the cap.
|
|
14931
|
+
*/
|
|
14932
|
+
async function listUserRepos(accessToken, opts = {}) {
|
|
14933
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
14934
|
+
const perPage = opts.perPage ?? 100;
|
|
14935
|
+
const maxPages = opts.maxPages ?? 3;
|
|
14936
|
+
const out = [];
|
|
14937
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
14938
|
+
const res = await fetcher(`https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=${perPage}&page=${page}`, { headers: {
|
|
14939
|
+
Authorization: `Bearer ${accessToken}`,
|
|
14940
|
+
Accept: "application/vnd.github+json"
|
|
14941
|
+
} });
|
|
14942
|
+
if (!res.ok) throw new GithubApiError(res.status, `GitHub repo list failed (${res.status})`);
|
|
14943
|
+
const rows = await res.json();
|
|
14944
|
+
for (const r of rows) out.push({
|
|
14945
|
+
fullName: r.full_name,
|
|
14946
|
+
cloneUrl: r.clone_url,
|
|
14947
|
+
htmlUrl: r.html_url,
|
|
14948
|
+
private: r.private,
|
|
14949
|
+
defaultBranch: r.default_branch ?? null,
|
|
14950
|
+
pushedAt: r.pushed_at ?? null
|
|
14951
|
+
});
|
|
14952
|
+
if (rows.length < perPage) break;
|
|
14953
|
+
}
|
|
14954
|
+
return out;
|
|
14955
|
+
}
|
|
14823
14956
|
/** Insert (or reactivate) a `members` row for `userId` in `organizationId`. */
|
|
14824
14957
|
async function ensureMembership(db, data) {
|
|
14825
14958
|
const [existing] = await db.select().from(members).where(and(eq(members.userId, data.userId), eq(members.organizationId, data.organizationId))).limit(1);
|
|
@@ -14872,14 +15005,15 @@ function sanitizeAgentName(login) {
|
|
|
14872
15005
|
* - First try: `${login}` (lowercased, sanitized)
|
|
14873
15006
|
* - On collision: append a 4-char hex disambiguator
|
|
14874
15007
|
*
|
|
14875
|
-
*
|
|
14876
|
-
*
|
|
14877
|
-
*
|
|
14878
|
-
*
|
|
15008
|
+
* Default team display name is `${login}'s team` (set by the caller — see
|
|
15009
|
+
* docs/new-user-onboarding-design.md §5.5). Reads as "this is a collective
|
|
15010
|
+
* space" from day one so a later teammate-invite doesn't surface a label
|
|
15011
|
+
* that looks like a private sandbox. Users can rename via Step 1 of the
|
|
15012
|
+
* onboarding flow or Settings.
|
|
14879
15013
|
*/
|
|
14880
15014
|
async function createPersonalTeam(db, input) {
|
|
14881
15015
|
const baseSlug = sanitizeOrgSlug(input.loginSeed);
|
|
14882
|
-
const displayName = input.
|
|
15016
|
+
const displayName = input.teamDisplayName;
|
|
14883
15017
|
const orgId = uuidv7();
|
|
14884
15018
|
return {
|
|
14885
15019
|
organizationId: orgId,
|
|
@@ -15136,7 +15270,7 @@ async function githubOauthRoutes(app) {
|
|
|
15136
15270
|
client_id: oauthCfg.clientId,
|
|
15137
15271
|
redirect_uri: redirectUri,
|
|
15138
15272
|
state: token,
|
|
15139
|
-
scope: "read:user user:email",
|
|
15273
|
+
scope: "read:user user:email repo",
|
|
15140
15274
|
allow_signup: "true"
|
|
15141
15275
|
});
|
|
15142
15276
|
return reply.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
|
|
@@ -15160,17 +15294,20 @@ async function githubOauthRoutes(app) {
|
|
|
15160
15294
|
}));
|
|
15161
15295
|
const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
|
|
15162
15296
|
let profile;
|
|
15297
|
+
let accessToken;
|
|
15163
15298
|
try {
|
|
15164
|
-
|
|
15299
|
+
const result = await exchangeCodeForProfile({
|
|
15165
15300
|
clientId: oauthCfg.clientId,
|
|
15166
15301
|
clientSecret: oauthCfg.clientSecret
|
|
15167
15302
|
}, code, redirectUri);
|
|
15303
|
+
profile = result.profile;
|
|
15304
|
+
accessToken = result.accessToken;
|
|
15168
15305
|
} catch (err) {
|
|
15169
15306
|
const msg = err instanceof Error ? err.message : "GitHub exchange failed";
|
|
15170
15307
|
app.log.warn({ err }, "github oauth code exchange failed");
|
|
15171
15308
|
return reply.status(401).send({ error: msg });
|
|
15172
15309
|
}
|
|
15173
|
-
return completeOauthFlow(app, request, reply, profile, next);
|
|
15310
|
+
return completeOauthFlow(app, request, reply, profile, next, accessToken);
|
|
15174
15311
|
});
|
|
15175
15312
|
app.get("/dev-callback", async (request, reply) => {
|
|
15176
15313
|
if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
|
|
@@ -15182,11 +15319,12 @@ async function githubOauthRoutes(app) {
|
|
|
15182
15319
|
email: params.email ?? null,
|
|
15183
15320
|
displayName: params.displayName ?? params.login,
|
|
15184
15321
|
avatarUrl: null
|
|
15185
|
-
}, next);
|
|
15322
|
+
}, next, process.env.DEV_GITHUB_PAT?.trim() || null);
|
|
15186
15323
|
});
|
|
15187
15324
|
}
|
|
15188
|
-
async function completeOauthFlow(app, request, reply, profile, next) {
|
|
15189
|
-
const
|
|
15325
|
+
async function completeOauthFlow(app, request, reply, profile, next, rawAccessToken) {
|
|
15326
|
+
const encryptedAccessToken = rawAccessToken ? encryptValue(rawAccessToken, app.config.secrets.encryptionKey) : void 0;
|
|
15327
|
+
const { userId } = await findOrCreateUserFromGithub(app.db, profile, { encryptedAccessToken });
|
|
15190
15328
|
let joinPath = "returning";
|
|
15191
15329
|
const inviteMatch = /^\/invite\/([^/?#]+)/.exec(next);
|
|
15192
15330
|
let resolved = false;
|
|
@@ -15212,14 +15350,21 @@ async function completeOauthFlow(app, request, reply, profile, next) {
|
|
|
15212
15350
|
next = "/";
|
|
15213
15351
|
} else if (await pickPrimaryMembership(app.db, userId)) resolved = true;
|
|
15214
15352
|
else {
|
|
15215
|
-
await createPersonalTeam(app.db, {
|
|
15353
|
+
const personal = await createPersonalTeam(app.db, {
|
|
15216
15354
|
userId,
|
|
15217
15355
|
loginSeed: profile.login,
|
|
15356
|
+
teamDisplayName: `${profile.login}'s team`,
|
|
15218
15357
|
userDisplayName: profile.displayName?.trim() || profile.login
|
|
15219
15358
|
});
|
|
15220
15359
|
joinPath = "solo";
|
|
15221
15360
|
resolved = true;
|
|
15222
15361
|
next = "/";
|
|
15362
|
+
app.log.info({
|
|
15363
|
+
event: "onboarding.team_created",
|
|
15364
|
+
userId,
|
|
15365
|
+
organizationId: personal.organizationId,
|
|
15366
|
+
source: "oauth-bootstrap"
|
|
15367
|
+
}, "onboarding funnel: team auto-created at OAuth bootstrap");
|
|
15223
15368
|
}
|
|
15224
15369
|
if (!resolved) return reply.status(500).send({ error: "Failed to resolve membership" });
|
|
15225
15370
|
const tokens = await signTokensForUser(app.config.secrets.jwtSecret, userId, app.config.auth);
|
|
@@ -16265,8 +16410,11 @@ const MAX_MARKDOWN_FILES = 1e3;
|
|
|
16265
16410
|
const MAX_MARKDOWN_FILE_BYTES = 512 * 1024;
|
|
16266
16411
|
const SNAPSHOT_CACHE_TTL_MS = 3e4;
|
|
16267
16412
|
const GIT_TIMEOUT_MS = 5e3;
|
|
16413
|
+
const GIT_SYNC_TIMEOUT_MS = 12e4;
|
|
16268
16414
|
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
|
|
16269
16415
|
const GIT_LOG_RECORD_SEPARATOR = "";
|
|
16416
|
+
const REMOTE_SYNC_TTL_MS = 6e4;
|
|
16417
|
+
const REMOTE_FAILURE_TTL_MS = 3e4;
|
|
16270
16418
|
const CONTEXT_TREE_SNAPSHOT_WINDOWS = {
|
|
16271
16419
|
ONE_DAY: "1d",
|
|
16272
16420
|
SEVEN_DAYS: "7d",
|
|
@@ -16278,10 +16426,14 @@ const WINDOW_DAYS = {
|
|
|
16278
16426
|
"30d": 30
|
|
16279
16427
|
};
|
|
16280
16428
|
const snapshotCache = /* @__PURE__ */ new Map();
|
|
16429
|
+
const remoteSyncPromises = /* @__PURE__ */ new Map();
|
|
16430
|
+
const remoteLastSyncedAt = /* @__PURE__ */ new Map();
|
|
16431
|
+
const remoteLastSyncWarnings = /* @__PURE__ */ new Map();
|
|
16432
|
+
const remoteLastFailures = /* @__PURE__ */ new Map();
|
|
16281
16433
|
async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WINDOWS.SEVEN_DAYS) {
|
|
16282
16434
|
const repo = binding.repo ?? null;
|
|
16283
16435
|
const branch = binding.branch ?? null;
|
|
16284
|
-
const resolved = resolveContextTreeRoot(repo, binding.localPath);
|
|
16436
|
+
const resolved = await resolveContextTreeRoot(repo, binding.localPath, branch, binding.githubToken);
|
|
16285
16437
|
if (!resolved.root) return unavailableSnapshot(repo, branch, resolved.reason);
|
|
16286
16438
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
16287
16439
|
try {
|
|
@@ -16291,14 +16443,13 @@ async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WI
|
|
|
16291
16443
|
"--abbrev-ref",
|
|
16292
16444
|
"HEAD"
|
|
16293
16445
|
]);
|
|
16294
|
-
if (branch && actualBranch && actualBranch !== branch) return unavailableSnapshot(repo, actualBranch, `Context Tree checkout is on branch "${actualBranch}", but
|
|
16446
|
+
if (branch && actualBranch && actualBranch !== branch) return unavailableSnapshot(repo, actualBranch, `Context Tree checkout is on branch "${actualBranch}", but the configured Context Tree branch is "${branch}".`);
|
|
16295
16447
|
const comparisonBaseCommit = await comparisonBaseForWindow(resolved.root, window);
|
|
16296
16448
|
const cacheKey = snapshotCacheKey(resolved.root, actualBranch ?? branch, headCommit, comparisonBaseCommit, window);
|
|
16297
16449
|
const cached = snapshotCache.get(cacheKey);
|
|
16298
|
-
if (cached && cached.expiresAt > Date.now())
|
|
16299
|
-
|
|
16300
|
-
|
|
16301
|
-
};
|
|
16450
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
16451
|
+
if (!(cached.snapshot.snapshotStatus === "stale" && !resolved.staleReason)) return withSnapshotStatus(cached.snapshot, now, statusWarningFromResolved(resolved.staleReason, null));
|
|
16452
|
+
}
|
|
16302
16453
|
const tree = buildTree(await readMarkdownFiles(resolved.root));
|
|
16303
16454
|
const diffResult = comparisonBaseCommit ? await readDiffEntries(resolved.root, comparisonBaseCommit, headCommit) : {
|
|
16304
16455
|
entries: [],
|
|
@@ -16308,18 +16459,14 @@ async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WI
|
|
|
16308
16459
|
const nodesWithGhosts = addRemovedGhostNodes(applyChangesToNodes(tree.nodes, changes), changes);
|
|
16309
16460
|
const summary = summarizeChanges(changes);
|
|
16310
16461
|
const updates = buildUpdates(changes, nodesWithGhosts);
|
|
16311
|
-
const statusWarning =
|
|
16462
|
+
const statusWarning = statusWarningFromResolved(resolved.staleReason, diffResult.truncated);
|
|
16312
16463
|
const snapshot = {
|
|
16313
16464
|
repo,
|
|
16314
16465
|
branch: actualBranch ?? branch,
|
|
16315
16466
|
headCommit,
|
|
16316
16467
|
syncedAt: now,
|
|
16317
|
-
snapshotStatus: "active",
|
|
16318
|
-
contextStatus:
|
|
16319
|
-
label: statusWarning ? "Team context needs attention" : "Team context is current",
|
|
16320
|
-
detail: statusWarning ?? "Agents have a synced team context snapshot available.",
|
|
16321
|
-
severity: statusWarning ? "warning" : "ok"
|
|
16322
|
-
},
|
|
16468
|
+
snapshotStatus: statusWarning?.stale ? "stale" : "active",
|
|
16469
|
+
contextStatus: contextStatus(statusWarning),
|
|
16323
16470
|
summary,
|
|
16324
16471
|
updates,
|
|
16325
16472
|
nodes: nodesWithGhosts,
|
|
@@ -16344,31 +16491,249 @@ function snapshotCacheKey(root, branch, headCommit, comparisonBase, window) {
|
|
|
16344
16491
|
window
|
|
16345
16492
|
].join(":");
|
|
16346
16493
|
}
|
|
16347
|
-
function
|
|
16348
|
-
if (
|
|
16494
|
+
function statusWarningFromResolved(staleReason, truncated) {
|
|
16495
|
+
if (staleReason) return {
|
|
16496
|
+
detail: `${staleReason}${truncated ? ` Showing the first ${MAX_DIFF_ENTRIES} changed files.` : ""}`,
|
|
16497
|
+
stale: true
|
|
16498
|
+
};
|
|
16499
|
+
if (truncated) return {
|
|
16500
|
+
detail: `Showing the first ${MAX_DIFF_ENTRIES} changed files.`,
|
|
16501
|
+
stale: false
|
|
16502
|
+
};
|
|
16349
16503
|
return null;
|
|
16350
16504
|
}
|
|
16351
|
-
function resolveContextTreeRoot(repo, localPath) {
|
|
16352
|
-
|
|
16353
|
-
|
|
16505
|
+
async function resolveContextTreeRoot(repo, localPath, branch, githubToken) {
|
|
16506
|
+
if (localPath && localPath.trim().length > 0) {
|
|
16507
|
+
const root = resolveLocalPath(localPath);
|
|
16508
|
+
if (existsSync(root)) return {
|
|
16509
|
+
root,
|
|
16510
|
+
reason: "ok",
|
|
16511
|
+
staleReason: null
|
|
16512
|
+
};
|
|
16513
|
+
return {
|
|
16514
|
+
root: null,
|
|
16515
|
+
reason: `Context Tree checkout not found at ${root}.`,
|
|
16516
|
+
staleReason: null
|
|
16517
|
+
};
|
|
16518
|
+
}
|
|
16519
|
+
if (!repo) return {
|
|
16354
16520
|
root: null,
|
|
16355
|
-
reason: "Context Tree is not configured."
|
|
16521
|
+
reason: "Context Tree is not configured.",
|
|
16522
|
+
staleReason: null
|
|
16356
16523
|
};
|
|
16357
|
-
|
|
16358
|
-
|
|
16524
|
+
if (isRemoteRepo(repo)) {
|
|
16525
|
+
const resolvedBranch = branch ?? "main";
|
|
16526
|
+
if (!isSafeBranchName(resolvedBranch)) return {
|
|
16527
|
+
root: null,
|
|
16528
|
+
reason: `Configured Context Tree branch "${resolvedBranch}" is invalid.`,
|
|
16529
|
+
staleReason: null
|
|
16530
|
+
};
|
|
16531
|
+
try {
|
|
16532
|
+
const materialized = await materializeRemoteContextTree(repo, resolvedBranch, void 0, githubToken);
|
|
16533
|
+
return {
|
|
16534
|
+
root: materialized.root,
|
|
16535
|
+
reason: "ok",
|
|
16536
|
+
staleReason: materialized.staleReason
|
|
16537
|
+
};
|
|
16538
|
+
} catch (error) {
|
|
16539
|
+
return {
|
|
16540
|
+
root: null,
|
|
16541
|
+
reason: `Hub could not sync the configured Context Tree repo. Check repo access and branch "${resolvedBranch}". ${errorMessage(error)}`,
|
|
16542
|
+
staleReason: null
|
|
16543
|
+
};
|
|
16544
|
+
}
|
|
16545
|
+
}
|
|
16546
|
+
const root = resolveLocalPath(repo);
|
|
16359
16547
|
if (existsSync(root)) return {
|
|
16360
16548
|
root,
|
|
16361
|
-
reason: "ok"
|
|
16549
|
+
reason: "ok",
|
|
16550
|
+
staleReason: null
|
|
16362
16551
|
};
|
|
16363
|
-
|
|
16552
|
+
return {
|
|
16364
16553
|
root: null,
|
|
16365
|
-
reason:
|
|
16554
|
+
reason: `Context Tree checkout not found at ${root}.`,
|
|
16555
|
+
staleReason: null
|
|
16556
|
+
};
|
|
16557
|
+
}
|
|
16558
|
+
function resolveLocalPath(value) {
|
|
16559
|
+
const normalized = value.startsWith("file://") ? value.slice(7) : value;
|
|
16560
|
+
return isAbsolute(normalized) ? normalize(normalized) : resolve(process.cwd(), normalized);
|
|
16561
|
+
}
|
|
16562
|
+
function isRemoteRepo(value) {
|
|
16563
|
+
return /^https?:\/\//.test(value) || /^file:\/\//.test(value) || /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(value);
|
|
16564
|
+
}
|
|
16565
|
+
function normalizeRemoteRepoUrl(value) {
|
|
16566
|
+
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(value)) return `https://github.com/${value}`;
|
|
16567
|
+
return value;
|
|
16568
|
+
}
|
|
16569
|
+
function managedContextTreeCacheRoot() {
|
|
16570
|
+
return join(DEFAULT_DATA_DIR$1, "context-tree-repos");
|
|
16571
|
+
}
|
|
16572
|
+
function managedContextTreePath(repoUrl, branch, cacheRoot = managedContextTreeCacheRoot()) {
|
|
16573
|
+
return join(cacheRoot, createHash("sha256").update(`${repoUrl}\0${branch}`).digest("hex"));
|
|
16574
|
+
}
|
|
16575
|
+
async function materializeRemoteContextTree(repo, branch, cacheRoot = managedContextTreeCacheRoot(), githubToken) {
|
|
16576
|
+
const repoUrl = normalizeRemoteRepoUrl(repo);
|
|
16577
|
+
const root = managedContextTreePath(repoUrl, branch, cacheRoot);
|
|
16578
|
+
const lastSyncedAt = remoteLastSyncedAt.get(root);
|
|
16579
|
+
if (lastSyncedAt && Date.now() - lastSyncedAt < REMOTE_SYNC_TTL_MS && existsSync(join(root, ".git"))) return {
|
|
16580
|
+
root,
|
|
16581
|
+
staleReason: remoteLastSyncWarnings.get(root) ?? null
|
|
16582
|
+
};
|
|
16583
|
+
const lastFailure = remoteLastFailures.get(root);
|
|
16584
|
+
if (lastFailure && Date.now() - lastFailure.failedAt < REMOTE_FAILURE_TTL_MS && !existsSync(join(root, ".git"))) throw new Error(lastFailure.reason);
|
|
16585
|
+
const existing = remoteSyncPromises.get(root);
|
|
16586
|
+
if (existing) return {
|
|
16587
|
+
root,
|
|
16588
|
+
staleReason: (await existing).staleReason
|
|
16589
|
+
};
|
|
16590
|
+
const syncPromise = syncRemoteContextTree(repoUrl, branch, root, cacheRoot, githubToken);
|
|
16591
|
+
remoteSyncPromises.set(root, syncPromise);
|
|
16592
|
+
try {
|
|
16593
|
+
const syncResult = await syncPromise;
|
|
16594
|
+
remoteLastSyncedAt.set(root, Date.now());
|
|
16595
|
+
remoteLastFailures.delete(root);
|
|
16596
|
+
if (syncResult.staleReason) remoteLastSyncWarnings.set(root, syncResult.staleReason);
|
|
16597
|
+
else remoteLastSyncWarnings.delete(root);
|
|
16598
|
+
return {
|
|
16599
|
+
root,
|
|
16600
|
+
staleReason: syncResult.staleReason
|
|
16601
|
+
};
|
|
16602
|
+
} catch (error) {
|
|
16603
|
+
if (!existsSync(join(root, ".git"))) remoteLastFailures.set(root, {
|
|
16604
|
+
failedAt: Date.now(),
|
|
16605
|
+
reason: `Previous Context Tree sync failed recently. ${errorMessage(error)}`
|
|
16606
|
+
});
|
|
16607
|
+
throw error;
|
|
16608
|
+
} finally {
|
|
16609
|
+
remoteSyncPromises.delete(root);
|
|
16610
|
+
}
|
|
16611
|
+
}
|
|
16612
|
+
async function syncRemoteContextTree(repoUrl, branch, root, cacheRoot, githubToken) {
|
|
16613
|
+
await mkdir(cacheRoot, { recursive: true });
|
|
16614
|
+
const env = await gitAuthEnv(repoUrl, cacheRoot, githubToken);
|
|
16615
|
+
if (!existsSync(join(root, ".git"))) {
|
|
16616
|
+
await rm(root, {
|
|
16617
|
+
recursive: true,
|
|
16618
|
+
force: true
|
|
16619
|
+
});
|
|
16620
|
+
await gitOutput(cacheRoot, [
|
|
16621
|
+
"clone",
|
|
16622
|
+
"--branch",
|
|
16623
|
+
branch,
|
|
16624
|
+
"--single-branch",
|
|
16625
|
+
repoUrl,
|
|
16626
|
+
root
|
|
16627
|
+
], {
|
|
16628
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
16629
|
+
env,
|
|
16630
|
+
disableHooks: true
|
|
16631
|
+
});
|
|
16632
|
+
return { staleReason: null };
|
|
16633
|
+
}
|
|
16634
|
+
try {
|
|
16635
|
+
await gitOutput(root, [
|
|
16636
|
+
"remote",
|
|
16637
|
+
"set-url",
|
|
16638
|
+
"origin",
|
|
16639
|
+
repoUrl
|
|
16640
|
+
], {
|
|
16641
|
+
timeout: GIT_TIMEOUT_MS,
|
|
16642
|
+
disableHooks: true
|
|
16643
|
+
});
|
|
16644
|
+
await gitOutput(root, [
|
|
16645
|
+
"fetch",
|
|
16646
|
+
"origin",
|
|
16647
|
+
branch,
|
|
16648
|
+
"--prune"
|
|
16649
|
+
], {
|
|
16650
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
16651
|
+
env,
|
|
16652
|
+
disableHooks: true
|
|
16653
|
+
});
|
|
16654
|
+
await gitOutput(root, [
|
|
16655
|
+
"checkout",
|
|
16656
|
+
"-B",
|
|
16657
|
+
branch,
|
|
16658
|
+
`origin/${branch}`
|
|
16659
|
+
], {
|
|
16660
|
+
timeout: GIT_TIMEOUT_MS,
|
|
16661
|
+
disableHooks: true
|
|
16662
|
+
});
|
|
16663
|
+
return { staleReason: null };
|
|
16664
|
+
} catch (error) {
|
|
16665
|
+
if (existsSync(join(root, ".git"))) return { staleReason: `Showing the last synced Context Tree snapshot because Hub could not refresh the configured repo. ${errorMessage(error)}` };
|
|
16666
|
+
throw error;
|
|
16667
|
+
}
|
|
16668
|
+
}
|
|
16669
|
+
async function gitAuthEnv(repoUrl, cacheRoot, githubToken) {
|
|
16670
|
+
if (!githubToken || !isGithubHttpsRepo(repoUrl)) return void 0;
|
|
16671
|
+
const askpassPath = join(cacheRoot, ".tools", "git-askpass.sh");
|
|
16672
|
+
if (!existsSync(askpassPath)) {
|
|
16673
|
+
await mkdir(dirname(askpassPath), { recursive: true });
|
|
16674
|
+
await writeFile(askpassPath, [
|
|
16675
|
+
"#!/bin/sh",
|
|
16676
|
+
"case \"$1\" in",
|
|
16677
|
+
"*Username*) printf \"%s\\n\" \"$GIT_USERNAME\" ;;",
|
|
16678
|
+
"*) printf \"%s\\n\" \"$GIT_PASSWORD\" ;;",
|
|
16679
|
+
"esac",
|
|
16680
|
+
""
|
|
16681
|
+
].join("\n"), "utf8");
|
|
16682
|
+
await chmod(askpassPath, 448);
|
|
16683
|
+
}
|
|
16684
|
+
return {
|
|
16685
|
+
...process.env,
|
|
16686
|
+
GIT_ASKPASS: askpassPath,
|
|
16687
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
16688
|
+
GIT_USERNAME: "x-access-token",
|
|
16689
|
+
GIT_PASSWORD: githubToken
|
|
16690
|
+
};
|
|
16691
|
+
}
|
|
16692
|
+
function isGithubHttpsRepo(repoUrl) {
|
|
16693
|
+
try {
|
|
16694
|
+
const url = new URL(repoUrl);
|
|
16695
|
+
return url.protocol === "https:" && url.hostname.toLowerCase() === "github.com";
|
|
16696
|
+
} catch {
|
|
16697
|
+
return false;
|
|
16698
|
+
}
|
|
16699
|
+
}
|
|
16700
|
+
function contextStatus(warning) {
|
|
16701
|
+
if (warning?.stale) return {
|
|
16702
|
+
label: "Team context is stale",
|
|
16703
|
+
detail: warning.detail,
|
|
16704
|
+
severity: "warning"
|
|
16705
|
+
};
|
|
16706
|
+
if (warning) return {
|
|
16707
|
+
label: "Team context needs attention",
|
|
16708
|
+
detail: warning.detail,
|
|
16709
|
+
severity: "warning"
|
|
16366
16710
|
};
|
|
16367
16711
|
return {
|
|
16368
|
-
|
|
16369
|
-
|
|
16712
|
+
label: "Team context is current",
|
|
16713
|
+
detail: "Agents have a synced team context snapshot available.",
|
|
16714
|
+
severity: "ok"
|
|
16370
16715
|
};
|
|
16371
16716
|
}
|
|
16717
|
+
function withSnapshotStatus(snapshot, syncedAt, warning) {
|
|
16718
|
+
return {
|
|
16719
|
+
...snapshot,
|
|
16720
|
+
syncedAt,
|
|
16721
|
+
snapshotStatus: warning?.stale ? "stale" : snapshot.snapshotStatus,
|
|
16722
|
+
contextStatus: warning ? contextStatus(warning) : snapshot.contextStatus
|
|
16723
|
+
};
|
|
16724
|
+
}
|
|
16725
|
+
function isSafeBranchName(branch) {
|
|
16726
|
+
if (branch.startsWith("-")) return false;
|
|
16727
|
+
if (branch.includes("..") || branch.includes("@{") || branch.includes("\\")) return false;
|
|
16728
|
+
return /^[A-Za-z0-9._/-]+$/.test(branch);
|
|
16729
|
+
}
|
|
16730
|
+
function errorMessage(error) {
|
|
16731
|
+
if (!(error instanceof Error) || error.message.trim().length === 0) return "";
|
|
16732
|
+
return redactSecret(error.message.trim().split("\n")[0] ?? "");
|
|
16733
|
+
}
|
|
16734
|
+
function redactSecret(message) {
|
|
16735
|
+
return message.replace(/(https?:\/\/)[^/@\s]+@/g, "$1[redacted]@").replace(/\bghp_[A-Za-z0-9_]+/g, "[redacted]").replace(/\bgithub_pat_[A-Za-z0-9_]+/g, "[redacted]");
|
|
16736
|
+
}
|
|
16372
16737
|
function unavailableSnapshot(repo, branch, detail) {
|
|
16373
16738
|
return {
|
|
16374
16739
|
repo,
|
|
@@ -16393,11 +16758,16 @@ function unavailableSnapshot(repo, branch, detail) {
|
|
|
16393
16758
|
changes: []
|
|
16394
16759
|
};
|
|
16395
16760
|
}
|
|
16396
|
-
async function gitOutput(cwd, args) {
|
|
16397
|
-
const { stdout } = await execFileAsync("git",
|
|
16761
|
+
async function gitOutput(cwd, args, options) {
|
|
16762
|
+
const { stdout } = await execFileAsync("git", options?.disableHooks ? [
|
|
16763
|
+
"-c",
|
|
16764
|
+
"core.hooksPath=/dev/null",
|
|
16765
|
+
...args
|
|
16766
|
+
] : args, {
|
|
16398
16767
|
cwd,
|
|
16399
|
-
timeout: GIT_TIMEOUT_MS,
|
|
16400
|
-
maxBuffer: GIT_MAX_BUFFER
|
|
16768
|
+
timeout: options?.timeout ?? GIT_TIMEOUT_MS,
|
|
16769
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
16770
|
+
env: options?.env
|
|
16401
16771
|
});
|
|
16402
16772
|
return stdout.trim();
|
|
16403
16773
|
}
|
|
@@ -16955,10 +17325,40 @@ async function contextTreeSnapshotRoutes(app) {
|
|
|
16955
17325
|
const query = querySchema.parse(request.query);
|
|
16956
17326
|
const { userId } = requireUser(request);
|
|
16957
17327
|
const orgId = await resolveUserPrimaryOrgId(app.db, userId);
|
|
16958
|
-
const
|
|
17328
|
+
const binding = orgId ? await getOrgContextTree(app.db, orgId) : {};
|
|
17329
|
+
const githubToken = contextTreeGithubTokenForRepo(binding.repo, app.config.contextTreeSync);
|
|
17330
|
+
const snapshot = await getContextTreeSnapshot({
|
|
17331
|
+
...binding,
|
|
17332
|
+
githubToken
|
|
17333
|
+
}, query.window ?? "7d");
|
|
16959
17334
|
return contextTreeSnapshotSchema.parse(snapshot);
|
|
16960
17335
|
});
|
|
16961
17336
|
}
|
|
17337
|
+
function contextTreeGithubTokenForRepo(repo, syncConfig) {
|
|
17338
|
+
if (!repo || !syncConfig?.githubToken) return void 0;
|
|
17339
|
+
const repoKey = githubRepoKey(repo);
|
|
17340
|
+
if (!repoKey) return void 0;
|
|
17341
|
+
return new Set((syncConfig.githubTokenRepos ?? "").split(",").map((entry) => normalizeGithubRepoKey(entry)).filter((entry) => entry !== null)).has(repoKey) ? syncConfig.githubToken : void 0;
|
|
17342
|
+
}
|
|
17343
|
+
function githubRepoKey(value) {
|
|
17344
|
+
const shorthand = normalizeGithubRepoKey(value);
|
|
17345
|
+
if (shorthand) return shorthand;
|
|
17346
|
+
let url;
|
|
17347
|
+
try {
|
|
17348
|
+
url = new URL(value);
|
|
17349
|
+
} catch {
|
|
17350
|
+
return null;
|
|
17351
|
+
}
|
|
17352
|
+
if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "github.com") return null;
|
|
17353
|
+
if (url.username || url.password) return null;
|
|
17354
|
+
return normalizeGithubRepoKey(url.pathname.replace(/^\/+/, ""));
|
|
17355
|
+
}
|
|
17356
|
+
function normalizeGithubRepoKey(value) {
|
|
17357
|
+
const trimmed = value.trim().replace(/^\/+/, "").replace(/\.git$/i, "");
|
|
17358
|
+
const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed);
|
|
17359
|
+
if (!match) return null;
|
|
17360
|
+
return `${match[1]?.toLowerCase()}/${match[2]?.toLowerCase()}`;
|
|
17361
|
+
}
|
|
16962
17362
|
/**
|
|
16963
17363
|
* Resolve the client IP for rate-limit attribution.
|
|
16964
17364
|
*
|
|
@@ -17060,7 +17460,7 @@ async function healthzRoutes(app) {
|
|
|
17060
17460
|
* `api/orgs/invitations.ts` (Class B, admin-gated).
|
|
17061
17461
|
*/
|
|
17062
17462
|
async function publicInvitationRoutes(app) {
|
|
17063
|
-
const { previewInvitation } = await import("./invitation-
|
|
17463
|
+
const { previewInvitation } = await import("./invitation-C299fxkP-BR-niZyp.mjs");
|
|
17064
17464
|
app.get("/:token/preview", async (request, reply) => {
|
|
17065
17465
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
17066
17466
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -17083,7 +17483,8 @@ async function meRoutes(app) {
|
|
|
17083
17483
|
id: users.id,
|
|
17084
17484
|
username: users.username,
|
|
17085
17485
|
displayName: users.displayName,
|
|
17086
|
-
avatarUrl: users.avatarUrl
|
|
17486
|
+
avatarUrl: users.avatarUrl,
|
|
17487
|
+
onboardingDismissedAt: users.onboardingDismissedAt
|
|
17087
17488
|
}).from(users).where(eq(users.id, userId)).limit(1);
|
|
17088
17489
|
const memberships = await listActiveMemberships(app.db, userId);
|
|
17089
17490
|
const defaultMembership = pickDefaultMembership(memberships.map((m) => ({
|
|
@@ -17098,7 +17499,7 @@ async function meRoutes(app) {
|
|
|
17098
17499
|
if (inv) inviteUrl = buildInviteUrl(resolvePublicUrl(app, request), inv.token);
|
|
17099
17500
|
}
|
|
17100
17501
|
}
|
|
17101
|
-
const
|
|
17502
|
+
const onboardingStep = await inferOnboardingStep(app, userId);
|
|
17102
17503
|
return {
|
|
17103
17504
|
user: user ?? null,
|
|
17104
17505
|
defaultOrganizationId: defaultOrgId,
|
|
@@ -17109,11 +17510,93 @@ async function meRoutes(app) {
|
|
|
17109
17510
|
role: mb.role,
|
|
17110
17511
|
agentId: mb.agentId
|
|
17111
17512
|
})),
|
|
17112
|
-
|
|
17513
|
+
onboarding: {
|
|
17514
|
+
step: onboardingStep,
|
|
17515
|
+
dismissedAt: user?.onboardingDismissedAt ? user.onboardingDismissedAt.toISOString() : null
|
|
17516
|
+
},
|
|
17113
17517
|
inviteUrl
|
|
17114
17518
|
};
|
|
17115
17519
|
});
|
|
17116
17520
|
/**
|
|
17521
|
+
* PATCH /me/onboarding — currently the only mutable field is
|
|
17522
|
+
* `dismissed`, set when the user clicks `✕` on the onboarding stepper.
|
|
17523
|
+
* Stamping NOW() server-side avoids client-clock skew. Idempotent: a
|
|
17524
|
+
* second PATCH leaves the original timestamp in place.
|
|
17525
|
+
*
|
|
17526
|
+
* See docs/new-user-onboarding-design.md §8.4.
|
|
17527
|
+
*/
|
|
17528
|
+
app.patch("/me/onboarding", async (request, reply) => {
|
|
17529
|
+
const { userId } = requireUser(request);
|
|
17530
|
+
const body = patchOnboardingSchema.parse(request.body);
|
|
17531
|
+
if (body.dismissed === true) {
|
|
17532
|
+
if ((await app.db.update(users).set({ onboardingDismissedAt: /* @__PURE__ */ new Date() }).where(and(eq(users.id, userId), isNull(users.onboardingDismissedAt))).returning({ id: users.id })).length > 0) app.log.info({
|
|
17533
|
+
event: "onboarding.dismissed",
|
|
17534
|
+
userId
|
|
17535
|
+
}, "onboarding funnel: stepper dismissed");
|
|
17536
|
+
} else if (body.dismissed === false) await app.db.update(users).set({ onboardingDismissedAt: null }).where(eq(users.id, userId));
|
|
17537
|
+
const [u] = await app.db.select({ onboardingDismissedAt: users.onboardingDismissedAt }).from(users).where(eq(users.id, userId)).limit(1);
|
|
17538
|
+
return reply.status(200).send({ dismissedAt: u?.onboardingDismissedAt ? u.onboardingDismissedAt.toISOString() : null });
|
|
17539
|
+
});
|
|
17540
|
+
/**
|
|
17541
|
+
* POST /me/onboarding/events — web-side onboarding funnel reporter.
|
|
17542
|
+
* Server-side milestones (`team_created` at OAuth, `dismissed` on PATCH)
|
|
17543
|
+
* are emitted directly; this endpoint surfaces the web-driven ones into
|
|
17544
|
+
* the same log stream so a single funnel query covers the full flow.
|
|
17545
|
+
* Body shape is enum-validated so the server won't log arbitrary names.
|
|
17546
|
+
*
|
|
17547
|
+
* Rate-limited to keep a buggy or hostile authenticated tab from
|
|
17548
|
+
* flooding the log stream. The cap is generous relative to legitimate
|
|
17549
|
+
* funnel traffic (≤ 4 events per onboarding pass).
|
|
17550
|
+
*/
|
|
17551
|
+
app.post("/me/onboarding/events", { config: { rateLimit: {
|
|
17552
|
+
max: 60,
|
|
17553
|
+
timeWindow: "1 minute"
|
|
17554
|
+
} } }, async (request, reply) => {
|
|
17555
|
+
const { userId } = requireUser(request);
|
|
17556
|
+
const body = onboardingEventSchema.parse(request.body);
|
|
17557
|
+
app.log.info({
|
|
17558
|
+
...body.attrs ?? {},
|
|
17559
|
+
event: `onboarding.${body.event}`,
|
|
17560
|
+
userId
|
|
17561
|
+
}, `onboarding funnel: ${body.event}`);
|
|
17562
|
+
return reply.status(204).send();
|
|
17563
|
+
});
|
|
17564
|
+
/**
|
|
17565
|
+
* GET /me/github/repos — list the caller's accessible GitHub repos. Used
|
|
17566
|
+
* by the Step 2 onboarding repo picker. The OAuth access token was
|
|
17567
|
+
* captured at sign-in (encrypted at rest in `auth_identities.metadata`)
|
|
17568
|
+
* so this endpoint avoids a second redirect.
|
|
17569
|
+
*
|
|
17570
|
+
* 503 if the user has no GitHub identity bound or the token wasn't
|
|
17571
|
+
* captured (e.g. dev-callback sign-in or pre-redesign user). The web
|
|
17572
|
+
* client falls back to a "Reconnect GitHub" hint in that case.
|
|
17573
|
+
*/
|
|
17574
|
+
app.get("/me/github/repos", async (request, reply) => {
|
|
17575
|
+
const { userId } = requireUser(request);
|
|
17576
|
+
const [identity] = await app.db.select({ metadata: authIdentities.metadata }).from(authIdentities).where(and(eq(authIdentities.userId, userId), eq(authIdentities.provider, "github"))).limit(1);
|
|
17577
|
+
const encrypted = identity?.metadata && typeof identity.metadata === "object" && "accessToken" in identity.metadata ? identity.metadata.accessToken : void 0;
|
|
17578
|
+
if (typeof encrypted !== "string" || !encrypted) return reply.status(503).send({ error: "GitHub access token unavailable — please reconnect your account" });
|
|
17579
|
+
let token;
|
|
17580
|
+
try {
|
|
17581
|
+
token = decryptValue(encrypted, app.config.secrets.encryptionKey);
|
|
17582
|
+
} catch {
|
|
17583
|
+
return reply.status(503).send({ error: "GitHub access token could not be decoded — please reconnect" });
|
|
17584
|
+
}
|
|
17585
|
+
try {
|
|
17586
|
+
return { repos: await listUserRepos(token) };
|
|
17587
|
+
} catch (err) {
|
|
17588
|
+
app.log.warn({
|
|
17589
|
+
err,
|
|
17590
|
+
userId
|
|
17591
|
+
}, "list github repos failed");
|
|
17592
|
+
if (err instanceof GithubApiError && (err.status === 401 || err.status === 403)) return reply.status(403).send({
|
|
17593
|
+
error: "GitHub access token is missing the `repo` scope. Please reconnect your GitHub account.",
|
|
17594
|
+
code: "scope_missing"
|
|
17595
|
+
});
|
|
17596
|
+
return reply.status(502).send({ error: "Couldn't reach GitHub. Try again, or reconnect your GitHub account." });
|
|
17597
|
+
}
|
|
17598
|
+
});
|
|
17599
|
+
/**
|
|
17117
17600
|
* POST /me/connect-tokens — short-lived connect token for the CLI.
|
|
17118
17601
|
* The token now carries only `sub = userId`; the CLI rejoins via
|
|
17119
17602
|
* `exchangeConnectToken` which probes `members` realtime.
|
|
@@ -17157,7 +17640,7 @@ async function meRoutes(app) {
|
|
|
17157
17640
|
*/
|
|
17158
17641
|
app.get("/me/pinned-agents", async (request) => {
|
|
17159
17642
|
const { userId } = requireUser(request);
|
|
17160
|
-
const { listMyPinnedAgents } = await import("./client-
|
|
17643
|
+
const { listMyPinnedAgents } = await import("./client-D_TRJFZY-LbgJF47t.mjs");
|
|
17161
17644
|
return listMyPinnedAgents(app.db, { userId });
|
|
17162
17645
|
});
|
|
17163
17646
|
/**
|
|
@@ -17263,24 +17746,24 @@ async function meRoutes(app) {
|
|
|
17263
17746
|
return reply.status(204).send();
|
|
17264
17747
|
});
|
|
17265
17748
|
/**
|
|
17266
|
-
* GET /me/
|
|
17267
|
-
* full /me payload. Same logic as
|
|
17749
|
+
* GET /me/onboarding-step — bare endpoint for clients that don't want the
|
|
17750
|
+
* full /me payload. Same logic as inferOnboardingStep below.
|
|
17268
17751
|
*/
|
|
17269
|
-
app.get("/me/
|
|
17752
|
+
app.get("/me/onboarding-step", async (request) => {
|
|
17270
17753
|
const { userId } = requireUser(request);
|
|
17271
|
-
return { step: await
|
|
17754
|
+
return { step: await inferOnboardingStep(app, userId) };
|
|
17272
17755
|
});
|
|
17273
17756
|
}
|
|
17274
17757
|
/**
|
|
17275
|
-
* Infer the onboarding
|
|
17758
|
+
* Infer the onboarding step from the *user-level* facts:
|
|
17276
17759
|
* - has at least one client → past "connect"
|
|
17277
17760
|
* - manages at least one non-human active agent (any org) → past "create_agent"
|
|
17278
17761
|
*
|
|
17279
17762
|
* Critically: the join from agents → members → userId means a user with
|
|
17280
|
-
* memberships across multiple orgs has
|
|
17763
|
+
* memberships across multiple orgs has onboarding satisfied as soon as ANY
|
|
17281
17764
|
* org has a non-human agent — matching the user-level mental model.
|
|
17282
17765
|
*/
|
|
17283
|
-
async function
|
|
17766
|
+
async function inferOnboardingStep(app, userId) {
|
|
17284
17767
|
const [hasClient] = await app.db.select({ id: clients.id }).from(clients).where(eq(clients.userId, userId)).limit(1);
|
|
17285
17768
|
if (!hasClient) return "connect";
|
|
17286
17769
|
const [hasAgent] = await app.db.select({ uuid: agents.uuid }).from(agents).innerJoin(members, eq(members.id, agents.managerId)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.type, "human"), eq(agents.status, "active"))).limit(1);
|