@huynle/hub 0.7.4 → 0.8.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/CHANGELOG.md +217 -0
- package/Dockerfile +7 -2
- package/README.md +148 -19
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +632 -49
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Shared agent profiles, skills and commands (HWA-02 contract; registry HWA-14, protocol HWA-15, adapters HWA-16/17/
|
|
2
|
+
// 18, UI HWA-19). A profile is a versioned, hub-stored bundle (agent instructions, skills, commands) that can be
|
|
3
|
+
// assigned to a running session. Distinct from runtime.profile (sandbox configuration) and from roles and grants:
|
|
4
|
+
// assigning a profile confers no authority.
|
|
5
|
+
//
|
|
6
|
+
// Invariants frozen here:
|
|
7
|
+
// - Versions are immutable; edits create a new version. Running sessions never change silently: an assignment
|
|
8
|
+
// pins a version, and a newer version only reaches a session through an explicit re-apply.
|
|
9
|
+
// - Application is session-specific and acknowledged by the spoke (applied | rejected | unsupported) with the
|
|
10
|
+
// harness's reason. No adapter may rewrite global harness config; adapters write session-scoped state only.
|
|
11
|
+
// - Spokes announce the spoke feature `profileApply` (constant below) to receive the `profileApply` op; the hub
|
|
12
|
+
// never sends it to a spoke that did not announce it, and reports `unsupported` instead.
|
|
13
|
+
// - (HWA-15, additive) The spoke answers the op with a ProfileApplyResult echoing the session, profile, version and
|
|
14
|
+
// digest it applied; an answer naming anything else is stale. "restart_required": accepted, effective only after
|
|
15
|
+
// the harness session restarts. An assignment keeps the requested pin (profileID, version) and, apart, the
|
|
16
|
+
// `effective` version the session last confirmed (applied or partial).
|
|
17
|
+
import { DESCRIPTION_MAX, isLabel, isSlug, isText, metadataProblem, tagsProblem, type RecordState, type Revisioned } from "./contracts.ts"
|
|
18
|
+
import { isObj } from "./wire.ts"
|
|
19
|
+
|
|
20
|
+
export const PROFILE_LIMITS = {
|
|
21
|
+
profilesPerUser: 200,
|
|
22
|
+
/** Retained versions per profile (older versions stay readable while any assignment pins them). */
|
|
23
|
+
versionsPerProfile: 100,
|
|
24
|
+
skillsPerProfile: 32,
|
|
25
|
+
commandsPerProfile: 64,
|
|
26
|
+
/** Characters of instructions, of each skill body and of each command template. */
|
|
27
|
+
instructionsChars: 32_000,
|
|
28
|
+
skillChars: 32_000,
|
|
29
|
+
commandChars: 8_000,
|
|
30
|
+
/** Total serialized size of one version. */
|
|
31
|
+
versionBytes: 512 * 1024,
|
|
32
|
+
/** (RP-08, additive) Project names one profile lists. */
|
|
33
|
+
projectsPerProfile: 16,
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
export const PROFILE_SCOPES = ["profiles:read", "profiles:manage", "profiles:assign"] as const
|
|
37
|
+
|
|
38
|
+
/** The spoke feature and op name for applying a profile to one session. */
|
|
39
|
+
export const PROFILE_APPLY_FEATURE = "profileApply"
|
|
40
|
+
|
|
41
|
+
export interface ProfileSkill {
|
|
42
|
+
readonly name: string
|
|
43
|
+
readonly description: string
|
|
44
|
+
readonly body: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ProfileCommand {
|
|
48
|
+
readonly name: string
|
|
49
|
+
readonly description: string
|
|
50
|
+
readonly template: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ProfileVersionContent {
|
|
54
|
+
readonly instructions?: string
|
|
55
|
+
/** Preferred model "provider/model"; adapters may ignore it with a reported reason. */
|
|
56
|
+
readonly model?: string
|
|
57
|
+
readonly skills: readonly ProfileSkill[]
|
|
58
|
+
readonly commands: readonly ProfileCommand[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AgentProfile extends Revisioned {
|
|
62
|
+
readonly v: 1
|
|
63
|
+
readonly id: string
|
|
64
|
+
readonly name: string
|
|
65
|
+
readonly title: string
|
|
66
|
+
readonly description?: string
|
|
67
|
+
readonly state: RecordState
|
|
68
|
+
readonly tags?: readonly string[]
|
|
69
|
+
/**
|
|
70
|
+
* (RP-08, additive) Optional bounded key/value details (the shared metadataProblem rule: ≤ 32 slug keys, values
|
|
71
|
+
* 1–200 characters, never secrets), e.g. `owner`, `purpose`. On the profile record, not on versions.
|
|
72
|
+
*/
|
|
73
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
74
|
+
/** (RP-08, additive) Project names the profile is meant for (≤ projectsPerProfile, unique; filters). */
|
|
75
|
+
readonly projects?: readonly string[]
|
|
76
|
+
readonly latestVersion: number
|
|
77
|
+
readonly updatedAt: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface AgentProfileVersion {
|
|
81
|
+
readonly v: 1
|
|
82
|
+
readonly profileID: string
|
|
83
|
+
readonly version: number
|
|
84
|
+
readonly content: ProfileVersionContent
|
|
85
|
+
/** sha256 hex of the canonical JSON of `content` (adapters echo it in the acknowledgment). */
|
|
86
|
+
readonly digest: string
|
|
87
|
+
readonly createdAt: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A session's pinned profile version and the latest acknowledgment for it. */
|
|
91
|
+
export interface ProfileAssignment extends Revisioned {
|
|
92
|
+
readonly v: 1
|
|
93
|
+
readonly sessionID: string
|
|
94
|
+
readonly profileID: string
|
|
95
|
+
readonly version: number
|
|
96
|
+
readonly ack?: ProfileAck
|
|
97
|
+
/** (HWA-15) What the session runs now; absent until a spoke confirmed a version. */
|
|
98
|
+
readonly effective?: ProfileEffective
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type ProfileAckStatus = "pending" | "applied" | "partial" | "rejected" | "unsupported" | "restart_required"
|
|
102
|
+
export const PROFILE_ACK_STATUSES: readonly ProfileAckStatus[] = ["pending", "applied", "partial", "rejected", "unsupported", "restart_required"]
|
|
103
|
+
|
|
104
|
+
export interface ProfileAck {
|
|
105
|
+
readonly status: ProfileAckStatus
|
|
106
|
+
readonly digest: string
|
|
107
|
+
/** Per-part outcomes (e.g. "model ignored: harness pins model at launch"); bounded, no secrets. */
|
|
108
|
+
readonly notes?: readonly string[]
|
|
109
|
+
readonly at: number
|
|
110
|
+
/** (HWA-15) With pending: no spoke reports the session now; the hub applies the pin when one does. */
|
|
111
|
+
readonly offline?: true
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** (HWA-15) The version a session last confirmed running (applied or partial), kept across later pins. */
|
|
115
|
+
export interface ProfileEffective {
|
|
116
|
+
readonly profileID: string
|
|
117
|
+
readonly version: number
|
|
118
|
+
readonly digest: string
|
|
119
|
+
readonly status: "applied" | "partial"
|
|
120
|
+
readonly at: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const PROFILE_EVENT_TYPES = ["profile.created", "profile.version.created", "profile.archived", "profile.assigned", "profile.ack"] as const
|
|
124
|
+
|
|
125
|
+
/** Why a profile version body is not acceptable, or undefined. */
|
|
126
|
+
export function profileContentProblem(v: unknown): string | undefined {
|
|
127
|
+
if (!isObj(v)) return "content must be an object"
|
|
128
|
+
if (v.instructions !== undefined && !isText(v.instructions, PROFILE_LIMITS.instructionsChars)) return `instructions must be at most ${PROFILE_LIMITS.instructionsChars} characters`
|
|
129
|
+
if (v.model !== undefined && (typeof v.model !== "string" || !/^[A-Za-z0-9._-]{1,80}\/[A-Za-z0-9._:/-]{1,200}$/.test(v.model))) return "model must be provider/model"
|
|
130
|
+
const parts: [unknown, number, number, "body" | "template", string][] = [
|
|
131
|
+
[v.skills, PROFILE_LIMITS.skillsPerProfile, PROFILE_LIMITS.skillChars, "body", "skills"],
|
|
132
|
+
[v.commands, PROFILE_LIMITS.commandsPerProfile, PROFILE_LIMITS.commandChars, "template", "commands"],
|
|
133
|
+
]
|
|
134
|
+
for (const [list, max, chars, field, label] of parts) {
|
|
135
|
+
if (!Array.isArray(list) || list.length > max) return `${label} must be a list of at most ${max}`
|
|
136
|
+
const names = new Set<string>()
|
|
137
|
+
for (const item of list) {
|
|
138
|
+
if (!isObj(item) || !isSlug(item.name)) return `each of ${label} needs a slug name`
|
|
139
|
+
if (names.has(item.name)) return `${label} names must be unique (${item.name})`
|
|
140
|
+
names.add(item.name)
|
|
141
|
+
if (!isLabel(item.description, 300)) return `${label}.${item.name}.description must be 1–300 characters`
|
|
142
|
+
if (!isText(item[field], chars) || (item[field] as string).trim() === "") return `${label}.${item.name}.${field} must be 1–${chars} characters`
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (new TextEncoder().encode(JSON.stringify(v)).length > PROFILE_LIMITS.versionBytes) return "profile version is too large"
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** (RP-08) Why a profile's `projects` is not acceptable, or undefined (absent is fine). */
|
|
150
|
+
export function profileProjectsProblem(v: unknown): string | undefined {
|
|
151
|
+
if (v === undefined) return undefined
|
|
152
|
+
const max = PROFILE_LIMITS.projectsPerProfile
|
|
153
|
+
if (!Array.isArray(v) || v.length > max || !v.every((p) => isLabel(p)) || new Set(v).size !== v.length) return `projects must be at most ${max} unique project names`
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Why a create-profile body is not acceptable, or undefined. */
|
|
158
|
+
export function profileInputProblem(v: unknown): string | undefined {
|
|
159
|
+
if (!isObj(v)) return "body must be an object"
|
|
160
|
+
if (!isSlug(v.name)) return "name must be 2–40 lowercase letters, digits or dashes"
|
|
161
|
+
if (!isLabel(v.title)) return "title must be 1–120 characters of text"
|
|
162
|
+
if (v.description !== undefined && !isText(v.description, DESCRIPTION_MAX)) return `description must be at most ${DESCRIPTION_MAX} characters`
|
|
163
|
+
return tagsProblem(v.tags) ?? metadataProblem(v.metadata) ?? profileProjectsProblem(v.projects) ?? profileContentProblem(v.content)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------- application (HWA-15) ----------
|
|
167
|
+
|
|
168
|
+
/** What a spoke answers to profileApply (never "pending": the hub alone records that). */
|
|
169
|
+
export type ProfileApplyStatus = Exclude<ProfileAckStatus, "pending">
|
|
170
|
+
export const PROFILE_APPLY_STATUSES: readonly ProfileApplyStatus[] = ["applied", "partial", "rejected", "unsupported", "restart_required"]
|
|
171
|
+
|
|
172
|
+
export const PROFILE_APPLY_LIMITS = {
|
|
173
|
+
/** Notes per acknowledgment. */
|
|
174
|
+
notes: 16,
|
|
175
|
+
/** Characters per note. */
|
|
176
|
+
noteChars: 300,
|
|
177
|
+
} as const
|
|
178
|
+
|
|
179
|
+
/** Hub → spoke: apply one immutable profile version to one session (feature `profileApply`). */
|
|
180
|
+
export interface ProfileApplyRequest {
|
|
181
|
+
readonly op: "profileApply"
|
|
182
|
+
readonly sessionID: string
|
|
183
|
+
readonly profileID: string
|
|
184
|
+
readonly version: number
|
|
185
|
+
/** sha256 hex of canonicalProfileJSON(content); the spoke checks it before applying and echoes it. */
|
|
186
|
+
readonly digest: string
|
|
187
|
+
readonly content: ProfileVersionContent
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Spoke → hub: the outcome for exactly the session, profile, version and digest it was asked to apply. */
|
|
191
|
+
export interface ProfileApplyResult {
|
|
192
|
+
readonly sessionID: string
|
|
193
|
+
readonly profileID: string
|
|
194
|
+
readonly version: number
|
|
195
|
+
readonly digest: string
|
|
196
|
+
readonly status: ProfileApplyStatus
|
|
197
|
+
/** Bounded (PROFILE_APPLY_LIMITS), plain text; never secret values or profile content. */
|
|
198
|
+
readonly notes?: readonly string[]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const DIGEST = /^[0-9a-f]{64}$/
|
|
202
|
+
const PROFILE_REF = /^[A-Za-z0-9_.:-]{1,100}$/
|
|
203
|
+
const CONTENT_KEYS = new Set(["instructions", "model", "skills", "commands"])
|
|
204
|
+
const SKILL_KEYS = new Set(["name", "description", "body"])
|
|
205
|
+
const COMMAND_KEYS = new Set(["name", "description", "template"])
|
|
206
|
+
const isVersion = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 1
|
|
207
|
+
const onlyKeys = (v: Record<string, unknown>, keys: ReadonlySet<string>) => Object.keys(v).every((k) => keys.has(k))
|
|
208
|
+
|
|
209
|
+
/** Why `v` is not an acceptable profileApply request, or undefined. Content is exactly the known fields. */
|
|
210
|
+
export function profileApplyRequestProblem(v: unknown): string | undefined {
|
|
211
|
+
if (!isObj(v) || v.op !== "profileApply") return "op must be profileApply"
|
|
212
|
+
if (typeof v.sessionID !== "string" || !PROFILE_REF.test(v.sessionID)) return "sessionID must be a session ID"
|
|
213
|
+
if (typeof v.profileID !== "string" || !PROFILE_REF.test(v.profileID)) return "profileID must be a profile ID"
|
|
214
|
+
if (!isVersion(v.version)) return "version must be a version number"
|
|
215
|
+
if (typeof v.digest !== "string" || !DIGEST.test(v.digest)) return "digest must be 64 lowercase hex characters"
|
|
216
|
+
const content = v.content
|
|
217
|
+
if (!isObj(content) || !onlyKeys(content, CONTENT_KEYS)) return "content must hold only instructions, model, skills and commands"
|
|
218
|
+
if (Array.isArray(content.skills) && !content.skills.every((s) => isObj(s) && onlyKeys(s, SKILL_KEYS))) return "skills hold only name, description and body"
|
|
219
|
+
if (Array.isArray(content.commands) && !content.commands.every((c) => isObj(c) && onlyKeys(c, COMMAND_KEYS))) return "commands hold only name, description and template"
|
|
220
|
+
return profileContentProblem(content)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Why `v` is not an acceptable profileApply result, or undefined. With `request`, a result that names another
|
|
225
|
+
* session, profile, version or digest is stale (the hub never records it).
|
|
226
|
+
*/
|
|
227
|
+
export function profileApplyResultProblem(v: unknown, request?: Pick<ProfileApplyRequest, "sessionID" | "profileID" | "version" | "digest">): string | undefined {
|
|
228
|
+
if (!isObj(v)) return "result must be an object"
|
|
229
|
+
if (!(PROFILE_APPLY_STATUSES as readonly unknown[]).includes(v.status)) return `status must be one of ${PROFILE_APPLY_STATUSES.join(", ")}`
|
|
230
|
+
if (v.notes !== undefined) {
|
|
231
|
+
const { notes: max, noteChars } = PROFILE_APPLY_LIMITS
|
|
232
|
+
if (!Array.isArray(v.notes) || v.notes.length > max || !v.notes.every((n) => typeof n === "string" && n.length <= noteChars && !/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(n))) {
|
|
233
|
+
return `notes must be at most ${max} lines of at most ${noteChars} printable characters`
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (typeof v.sessionID !== "string" || typeof v.profileID !== "string" || !isVersion(v.version) || typeof v.digest !== "string" || !DIGEST.test(v.digest)) {
|
|
237
|
+
return "result must echo sessionID, profileID, version and digest"
|
|
238
|
+
}
|
|
239
|
+
if (!request) return undefined
|
|
240
|
+
if (v.sessionID !== request.sessionID) return "stale: the result names another session"
|
|
241
|
+
if (v.profileID !== request.profileID) return "stale: the result names another profile"
|
|
242
|
+
if (v.version !== request.version) return "stale: the result names another version"
|
|
243
|
+
if (v.digest !== request.digest) return "stale: the result names another digest"
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** JSON with object keys sorted at every level (undefined members dropped): what the version digest hashes. */
|
|
248
|
+
export function canonicalProfileJSON(value: unknown): string {
|
|
249
|
+
if (Array.isArray(value)) return `[${value.map(canonicalProfileJSON).join(",")}]`
|
|
250
|
+
if (isObj(value)) {
|
|
251
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
|
|
252
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalProfileJSON(value[k])}`).join(",")}}`
|
|
253
|
+
}
|
|
254
|
+
return JSON.stringify(value)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** One part of a command template: literal text, a `{{param}}` or a `{{secret:NAME}}` reference. */
|
|
258
|
+
export type ProfileTemplatePart = { readonly text: string } | { readonly param: string } | { readonly secret: string; readonly raw: string }
|
|
259
|
+
|
|
260
|
+
const TEMPLATE_PARAM = /^[a-z][a-z0-9_]{0,39}$/
|
|
261
|
+
const TEMPLATE_SECRET = /^[A-Z][A-Z0-9_]{0,63}$/
|
|
262
|
+
const SECRET_PREFIX = "secret:"
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Splits a command template (HWA-14 syntax: `{{param}}`, `{{arguments}}`, `{{secret:NAME}}`, `\{{` for a literal
|
|
266
|
+
* `{{`; nothing else) into parts, or says why it is refused. Problems name positions, never template text.
|
|
267
|
+
*/
|
|
268
|
+
export function profileTemplateParts(template: string): ProfileTemplatePart[] | string {
|
|
269
|
+
const out: ProfileTemplatePart[] = []
|
|
270
|
+
let text = ""
|
|
271
|
+
let i = 0
|
|
272
|
+
while (i < template.length) {
|
|
273
|
+
if (template.startsWith("\\{{", i)) {
|
|
274
|
+
text += "{{"
|
|
275
|
+
i += 3
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (!template.startsWith("{{", i)) {
|
|
279
|
+
text += template[i]
|
|
280
|
+
i++
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
const end = template.indexOf("}}", i + 2)
|
|
284
|
+
if (end < 0) return `unclosed placeholder at character ${i + 1}`
|
|
285
|
+
const inner = template.slice(i + 2, end).trim()
|
|
286
|
+
if (inner.startsWith(SECRET_PREFIX)) {
|
|
287
|
+
if (!TEMPLATE_SECRET.test(inner.slice(SECRET_PREFIX.length))) return `secret reference at character ${i + 1} must be {{secret:NAME}} (uppercase letters, digits, _)`
|
|
288
|
+
if (text) out.push({ text })
|
|
289
|
+
text = ""
|
|
290
|
+
out.push({ secret: inner.slice(SECRET_PREFIX.length), raw: template.slice(i, end + 2) })
|
|
291
|
+
} else {
|
|
292
|
+
if (!TEMPLATE_PARAM.test(inner)) return `placeholder at character ${i + 1} is not a {{name}} parameter (no expression, condition or filter is allowed; write \\{{ for a literal {{)`
|
|
293
|
+
if (text) out.push({ text })
|
|
294
|
+
text = ""
|
|
295
|
+
out.push({ param: inner })
|
|
296
|
+
}
|
|
297
|
+
i = end + 2
|
|
298
|
+
}
|
|
299
|
+
if (text) out.push({ text })
|
|
300
|
+
return out
|
|
301
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Persistent teams (HWA-02 contract; definitions HWA-33, enforcement HWA-34, staffing HWA-35, intake HWA-36,
|
|
2
|
+
// API/SDK/CLI/MCP HWA-37, Watchtower team views HWA-38/39, team-aware discovery HWA-40). Decisions zltiwb4s,
|
|
3
|
+
// 5etngvgm (external contact), r3vv4eon (no-team sessions unchanged), qxzrdc6c (@@role#team).
|
|
4
|
+
//
|
|
5
|
+
// A team is a versioned definition (roles, one entry role, communication mode and paths, tags/metadata). It grants
|
|
6
|
+
// no authority: staffing a role with a session needs the existing grants. Membership is many-to-many: a session
|
|
7
|
+
// may hold roles in several teams, and more than one role in one team.
|
|
8
|
+
//
|
|
9
|
+
// teamDeliveryVerdict below is the normative delivery rule; HWA-34 calls it from central message delivery and
|
|
10
|
+
// must not re-derive it.
|
|
11
|
+
import { DESCRIPTION_MAX, isLabel, isSlug, isText, metadataProblem, tagsProblem, type RecordState, type Revisioned } from "./contracts.ts"
|
|
12
|
+
import { isObj } from "./wire.ts"
|
|
13
|
+
|
|
14
|
+
export const TEAM_LIMITS = {
|
|
15
|
+
teamsPerUser: 100,
|
|
16
|
+
rolesPerTeam: 32,
|
|
17
|
+
edgesPerTeam: 256,
|
|
18
|
+
/** Sessions staffed per role (several sessions may share a role). */
|
|
19
|
+
sessionsPerRole: 16,
|
|
20
|
+
/** Open intake items per team. */
|
|
21
|
+
openIntakePerTeam: 500,
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
export const TEAM_SCOPES = ["teams:read", "teams:manage", "teams:submit"] as const
|
|
25
|
+
|
|
26
|
+
/** "open": every member may msg/ask every other member (two-way). "restricted": only the drawn edges. */
|
|
27
|
+
export type TeamMode = "open" | "restricted"
|
|
28
|
+
|
|
29
|
+
/** Message kinds a path permits. Replies to a permitted ask never need a path. */
|
|
30
|
+
export type TeamMessageKind = "msg" | "ask"
|
|
31
|
+
|
|
32
|
+
export interface TeamRole {
|
|
33
|
+
readonly name: string
|
|
34
|
+
readonly label?: string
|
|
35
|
+
readonly description?: string
|
|
36
|
+
/** Decision 5etngvgm: holders of this role may contact sessions outside all their teams. Default false. */
|
|
37
|
+
readonly externalContact?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TeamEdge {
|
|
41
|
+
readonly from: string
|
|
42
|
+
readonly to: string
|
|
43
|
+
readonly kinds: readonly TeamMessageKind[]
|
|
44
|
+
/** "two-way": `to` may also contact `from` with the same kinds. */
|
|
45
|
+
readonly direction: "one-way" | "two-way"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TeamDefinition extends Revisioned {
|
|
49
|
+
readonly v: 1
|
|
50
|
+
readonly id: string
|
|
51
|
+
/** Slug used in addresses: `@@role#name`. Unique per user. */
|
|
52
|
+
readonly name: string
|
|
53
|
+
readonly title: string
|
|
54
|
+
readonly description?: string
|
|
55
|
+
readonly state: RecordState
|
|
56
|
+
readonly mode: TeamMode
|
|
57
|
+
readonly roles: readonly TeamRole[]
|
|
58
|
+
/** The intake role; receives submitted work and supervises members (via workspace supervision). */
|
|
59
|
+
readonly entryRole: string
|
|
60
|
+
/** Ignored in open mode (kept so switching modes is lossless). */
|
|
61
|
+
readonly edges: readonly TeamEdge[]
|
|
62
|
+
readonly tags?: readonly string[]
|
|
63
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
64
|
+
readonly updatedAt: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A session staffed in a role of a team (HWA-35). A replacement is a new generation of the same slot. */
|
|
68
|
+
export interface TeamStaffing extends Revisioned {
|
|
69
|
+
readonly v: 1
|
|
70
|
+
readonly id: string
|
|
71
|
+
readonly teamID: string
|
|
72
|
+
readonly role: string
|
|
73
|
+
readonly sessionID: string
|
|
74
|
+
readonly generation: number
|
|
75
|
+
readonly since: number
|
|
76
|
+
readonly until?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Work submitted to a team (HWA-36): routed to the entry role, tracked as a workspace assignment. */
|
|
80
|
+
export interface TeamIntake extends Revisioned {
|
|
81
|
+
readonly v: 1
|
|
82
|
+
readonly id: string
|
|
83
|
+
readonly teamID: string
|
|
84
|
+
readonly title: string
|
|
85
|
+
readonly source: { readonly kind: "user" } | { readonly kind: "app"; readonly tokenID: string } | { readonly kind: "session"; readonly sessionID: string }
|
|
86
|
+
readonly ref?: string
|
|
87
|
+
readonly assignmentID?: string
|
|
88
|
+
readonly status: "queued" | "routed" | "done" | "cancelled" | "failed"
|
|
89
|
+
readonly createdAt: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const TEAM_EVENT_TYPES = ["team.created", "team.updated", "team.archived", "team.staffed", "team.unstaffed", "team.intake", "team.denied"] as const
|
|
93
|
+
|
|
94
|
+
// ---- Addressing (decision qxzrdc6c) --------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
export interface TeamRoleAddress {
|
|
97
|
+
readonly role: string
|
|
98
|
+
readonly project?: string
|
|
99
|
+
readonly team: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parses `@@role#team` and `@@role.project#team`. Undefined for anything else, including a plain `@@role`
|
|
104
|
+
* (which parseAddress handles; a team-less role address that resolves in several teams is ambiguous: HWA-34).
|
|
105
|
+
*/
|
|
106
|
+
export function parseTeamRoleAddress(addr: string): TeamRoleAddress | undefined {
|
|
107
|
+
const match = /^@@([a-z0-9-]{2,40})(?:\.([^./\s#]+))?#([a-z0-9-]{2,40})$/i.exec(addr)
|
|
108
|
+
if (!match) return undefined
|
|
109
|
+
const [, role, project, team] = match
|
|
110
|
+
if (role !== role!.toLowerCase() || team !== team!.toLowerCase()) return undefined
|
|
111
|
+
return project ? { role: role!, project: project.toLowerCase(), team: team! } : { role: role!, team: team! }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const formatTeamRoleAddress = (a: TeamRoleAddress): string => `@@${a.role}${a.project ? `.${a.project}` : ""}#${a.team}`
|
|
115
|
+
|
|
116
|
+
// ---- Delivery rule (decisions zltiwb4s, 5etngvgm, r3vv4eon) -----------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/** One (team, role) a session holds. */
|
|
119
|
+
export interface TeamSeat {
|
|
120
|
+
readonly teamID: string
|
|
121
|
+
readonly role: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface DeliveryQuestion {
|
|
125
|
+
readonly kind: TeamMessageKind | "reply"
|
|
126
|
+
/** The recipient is the user (@me escalation). */
|
|
127
|
+
readonly toUser: boolean
|
|
128
|
+
/** For replies: the ask being answered was itself delivered (permitted). */
|
|
129
|
+
readonly replyToPermittedAsk: boolean
|
|
130
|
+
readonly senderSeats: readonly TeamSeat[]
|
|
131
|
+
readonly recipientSeats: readonly TeamSeat[]
|
|
132
|
+
/** Explicit team context (`#team` address or API field): only that team's policy is consulted. */
|
|
133
|
+
readonly teamID?: string
|
|
134
|
+
readonly teams: ReadonlyMap<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state">>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type DeliveryVerdict =
|
|
138
|
+
| { readonly allow: true; readonly via: "user" | "reply" | "no_team" | "team"; readonly teamID?: string }
|
|
139
|
+
| { readonly allow: false; readonly reason: "policy" | "external" | "not_member" }
|
|
140
|
+
|
|
141
|
+
function edgePermits(team: Pick<TeamDefinition, "mode" | "edges">, from: string, to: string, kind: TeamMessageKind): boolean {
|
|
142
|
+
if (team.mode === "open") return true
|
|
143
|
+
return team.edges.some((e) => e.kinds.includes(kind) && ((e.from === from && e.to === to) || (e.direction === "two-way" && e.from === to && e.to === from)))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether central may deliver a msg/ask/reply between two sessions under team policy. Order:
|
|
148
|
+
* 1. @me escalation and replies to a permitted ask: always allowed.
|
|
149
|
+
* 2. A sender in no (active) team: today's behavior, allowed (r3vv4eon). Inbound to team members included.
|
|
150
|
+
* 3. With a team context: the sender must hold a seat there; only that team decides.
|
|
151
|
+
* 4. Shared teams: allowed if at least one shared team permits sender role → recipient role for this kind.
|
|
152
|
+
* 5. No shared team: allowed only if one of the sender's roles opts into external contact (5etngvgm).
|
|
153
|
+
*/
|
|
154
|
+
export function teamDeliveryVerdict(q: DeliveryQuestion): DeliveryVerdict {
|
|
155
|
+
if (q.toUser) return { allow: true, via: "user" }
|
|
156
|
+
if (q.kind === "reply" && q.replyToPermittedAsk) return { allow: true, via: "reply" }
|
|
157
|
+
const active = (s: TeamSeat): boolean => q.teams.get(s.teamID)?.state === "active"
|
|
158
|
+
const sender = q.senderSeats.filter(active)
|
|
159
|
+
const recipient = q.recipientSeats.filter(active)
|
|
160
|
+
if (sender.length === 0) return { allow: true, via: "no_team" }
|
|
161
|
+
const kind: TeamMessageKind = q.kind === "reply" ? "msg" : q.kind
|
|
162
|
+
const consider = q.teamID === undefined ? sender : sender.filter((s) => s.teamID === q.teamID)
|
|
163
|
+
if (q.teamID !== undefined && consider.length === 0) return { allow: false, reason: "not_member" }
|
|
164
|
+
let shared = false
|
|
165
|
+
for (const s of consider) {
|
|
166
|
+
for (const r of recipient) {
|
|
167
|
+
if (r.teamID !== s.teamID) continue
|
|
168
|
+
shared = true
|
|
169
|
+
if (edgePermits(q.teams.get(s.teamID)!, s.role, r.role, kind)) return { allow: true, via: "team", teamID: s.teamID }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (shared || q.teamID !== undefined) return { allow: false, reason: "policy" }
|
|
173
|
+
const external = sender.some((s) => q.teams.get(s.teamID)!.roles.some((r) => r.name === s.role && r.externalContact === true))
|
|
174
|
+
return external ? { allow: true, via: "team" } : { allow: false, reason: "external" }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- Input checks -----------------------------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export function teamInputProblem(v: unknown): string | undefined {
|
|
180
|
+
if (!isObj(v)) return "body must be an object"
|
|
181
|
+
if (!isSlug(v.name)) return "name must be 2–40 lowercase letters, digits or dashes"
|
|
182
|
+
if (!isLabel(v.title)) return "title must be 1–120 characters of text"
|
|
183
|
+
if (v.description !== undefined && !isText(v.description, DESCRIPTION_MAX)) return `description must be at most ${DESCRIPTION_MAX} characters`
|
|
184
|
+
if (v.mode !== "open" && v.mode !== "restricted") return "mode must be open or restricted"
|
|
185
|
+
if (!Array.isArray(v.roles) || v.roles.length === 0 || v.roles.length > TEAM_LIMITS.rolesPerTeam) return `roles must list 1–${TEAM_LIMITS.rolesPerTeam} roles`
|
|
186
|
+
const names = new Set<string>()
|
|
187
|
+
for (const role of v.roles) {
|
|
188
|
+
if (!isObj(role) || !isSlug(role.name)) return "each role needs a slug name"
|
|
189
|
+
if (names.has(role.name)) return `role ${role.name} is listed twice`
|
|
190
|
+
names.add(role.name)
|
|
191
|
+
if (role.label !== undefined && !isLabel(role.label)) return `role ${role.name} label must be 1–120 characters`
|
|
192
|
+
if (role.externalContact !== undefined && typeof role.externalContact !== "boolean") return `role ${role.name} externalContact must be true or false`
|
|
193
|
+
}
|
|
194
|
+
if (typeof v.entryRole !== "string" || !names.has(v.entryRole)) return "entryRole must name one of the roles"
|
|
195
|
+
const edges = v.edges ?? []
|
|
196
|
+
if (!Array.isArray(edges) || edges.length > TEAM_LIMITS.edgesPerTeam) return `edges must be a list of at most ${TEAM_LIMITS.edgesPerTeam}`
|
|
197
|
+
for (const e of edges) {
|
|
198
|
+
if (!isObj(e) || typeof e.from !== "string" || typeof e.to !== "string" || !names.has(e.from) || !names.has(e.to)) return "each edge must connect two listed roles"
|
|
199
|
+
if (e.from === e.to) return "an edge must connect two different roles"
|
|
200
|
+
if (!Array.isArray(e.kinds) || e.kinds.length === 0 || !e.kinds.every((k) => k === "msg" || k === "ask")) return "edge kinds must be msg and/or ask"
|
|
201
|
+
if (e.direction !== "one-way" && e.direction !== "two-way") return "edge direction must be one-way or two-way"
|
|
202
|
+
}
|
|
203
|
+
return tagsProblem(v.tags) ?? metadataProblem(v.metadata)
|
|
204
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-runtime-openshell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,8 +9,12 @@ export const validName = (name: string): boolean => NAME.test(name)
|
|
|
9
9
|
|
|
10
10
|
export type Address = { kind: "id"; id: string } | { kind: "name"; name: string; project?: string } | { kind: "role"; role: string; project?: string }
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* A project scope: any non-empty run of characters without dots, slashes, spaces or `#`, compared lowercased.
|
|
14
|
+
* `#` is the team separator (`@@role#team`, `@@role.project#team`: protocol parseTeamRoleAddress), never part of
|
|
15
|
+
* a plain address, so a team address is never resolved as a global role.
|
|
16
|
+
*/
|
|
17
|
+
const SCOPED = /^([a-z0-9-]{2,40})(?:\.([^./\s#]+))?$/i
|
|
14
18
|
|
|
15
19
|
/** `ses_…` (an ID or ID prefix), `@name[.project]` or `@@role[.project]`; undefined when malformed. */
|
|
16
20
|
export function parseAddress(addr: string): Address | undefined {
|
|
@@ -23,3 +27,47 @@ export function parseAddress(addr: string): Address | undefined {
|
|
|
23
27
|
const scope = project ? { project } : {}
|
|
24
28
|
return role ? { kind: "role", role: match[1]!, ...scope } : { kind: "name", name: match[1]!, ...scope }
|
|
25
29
|
}
|
|
30
|
+
|
|
31
|
+
/** What a refused address answers: the accepted forms, and a hint derived from the address's shape only. */
|
|
32
|
+
export interface AddressProblem {
|
|
33
|
+
readonly message: string
|
|
34
|
+
readonly hint: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The address forms, as error messages list them. */
|
|
38
|
+
export const ADDRESS_FORMS = "ses_…, @name, @@role or @@role#team"
|
|
39
|
+
const FORMS_HINT = "Addresses are ses_… (an ID or a prefix), @name[.project], @@role[.project], @@role#team (a role in one of your teams; quote # in a shell) or @me"
|
|
40
|
+
const TEAM_FORMS = "@@role#team or @@role.project#team"
|
|
41
|
+
const SLUG = /^[a-z0-9-]{2,40}$/
|
|
42
|
+
/** A well-formed team address (protocol parseTeamRoleAddress's syntax). */
|
|
43
|
+
const TEAM_ADDRESS = /^@@[a-z0-9-]{2,40}(?:\.[^./\s#]+)?#[a-z0-9-]{2,40}$/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Why `addr` is not an address here (parseAddress refused it). Pure syntax: it never says whether a session, role
|
|
47
|
+
* or team exists, and it quotes only parts of the input that are valid name slugs (never free text).
|
|
48
|
+
*/
|
|
49
|
+
export function addressProblem(addr: string): AddressProblem {
|
|
50
|
+
const message = `Not an address: use ${ADDRESS_FORMS}`
|
|
51
|
+
const hash = addr.indexOf("#")
|
|
52
|
+
if (hash < 0) {
|
|
53
|
+
const lowercase = /[A-Z]/.test(addr) && parseAddress(addr.toLowerCase()) !== undefined
|
|
54
|
+
return { message, hint: lowercase ? `Names and roles are lowercase. ${FORMS_HINT}` : FORMS_HINT }
|
|
55
|
+
}
|
|
56
|
+
if (TEAM_ADDRESS.test(addr)) {
|
|
57
|
+
return {
|
|
58
|
+
message: "A team address (@@role#team) is not accepted here: use ses_…, @name or @@role",
|
|
59
|
+
hint: "Team addresses work for messages and reads (msg, ask, who, show, log, events); to act on a session, name it: ses_… or @name (see who)",
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const head = addr.slice(0, hash)
|
|
63
|
+
const team = addr.slice(hash + 1)
|
|
64
|
+
const doubled = head.startsWith("@@")
|
|
65
|
+
const name = head.startsWith("@") ? head.slice(doubled ? 2 : 1).split(".")[0]! : undefined
|
|
66
|
+
if (name !== undefined && !doubled && SLUG.test(name) && SLUG.test(team)) {
|
|
67
|
+
return { message, hint: `A team address names a role, not a session: @@${name}#${team} (the ${name} role of team ${team}), or @${name} without #team for the session named ${name}` }
|
|
68
|
+
}
|
|
69
|
+
if (name !== undefined && SLUG.test(name.toLowerCase()) && SLUG.test(team.toLowerCase())) {
|
|
70
|
+
return { message, hint: `Role and team names are lowercase: ${TEAM_FORMS}` }
|
|
71
|
+
}
|
|
72
|
+
return { message, hint: `Team addresses are ${TEAM_FORMS}: lowercase a-z, 0-9 and -, 2–40 characters (quote # in a shell)` }
|
|
73
|
+
}
|