forked from bai/curriculum-project-hub
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb96159b3b | |||
| ef96f8d33d | |||
| 5e10419fc8 | |||
| 64b3d1fc64 | |||
| b673dd1fe9 | |||
| aaa098bb8b | |||
| 97f7972cc5 | |||
| cc42e6a7c6 | |||
| 4e01c18cac | |||
|
a12984d174
|
|||
| b93acd8e8c | |||
| 35251986af | |||
| 3ee6da7ceb | |||
| 79f72ecca8 | |||
| ae5f78f036 | |||
| 0782e155f6 | |||
|
0726dc13c8
|
|||
|
fb66614e38
|
|||
| 11de9e81db | |||
| 46ce942aec | |||
| 8990277916 | |||
| b217c16c1b |
@@ -123,15 +123,23 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
|
||||
workspace or service-user config from widening tools, hooks, MCP servers, or
|
||||
sandbox paths.
|
||||
- Agent skills are Organization-scoped runtime configuration, not Hub release
|
||||
assets. A controlled host-console installer imports each version into a
|
||||
content-addressed persistent store and records its digest in PostgreSQL. A
|
||||
role selects enabled Organization skills alongside its model, system prompt
|
||||
and tool allowlist. Each run copies only those selected immutable versions
|
||||
into a run-scoped plugin outside the project workspace; the sandbox exposes
|
||||
that snapshot read-only and deletes it after the run. SDK-bundled skills and
|
||||
filesystem setting sources remain disabled, so project `.claude` content
|
||||
cannot register skills or widen tools. Requested skill versions are recorded
|
||||
on `run.created`; SDK initialization remains authoritative loading evidence.
|
||||
assets. Skill content is imported into a content-addressed persistent store
|
||||
through a shared ingestion pipeline (`importSkillDirectory` for host-console
|
||||
CLI, `importSkillFromFiles` for org-admin web surface) that enforces the same
|
||||
safety checks: `SKILL.md` manifest required, 512-file / 16-byte limits,
|
||||
symlink rejection, SHA-256 content addressing. The web surface
|
||||
(`OrganizationAgentConfiguration.installSkillFromFiles`) writes to the same
|
||||
store as the host-console CLI (`installSkill`); both flow through
|
||||
`commitSkillContent` for deduplication and atomic rename into
|
||||
`versions/<digest>/`. Org-admin authentication gates the web surface; the
|
||||
content-addressed store remains platform-controlled. A role selects enabled
|
||||
Organization skills alongside its model, system prompt and tool allowlist.
|
||||
Each run copies only those selected immutable versions into a run-scoped
|
||||
plugin outside the project workspace; the sandbox exposes that snapshot
|
||||
read-only and deletes it after the run. SDK-bundled skills and filesystem
|
||||
setting sources remain disabled, so project `.claude` content cannot register
|
||||
skills or widen tools. Requested skill versions are recorded on
|
||||
`run.created`; SDK initialization remains authoritative loading evidence.
|
||||
- Network: open (see Open Questions).
|
||||
|
||||
`bypassPermissions` is kept (headless server — no interactive prompts); the
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# ADR 0026: Usage Fact Ledger
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0022 pins the cost-attribution contract for the platform: token,
|
||||
provider-reported cost, run count, and duration are attributed by Organization,
|
||||
Project, Run, model, and Provider Connection; "missing provider cost remains
|
||||
unknown rather than zero." ADR-0021 scopes usage accounting as operational
|
||||
reporting, not payment collection (commercial billing stays deferred).
|
||||
|
||||
The implementation today stores this as **one scalar per `AgentRun`**:
|
||||
`inputTokens`, `outputTokens`, `costUsd?`, `costSource?`, written once from the
|
||||
Claude Agent SDK's `result.total_cost_usd` when the run finishes. This is
|
||||
adequate for a single provider-reported model loop, but it cannot represent:
|
||||
|
||||
- **External capability consumption** inside a run. PDF→Markdown bundle
|
||||
conversion, audio/video transcription, OCR and similar media transforms are
|
||||
not the main agent loop. They run as side effects of a run, may use a
|
||||
different provider/model, may bill in non-token units (pages, seconds,
|
||||
invocations), and may report cost through a different channel than the
|
||||
OpenRouter gateway. Today there is no row to write that cost to — it would
|
||||
either disappear or silently corrupt the run's single scalar.
|
||||
- **Multiple model calls within one run** (e.g. a sub-model invoked by a
|
||||
tool, a gateway-side reroute). The scalar collapses them into one number.
|
||||
- **Pricebook derivation** after the fact. With only a final USD figure and no
|
||||
`(provider, model, occurredAt, tokens)` fact, an operator cannot re-derive
|
||||
cost from a price table when the provider did not report it.
|
||||
|
||||
Treating each external call as a nested `AgentRun` was considered and
|
||||
rejected: `AgentRun` carries lock ownership (ADR-0002), session/provider/role
|
||||
binding (ADR-0017), admission/capacity semantics (ADR-0022), and the
|
||||
user-visible task boundary. External calls hold none of those. Making them
|
||||
`AgentRun`s would pollute run counts, admission, lock semantics, and session
|
||||
continuity, and would still not solve non-token metering.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce **`UsageFact`** as the single source of truth for billable
|
||||
consumption inside an `AgentRun`. An `AgentRun` owns zero or more
|
||||
append-only `UsageFact` rows; each row records one billable consumption event:
|
||||
|
||||
- `kind` — `model_completion` (the main agent loop) | `external_capability`
|
||||
| `tool_proxy`. The kind set is `OPEN`; new kinds must be surfaced, not
|
||||
silently folded into an existing one.
|
||||
- `provider` — e.g. `openrouter`, `mineru`, `openai_whisper`.
|
||||
- `model?`, `inputTokens?`, `outputTokens?` — token metering, optional
|
||||
because non-token capabilities have none.
|
||||
- `quantity?` + `unit?` — non-token metering (pages, audio_seconds,
|
||||
invocations), coexisting with tokens rather than replacing them.
|
||||
- `costUsd?` + `costSource` — `provider_reported` | `pricebook_derived` |
|
||||
`unknown`. `costUsd = null` means **unknown, not zero** (ADR-0022). When the
|
||||
provider reported a cost, `costSource = provider_reported` and that value
|
||||
wins. When only tokens are known, a later pricebook pass may derive
|
||||
`costUsd` with `costSource = pricebook_derived`. When neither is possible,
|
||||
`costSource = unknown` and `costUsd` stays null.
|
||||
- `occurredAt` — when the consumption happened; the pricebook derivation
|
||||
depends on this, not on `AgentRun.finishedAt`, because an external
|
||||
capability may complete before the run finishes.
|
||||
- `capabilityId?` — for `external_capability` facts, the registered capability
|
||||
id (e.g. `pdf_to_md_bundle`, `audio_video_to_text`).
|
||||
- `correlationId?` — external request id for reconciliation / idempotency;
|
||||
not part of the aggregation key.
|
||||
|
||||
### Invariants
|
||||
|
||||
1. **Append-only.** A `UsageFact` row is never updated or deleted. A cost
|
||||
correction is a new row; the old row stays. `onDelete: Cascade` exists only
|
||||
so a hard run delete (itself not a normal path) cleans up its facts.
|
||||
2. **Belongs to exactly one Run; never holds a lock.** A fact is a side-effect
|
||||
ledger of one run, not a sub-run. External capability calls obey the same
|
||||
boundary: their identity is `capabilityId + correlationId`, not `RunId`.
|
||||
3. **Missing cost ≠ zero.** Aggregation MUST NOT sum `null` `costUsd` as 0.
|
||||
A run whose facts all have `costUsd = null` is "cost unknown" — reported as
|
||||
`runsWithoutCost`, exactly as the pre-migration `costUsd = null` runs are
|
||||
today. A run with at least one `costUsd`-bearing fact contributes its sum.
|
||||
|
||||
### Rollup cache
|
||||
|
||||
`AgentRun.costUsd / inputTokens / outputTokens / costSource` columns are kept
|
||||
as a **derived rollup cache**, not dropped:
|
||||
|
||||
- Existing non-`/usage` readers (slash `/usage`, session detail, integration
|
||||
tests asserting `run.costUsd`) continue to work without code changes for
|
||||
historical runs.
|
||||
- On run finish, the writer writes the `UsageFact` row first and then mirrors
|
||||
it onto `AgentRun` as two separate statements, not one transaction. The
|
||||
fact is the truth, so it is written first; the cache is derived, so it is
|
||||
written second. A crash between the two leaves the cache stale, but the
|
||||
usage service re-reads `UsageFact` directly, so staleness is recoverable
|
||||
(and the reverse order would lose the truth, which is not). Keeping the
|
||||
fact insert and the run update in separate statements also avoids holding
|
||||
the `AgentRun` row lock across the FK ShareLock taken by the insert, which
|
||||
deadlocks against concurrent workspace teardown under the cascade path
|
||||
`Organization → Project → AgentRun → UsageFact`.
|
||||
- The org/project usage service and the slash `/usage` command read from
|
||||
`UsageFact` directly — they are the canonical aggregation path.
|
||||
|
||||
### Migration
|
||||
|
||||
A new `UsageFact` table is added. For every existing `AgentRun` with a
|
||||
non-null `costUsd` or non-null `inputTokens`/`outputTokens`, the migration
|
||||
inserts **one synthetic `model_completion` fact** carrying the run's
|
||||
provider, model, tokens, cost, and `costSource`. Its `correlationId` is the
|
||||
run id, so the row is identifiable as a backfill artefact. This keeps
|
||||
historical reporting correct under the new aggregation path. Pre-migration
|
||||
runs with no recorded cost remain `runsWithoutCost` by design, matching
|
||||
ADR-0022's "missing cost ≠ zero" rule and the existing migration
|
||||
`20260709143000_agent_run_cost_tracking`'s "no backfill" stance for the
|
||||
truly unrecorded.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The billing model now supports external capabilities (PDF→MD, ASR, OCR, …)
|
||||
without per-capability schema changes: a new capability is one new
|
||||
`capabilityId` value and one or more `external_capability` facts.
|
||||
- `AgentRun.costUsd` is no longer the truth; it is a convenience cache.
|
||||
Readers that need the truth (cost breakdown, per-capability attribution)
|
||||
must read `UsageFact`. The cache MUST be kept consistent on the write path.
|
||||
- The capability registry, pricebook, and any commercial settlement remain
|
||||
`OPEN` and out of pilot scope (ADR-0021). This ADR only pins the ledger
|
||||
shape and invariants.
|
||||
- `usage.ts` and `/usage` now perform a join against `UsageFact` rather than a
|
||||
single-table scan of `AgentRun`; the index on `(runId, occurredAt)` and
|
||||
`(provider, model, occurredAt)` keeps the existing org/project/report
|
||||
queries bounded.
|
||||
- Cost corrections (e.g. a provider rebills a run) produce a new fact row; the
|
||||
rollup cache must be recomputed. The initial release writes facts once at
|
||||
run finish and does not support post-hoc correction flows — that remains
|
||||
`OPEN`.
|
||||
|
||||
## Deferred
|
||||
|
||||
- **Capability registry** as a first-class spec entity (`Spec.System.Capability`)
|
||||
with org-scoped enable/disable, input/output contracts, metering schemas,
|
||||
and org-exclusive credentials (ADR-0024 alignment). This ADR only reserves
|
||||
the `capabilityId` field and the `external_capability` fact kind.
|
||||
- **Pricebook** — a versioned price table keyed by `(provider, model, unit)`
|
||||
with time validity. Required to actually produce `pricebook_derived` costs;
|
||||
until then, facts without provider-reported cost stay `unknown`.
|
||||
- **Post-hoc cost correction flow** — append-only today; correction UI and
|
||||
rollup recompute are `OPEN`.
|
||||
- **Commercial billing, invoicing, settlement** — still deferred per ADR-0021.
|
||||
@@ -0,0 +1,152 @@
|
||||
# ADR 0027: External Capability Registry
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0026 introduced the `UsageFact` ledger with a `kind = external_capability`
|
||||
fact and a `capabilityId` field, but deferred the capability registry itself.
|
||||
Two concrete needs now force the issue:
|
||||
|
||||
- **PDF→Markdown bundle** conversion (and, imminently, audio/video→text)
|
||||
must run as a side effect of an `AgentRun`, bill in non-token units
|
||||
(pages, seconds), use a different provider than the model loop, and report
|
||||
cost through a different channel. It is not a sub-run (ADR-0026 rejected
|
||||
that) and not a model-provider call (it does not speak the Anthropic/
|
||||
OpenRouter protocol).
|
||||
- The Agent already has a Bash tool. Without a first-class capability seam,
|
||||
the path of least resistance is for the agent to shell out to ad-hoc
|
||||
scripts that embed API keys, write to arbitrary paths, and report nothing
|
||||
to the ledger. That is exactly the unattributed, uncontained external
|
||||
consumption ADR-0022/0026 exist to prevent.
|
||||
|
||||
The model-provider connection (`OrganizationProviderConnection`, ADR-0024)
|
||||
is the wrong seam for these services:
|
||||
|
||||
- Its payload schema (`baseUrl` + `authToken` + `anthropicApiKey`) and
|
||||
readiness probe (`/v1/models?supported_parameters=tools`) are specific to
|
||||
OpenRouter/Anthropic. MinerU, Whisper, and future OCR/ASR services have
|
||||
different auth shapes (an API token, optionally a project id) and no
|
||||
`/v1/models` endpoint.
|
||||
- Its uniqueness key is `(organizationId, providerId)` where `providerId`
|
||||
is an OpenRouter-style model-routing id. A capability provider id
|
||||
(`mineru`) names a *service*, not a model.
|
||||
- Coupling capability credentials into the model-provider table would force
|
||||
every capability's auth shape through `ProviderSecretPayloadV1` and every
|
||||
readiness probe through `probeOpenRouterCredential`.
|
||||
|
||||
The Feishu Application Connection (`OrganizationFeishuApplicationConnection`)
|
||||
is the right structural precedent: it reuses the ADR-0024 envelope (KEK →
|
||||
DEK → AES-256-GCM, AAD-bound to purpose/org/connection/version) but has its
|
||||
own connection table, its own payload schema, its own readiness probe, and
|
||||
its own per-org uniqueness. Capability connections follow the same pattern.
|
||||
|
||||
## Decision
|
||||
|
||||
### External Capability
|
||||
|
||||
An **External Capability** is a platform-registered, org-enabled document or
|
||||
media transform service invoked as a side effect of an `AgentRun`. It is
|
||||
identified by a stable `capabilityId` (e.g. `pdf_to_md_bundle`,
|
||||
`audio_video_to_text`). A capability:
|
||||
|
||||
- Has an **input kind** (PDF, image, audio, video, …) and an **output
|
||||
contract** (markdown bundle with extracted images, transcript text, …).
|
||||
- Bills in **non-token units** (pages, audio-seconds) recorded on a
|
||||
`UsageFact` with `kind = external_capability`, or in tokens when the
|
||||
backing service reports them.
|
||||
- Writes its output **into the invoking run's workspace** (ADR-0018
|
||||
`AgentSurface` — no escapes).
|
||||
- Is invoked through a **capability adapter** in Hub, never by the Agent
|
||||
shelling out with embedded credentials.
|
||||
|
||||
### Capability Connection
|
||||
|
||||
Credentials for a capability live in an **`OrganizationCapabilityConnection`**,
|
||||
structurally identical to the Feishu Application Connection:
|
||||
|
||||
- Belongs to exactly one Organization.
|
||||
- Unique by `(organizationId, capabilityId)`.
|
||||
- `DRAFT` / `ACTIVE` / `DISABLED`; resolution accepts only `ACTIVE` with a
|
||||
valid active secret version.
|
||||
- Secret material is an immutable, AAD-bound, KEK-wrapped envelope version
|
||||
(`CapabilityCredentialVersion`), reusing the ADR-0024 encryption
|
||||
machinery with `purpose = "capability"`.
|
||||
- Its payload schema is capability-specific (`CapabilitySecretPayloadV1`:
|
||||
`baseUrl`, `apiToken`, optional `projectId`). New capability types extend
|
||||
the payload, not the connection table.
|
||||
- A capability-specific **readiness probe** validates the credential before
|
||||
activation (e.g. MinerU: a trivial authenticated GET). The probe is
|
||||
injectable, matching the Feishu/provider pattern, so tests never hit the
|
||||
network.
|
||||
|
||||
### Capability Adapter
|
||||
|
||||
The adapter is the seam between the Agent and the external service. It:
|
||||
|
||||
- Resolves the org's active capability connection (fail-closed, no
|
||||
process-global fallback — ADR-0024).
|
||||
- Accepts a workspace-relative input path and an output directory.
|
||||
- Calls the backing service (MinerU, Whisper, …) via an injectable
|
||||
`Client` interface so the real HTTP client is swappable and mockable.
|
||||
- Writes the produced markdown + image assets into the run's workspace.
|
||||
- Writes one `UsageFact` (or more, if the service reports per-stage
|
||||
consumption) with `kind = external_capability`, `capabilityId`,
|
||||
`provider` (the service id), `quantity + unit` (pages / seconds), and
|
||||
`costUsd + costSource = provider_reported` when the service reports cost.
|
||||
|
||||
### Registry
|
||||
|
||||
The platform maintains a **registry** of known capabilities: their id,
|
||||
input kind, output contract, metering unit, and adapter. This is
|
||||
code-level registration (like `ToolRegistry`), not a database table — a
|
||||
capability is available to an Organization only when (a) the platform
|
||||
knows the adapter and (b) the Organization has an `ACTIVE` connection for
|
||||
it. Both gates are required.
|
||||
|
||||
### What is NOT in this ADR
|
||||
|
||||
- The capability invocation is **not** a first-class persisted record
|
||||
(`CapabilityInvocation` table) in this ADR. The `UsageFact` row with
|
||||
`capabilityId` + `correlationId` is the durable trace. If we later need
|
||||
a richer invocation log (retries, partial output, multi-stage status),
|
||||
that is a follow-up; for now the fact is enough.
|
||||
- **Pricebook** remains deferred (ADR-0026). Capability facts use
|
||||
`provider_reported` when the service returns cost; otherwise `unknown`.
|
||||
- **Org-scoped enable/disable policy** beyond connection status is
|
||||
deferred. An org with an `ACTIVE` connection has the capability; one
|
||||
without does not. A finer "enabled but no credential" toggle is not
|
||||
needed yet.
|
||||
- **Agent-facing tool exposure** (how the Agent discovers and calls the
|
||||
capability — MCP tool, Bash wrapper, or built-in) is an implementation
|
||||
detail of the adapter wiring, not a contract concern. The contract pins
|
||||
that the Agent never receives the capability credential.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Adding a new external capability (e.g. `image_ocr`) is: register an
|
||||
adapter, add a `capabilityId` constant, optionally extend the secret
|
||||
payload — no schema change to `UsageFact` or `AgentRun`.
|
||||
- The model-provider connection table stays focused on model routing;
|
||||
capability credentials do not pollute its payload or readiness probe.
|
||||
- Three connection types now share the ADR-0024 envelope: model-provider,
|
||||
Feishu application, and capability. Each has its own table, payload
|
||||
schema, and probe, but the same encryption, rotation, and resolver
|
||||
boundary.
|
||||
- The Agent's Bash tool remains available, but the intended path for
|
||||
document/media transforms is the capability adapter. Whether to narrow
|
||||
Bash for capability-shaped tasks is an operational policy decision,
|
||||
not a contract one.
|
||||
- Tests prove: workspace containment of capability output, fail-closed
|
||||
credential resolution, `UsageFact` attribution with non-token metering,
|
||||
and that the Agent process never receives the capability credential.
|
||||
|
||||
## Deferred
|
||||
|
||||
- `CapabilityInvocation` as a first-class durable record (status, retries,
|
||||
partial output) — currently the `UsageFact` row is the only trace.
|
||||
- Pricebook derivation for capability costs (ADR-0026 deferred).
|
||||
- Org-scoped capability enable/disable policy finer than connection status.
|
||||
- Agent-facing tool discovery (MCP vs built-in) for capabilities.
|
||||
@@ -34,6 +34,10 @@ HUB_FEISHU_EVENTS_PER_MINUTE="120"
|
||||
# to this path and rejects any overlap before installing the unit.
|
||||
HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
|
||||
|
||||
# Persistent root for the content-addressed agent skill store. Required at hub
|
||||
# startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills).
|
||||
HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/skills"
|
||||
|
||||
# This process is pinned to exactly one Organization. Feishu credentials are
|
||||
# resolved from that Organization's encrypted ACTIVE connection.
|
||||
HUB_SILO_ORGANIZATION_ID=""
|
||||
|
||||
@@ -168,6 +168,16 @@ export interface FeishuApplicationConnection {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CapabilityConnection {
|
||||
id: string;
|
||||
capabilityId: string;
|
||||
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
|
||||
activeVersion: number | null;
|
||||
keyId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UsageTotals {
|
||||
runCount: number;
|
||||
runsWithCost: number;
|
||||
@@ -226,6 +236,50 @@ export interface CapacityPolicyView {
|
||||
dimensions: CapacityDimensionRow[];
|
||||
}
|
||||
|
||||
export interface AgentRoleRow {
|
||||
id: string;
|
||||
roleId: string;
|
||||
label: string;
|
||||
defaultModel: string | null;
|
||||
systemPrompt: string | null;
|
||||
tools: readonly string[] | null;
|
||||
sortOrder: number;
|
||||
isDefault: boolean;
|
||||
disabledAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
skillNames: readonly string[];
|
||||
}
|
||||
|
||||
export interface AgentSkillRow {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string | null;
|
||||
contentDigest: string;
|
||||
disabledAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
boundRoleIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface SkillFileEntry {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface InstalledSkillResult {
|
||||
id: string;
|
||||
name: string;
|
||||
contentDigest: string;
|
||||
}
|
||||
|
||||
export interface AgentModelRow {
|
||||
id: string;
|
||||
label: string;
|
||||
toolCapable: boolean;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const api = {
|
||||
@@ -261,8 +315,7 @@ export const api = {
|
||||
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
|
||||
|
||||
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
|
||||
myProjects: (slug: string) =>
|
||||
get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
|
||||
myProjects: (slug: string) => get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
|
||||
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
|
||||
post(`${orgBase(slug)}/folders`, body) as Promise<{
|
||||
id: string;
|
||||
@@ -338,8 +391,48 @@ export const api = {
|
||||
disableFeishuApplication: (slug: string) =>
|
||||
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
|
||||
|
||||
capacityPolicy: (slug: string) =>
|
||||
get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
|
||||
capabilityConnections: (slug: string) =>
|
||||
get(`${orgBase(slug)}/capability-connections`) as Promise<{ connections: CapabilityConnection[] }>,
|
||||
capabilityConnection: (slug: string, capabilityId: string) =>
|
||||
get(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<{
|
||||
connection: CapabilityConnection | null;
|
||||
}>,
|
||||
rotateCapabilityConnection: (
|
||||
slug: string,
|
||||
capabilityId: string,
|
||||
body: { accessKeyId: string; accessKeySecret: string; endpoint: string },
|
||||
) =>
|
||||
put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>,
|
||||
disableCapabilityConnection: (slug: string, capabilityId: string) =>
|
||||
del(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<CapabilityConnection>,
|
||||
|
||||
capacityPolicy: (slug: string) => get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
|
||||
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
|
||||
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
|
||||
|
||||
agentRoles: (slug: string) => get(`${orgBase(slug)}/agent-roles`) as Promise<{ roles: AgentRoleRow[] }>,
|
||||
upsertAgentRole: (
|
||||
slug: string,
|
||||
roleId: string,
|
||||
body: {
|
||||
label: string;
|
||||
defaultModel?: string | null;
|
||||
systemPrompt?: string | null;
|
||||
tools?: readonly string[] | null;
|
||||
sortOrder?: number;
|
||||
isDefault?: boolean;
|
||||
},
|
||||
) => put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}`, body) as Promise<AgentRoleRow>,
|
||||
setAgentRoleSkills: (slug: string, roleId: string, skillNames: readonly string[]) =>
|
||||
put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/skills`, { skillNames }) as Promise<{
|
||||
skillNames: string[];
|
||||
}>,
|
||||
agentSkills: (slug: string) => get(`${orgBase(slug)}/agent-skills`) as Promise<{ skills: AgentSkillRow[] }>,
|
||||
agentSkillFiles: (slug: string, name: string) =>
|
||||
get(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/files`) as Promise<{ files: SkillFileEntry[] }>,
|
||||
installAgentSkill: (slug: string, name: string, body: { version: string; files: readonly SkillFileEntry[] }) =>
|
||||
put(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<InstalledSkillResult>,
|
||||
patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) =>
|
||||
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>,
|
||||
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
folders,
|
||||
projects,
|
||||
slug,
|
||||
onCreateFolder,
|
||||
onCreateProject,
|
||||
}: {
|
||||
folder: ExplorerFolder;
|
||||
folders: ExplorerFolder[];
|
||||
@@ -19,30 +21,58 @@
|
||||
binding: { chatId: string } | null;
|
||||
}[];
|
||||
slug: string;
|
||||
onCreateFolder?: (parentId: string) => void;
|
||||
onCreateProject?: (folderId: string) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(true);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm transition hover:bg-surface-100"
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span>
|
||||
<span class="flex h-7 w-7 items-center justify-center border border-warning-300 bg-warning-50 text-warning-800">
|
||||
<Icon name="folder" class="h-4 w-4" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-surface-900">{folder.name}</span>
|
||||
<span class="saas-badge-neutral">{folder.projectCount} 项目</span>
|
||||
{#if folder.childFolderCount > 0}
|
||||
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
|
||||
<div class="group flex w-full items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100">
|
||||
<button type="button" class="flex min-w-0 flex-1 items-center gap-2.5 text-left" onclick={() => (open = !open)}>
|
||||
<span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span>
|
||||
<span class="flex h-7 w-7 items-center justify-center border border-warning-300 bg-warning-50 text-warning-800">
|
||||
<Icon name="folder" class="h-4 w-4" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-surface-900">{folder.name}</span>
|
||||
<span class="saas-badge-neutral">{folder.projectCount} 项目</span>
|
||||
{#if folder.childFolderCount > 0}
|
||||
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if onCreateFolder || onCreateProject}
|
||||
<span
|
||||
class="flex shrink-0 items-center gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
|
||||
>
|
||||
{#if onCreateFolder}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center border border-surface-300 bg-surface-50 text-surface-600 transition hover:border-primary-400 hover:text-primary-700"
|
||||
title="在此新建子文件夹"
|
||||
aria-label="在 {folder.name} 内新建子文件夹"
|
||||
onclick={() => onCreateFolder(folder.id)}
|
||||
>
|
||||
<Icon name="folder-plus" class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if onCreateProject}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center border border-surface-300 bg-surface-50 text-surface-600 transition hover:border-primary-400 hover:text-primary-700"
|
||||
title="在此新建项目"
|
||||
aria-label="在 {folder.name} 内新建项目"
|
||||
onclick={() => onCreateProject(folder.id)}
|
||||
>
|
||||
<Icon name="file-plus" class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if open}
|
||||
<div class="ml-4 border-l border-surface-300 pl-2">
|
||||
<FolderTree {folders} {projects} parentId={folder.id} {slug} />
|
||||
<FolderTree {folders} {projects} parentId={folder.id} {slug} {onCreateFolder} {onCreateProject} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
projects,
|
||||
parentId,
|
||||
slug,
|
||||
onCreateFolder,
|
||||
onCreateProject,
|
||||
}: {
|
||||
folders: ExplorerFolder[];
|
||||
projects: {
|
||||
@@ -20,6 +22,8 @@
|
||||
}[];
|
||||
parentId: string | null;
|
||||
slug: string;
|
||||
onCreateFolder?: (parentId: string) => void;
|
||||
onCreateProject?: (folderId: string) => void;
|
||||
} = $props();
|
||||
|
||||
let childFolders = $derived(folders.filter((f) => f.parentId === parentId));
|
||||
@@ -44,6 +48,6 @@
|
||||
{/each}
|
||||
|
||||
{#each childFolders as f (f.id)}
|
||||
<FolderNode folder={f} {folders} {projects} {slug} />
|
||||
<FolderNode folder={f} {folders} {projects} {slug} {onCreateFolder} {onCreateProject} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,13 @@
|
||||
| 'logout'
|
||||
| 'org'
|
||||
| 'chevron'
|
||||
| 'arrow-left'
|
||||
| 'folder'
|
||||
| 'file'
|
||||
| 'check';
|
||||
| 'folder-plus'
|
||||
| 'file-plus'
|
||||
| 'check'
|
||||
| 'roles';
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
@@ -100,6 +104,10 @@
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
{:else if name === 'arrow-left'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
{:else if name === 'folder'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
@@ -116,8 +124,32 @@
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === 'folder-plus'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === 'file-plus'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === 'check'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{:else if name === 'roles'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.847-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.847.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.456-2.456L14.25 6l1.035-.259a3.375 3.375 0 002.456-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox, Label } from 'bits-ui';
|
||||
import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
|
||||
import { api } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { TOOL_OPTIONS } from '$lib/constants';
|
||||
import SelectField from '$lib/components/SelectField.svelte';
|
||||
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
let {
|
||||
r,
|
||||
models,
|
||||
skills,
|
||||
slug,
|
||||
onupdated,
|
||||
onskillschanged,
|
||||
}: {
|
||||
r: AgentRoleRow;
|
||||
models: AgentModelRow[];
|
||||
skills: AgentSkillRow[];
|
||||
slug: string;
|
||||
onupdated: (updated: AgentRoleRow) => void;
|
||||
onskillschanged: (roleId: string, skillNames: string[]) => void;
|
||||
} = $props();
|
||||
|
||||
const initial = {
|
||||
label: r.label,
|
||||
defaultModel: r.defaultModel ?? '',
|
||||
systemPrompt: r.systemPrompt ?? '',
|
||||
unrestricted: r.tools === null,
|
||||
tools: r.tools ?? [],
|
||||
sortOrder: String(r.sortOrder),
|
||||
isDefault: r.isDefault,
|
||||
skillNames: r.skillNames,
|
||||
};
|
||||
let label = $state(initial.label);
|
||||
let defaultModel = $state(initial.defaultModel);
|
||||
let systemPrompt = $state(initial.systemPrompt);
|
||||
let unrestricted = $state(initial.unrestricted);
|
||||
let selectedTools = $state<string[]>([...initial.tools]);
|
||||
let sortOrder = $state(initial.sortOrder);
|
||||
let isDefault = $state(initial.isDefault);
|
||||
let selectedSkills = $state<string[]>([...initial.skillNames]);
|
||||
let saving = $state(false);
|
||||
|
||||
const groupedTools = TOOL_OPTIONS.reduce(
|
||||
(acc, t) => {
|
||||
(acc[t.group] ??= []).push(t);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof TOOL_OPTIONS>,
|
||||
);
|
||||
|
||||
const modelItems = $derived([
|
||||
{ value: '', label: '(使用平台默认模型)' },
|
||||
...models.map((m) => ({ value: m.id, label: `${m.label}(${m.id})` })),
|
||||
]);
|
||||
|
||||
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
|
||||
|
||||
function skillsDirty(): boolean {
|
||||
const a = [...selectedSkills].sort();
|
||||
const b = [...r.skillNames].sort();
|
||||
return a.length !== b.length || a.some((v, i) => v !== b[i]);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const trimmedLabel = label.trim();
|
||||
if (trimmedLabel === '') {
|
||||
toastError('显示名不能为空');
|
||||
return;
|
||||
}
|
||||
const order = Number(sortOrder);
|
||||
if (sortKeyDirty() && (!Number.isSafeInteger(order) || order < 0)) {
|
||||
toastError('排序必须为非负整数');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
const tools = unrestricted ? null : selectedTools;
|
||||
try {
|
||||
const updated = await api.upsertAgentRole(slug, r.roleId, {
|
||||
label: trimmedLabel,
|
||||
defaultModel: defaultModel === '' ? null : defaultModel,
|
||||
systemPrompt: systemPrompt === '' ? null : systemPrompt,
|
||||
tools,
|
||||
...(sortKeyDirty() ? { sortOrder: order } : {}),
|
||||
isDefault,
|
||||
});
|
||||
onupdated(updated);
|
||||
if (skillsDirty()) {
|
||||
const res = await api.setAgentRoleSkills(slug, r.roleId, selectedSkills);
|
||||
selectedSkills = [...res.skillNames];
|
||||
onskillschanged(r.roleId, res.skillNames);
|
||||
}
|
||||
toastSuccess('角色已保存');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function sortKeyDirty(): boolean {
|
||||
return Number(sortOrder) !== r.sortOrder;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="saas-card-pad">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<span class="saas-badge-primary font-mono">/{r.roleId}</span>
|
||||
<span class="text-sm text-surface-700">{label || r.label}</span>
|
||||
{#if r.isDefault}
|
||||
<span class="saas-badge-success">默认</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label.Root for="role-label-{r.id}" class="saas-label">显示名</Label.Root>
|
||||
<input id="role-label-{r.id}" class="saas-input" bind:value={label} />
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root for="role-sort-{r.id}" class="saas-label">排序</Label.Root>
|
||||
<input id="role-sort-{r.id}" class="saas-input" type="number" min="0" bind:value={sortOrder} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="saas-label">默认模型</p>
|
||||
<SelectField items={modelItems} bind:value={defaultModel} />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<span class="saas-label">工具白名单</span>
|
||||
<label class="mb-3 flex cursor-pointer items-center gap-2 border border-surface-300 bg-surface-100 px-3 py-2">
|
||||
<CheckboxControl bind:checked={unrestricted} />
|
||||
<span class="text-sm">不限(使用全部注册工具)</span>
|
||||
</label>
|
||||
<div class="space-y-3 {unrestricted ? 'pointer-events-none opacity-40' : ''}">
|
||||
<Checkbox.Group bind:value={selectedTools} disabled={unrestricted}>
|
||||
{#each Object.entries(groupedTools) as [group, tools]}
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-surface-600">{group}</p>
|
||||
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{#each tools as t}
|
||||
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
|
||||
<Checkbox.Root class="saas-checkbox" value={t.id} id={`tool-${r.id}-${t.id}`}>
|
||||
{#snippet children({ checked })}
|
||||
{#if checked}
|
||||
<Icon name="check" class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Checkbox.Root>
|
||||
<span>{t.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<span class="saas-label">技能绑定</span>
|
||||
{#if skills.length === 0}
|
||||
<p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装(ADR-0018)。</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{#each skillItems as s}
|
||||
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
|
||||
<CheckboxControl
|
||||
checked={selectedSkills.includes(s.value)}
|
||||
onchange={(checked) => {
|
||||
selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
|
||||
}}
|
||||
/>
|
||||
<span class="font-mono text-xs">{s.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<Label.Root for="role-prompt-{r.id}" class="saas-label">系统提示词</Label.Root>
|
||||
<textarea
|
||||
id="role-prompt-{r.id}"
|
||||
class="saas-textarea"
|
||||
rows="4"
|
||||
placeholder="系统提示词(可选)。会话开始时注入,定义智能体人格/指令。"
|
||||
bind:value={systemPrompt}></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<CheckboxControl bind:checked={isDefault} />
|
||||
<span class="text-sm">设为组织默认角色</span>
|
||||
</label>
|
||||
<span class="text-xs text-surface-600">更新于 {fmtDate(r.updatedAt)}</span>
|
||||
<div class="flex-1"></div>
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script lang="ts">
|
||||
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
|
||||
import { api } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
let {
|
||||
slug,
|
||||
skill,
|
||||
oninstalled,
|
||||
ondisabled,
|
||||
}: {
|
||||
slug: string;
|
||||
skill: AgentSkillRow;
|
||||
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
|
||||
ondisabled: (name: string) => void;
|
||||
} = $props();
|
||||
|
||||
type FileNode = { path: string; content: string };
|
||||
|
||||
let files = $state<FileNode[]>([]);
|
||||
let selectedPath = $state<string | null>(null);
|
||||
let version = $state(skill.version);
|
||||
let description = $state(skill.description ?? '');
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let dirty = $state(false);
|
||||
let newFilePath = $state('');
|
||||
let showNewFile = $state(false);
|
||||
|
||||
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
|
||||
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
|
||||
const sortedFiles = $derived([...files].sort((a, b) => a.path.localeCompare(b.path)));
|
||||
|
||||
async function loadFiles() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await api.agentSkillFiles(slug, skill.name);
|
||||
files = res.files.map((f) => ({ path: f.path, content: f.content }));
|
||||
if (files.length > 0 && selectedPath === null) {
|
||||
selectedPath = files[0]!.path;
|
||||
}
|
||||
dirty = false;
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectFile(path: string) {
|
||||
selectedPath = path;
|
||||
}
|
||||
|
||||
function updateContent(path: string, content: string) {
|
||||
const file = files.find((f) => f.path === path);
|
||||
if (file) {
|
||||
file.content = content;
|
||||
dirty = true;
|
||||
if (path === 'SKILL.md') {
|
||||
const desc = parseDescription(content);
|
||||
if (desc !== null) description = desc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addFile() {
|
||||
const path = newFilePath.trim();
|
||||
if (path === '') {
|
||||
toastError('文件路径不能为空');
|
||||
return;
|
||||
}
|
||||
if (files.some((f) => f.path === path)) {
|
||||
toastError(`文件已存在:${path}`);
|
||||
return;
|
||||
}
|
||||
if (path.startsWith('/') || path.includes('..')) {
|
||||
toastError('文件路径必须为相对路径');
|
||||
return;
|
||||
}
|
||||
files.push({ path, content: '' });
|
||||
selectedPath = path;
|
||||
newFilePath = '';
|
||||
showNewFile = false;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
function deleteFile(path: string) {
|
||||
if (path === 'SKILL.md') {
|
||||
toastError('SKILL.md 是必需的 manifest,不能删除');
|
||||
return;
|
||||
}
|
||||
files = files.filter((f) => f.path !== path);
|
||||
if (selectedPath === path) {
|
||||
selectedPath = files.length > 0 ? files[0]!.path : null;
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
function parseDescription(manifest: string): string | null {
|
||||
const match = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(manifest);
|
||||
return match ? match[1]!.trim() : null;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!hasManifest) {
|
||||
toastError('缺少 SKILL.md manifest 文件');
|
||||
return;
|
||||
}
|
||||
const trimmedVersion = version.trim();
|
||||
if (trimmedVersion === '') {
|
||||
toastError('版本号不能为空');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
const fileEntries: SkillFileEntry[] = files.map((f) => ({ path: f.path, content: f.content }));
|
||||
const result = await api.installAgentSkill(slug, skill.name, {
|
||||
version: trimmedVersion,
|
||||
files: fileEntries,
|
||||
});
|
||||
dirty = false;
|
||||
toastSuccess('技能已保存');
|
||||
oninstalled(result);
|
||||
await loadFiles();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
saving = true;
|
||||
try {
|
||||
await api.patchAgentSkill(slug, skill.name, { disabled: true });
|
||||
toastSuccess('技能已禁用');
|
||||
ondisabled(skill.name);
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveDescription() {
|
||||
const manifest = files.find((f) => f.path === 'SKILL.md');
|
||||
if (!manifest) return;
|
||||
const updated = updateFrontmatter(manifest.content, 'description', description.trim());
|
||||
manifest.content = updated;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
function updateFrontmatter(content: string, key: string, value: string): string {
|
||||
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
|
||||
if (regex.test(content)) {
|
||||
return content.replace(regex, `${key}: ${value}`);
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
if (lines[0] === '---') {
|
||||
lines.splice(1, 0, `${key}: ${value}`);
|
||||
} else {
|
||||
lines.unshift('---', `${key}: ${value}`, '---');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (skill.name) loadFiles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="saas-card-pad">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<span class="saas-badge-primary font-mono">{skill.name}</span>
|
||||
<span class="text-sm text-surface-700">v{skill.version}</span>
|
||||
{#if skill.disabledAt}
|
||||
<span class="saas-badge-error">已禁用</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="skill-version-{skill.id}" class="saas-label">版本号</label>
|
||||
<input id="skill-version-{skill.id}" class="saas-input" bind:value={version} placeholder="如 0.1.0" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="skill-desc-{skill.id}" class="saas-label">描述(同步到 SKILL.md frontmatter)</label>
|
||||
<div class="flex gap-2">
|
||||
<input id="skill-desc-{skill.id}" class="saas-input" bind:value={description} onchange={saveDescription} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2 text-xs text-surface-600">
|
||||
<span>digest: <code class="font-mono">{skill.contentDigest.slice(0, 12)}</code></span>
|
||||
<span>·</span>
|
||||
<span>更新于 {fmtDate(skill.updatedAt)}</span>
|
||||
{#if skill.boundRoleIds.length > 0}
|
||||
<span>·</span>
|
||||
<span>绑定角色: {skill.boundRoleIds.join(', ')}</span>
|
||||
{/if}
|
||||
{#if dirty}
|
||||
<span>·</span>
|
||||
<span class="font-medium text-warning-700">未保存</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="py-8 text-center text-sm text-surface-600">加载文件中…</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-[16rem_1fr]">
|
||||
<div class="border border-surface-300 bg-surface-100">
|
||||
<div class="flex items-center justify-between border-b border-surface-300 px-3 py-2">
|
||||
<span class="text-xs font-medium text-surface-700">文件</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary-700 hover:text-primary-900"
|
||||
onclick={() => (showNewFile = !showNewFile)}
|
||||
>
|
||||
{showNewFile ? '取消' : '+ 新增'}
|
||||
</button>
|
||||
</div>
|
||||
{#if showNewFile}
|
||||
<div class="border-b border-surface-300 px-3 py-2">
|
||||
<input
|
||||
class="saas-input text-xs"
|
||||
placeholder="如 reference.md"
|
||||
bind:value={newFilePath}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') addFile();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
{#each sortedFiles as f (f.path)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition hover:bg-surface-200
|
||||
{selectedPath === f.path ? 'bg-primary-100 text-primary-900' : 'text-surface-800'}"
|
||||
onclick={() => selectFile(f.path)}
|
||||
>
|
||||
<Icon name="file" class="h-3.5 w-3.5 shrink-0 opacity-60" />
|
||||
<span class="truncate font-mono text-xs">{f.path}</span>
|
||||
{#if f.path === 'SKILL.md'}
|
||||
<span class="ml-auto text-[10px] text-primary-700">manifest</span>
|
||||
{:else}
|
||||
<span
|
||||
class="ml-auto text-xs text-surface-500 hover:text-error-700"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteFile(f.path);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
deleteFile(f.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if files.length === 0}
|
||||
<div class="px-3 py-4 text-center text-xs text-surface-600">暂无文件</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if selectedFile}
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="font-mono text-xs text-surface-600">{selectedFile.path}</span>
|
||||
</div>
|
||||
<textarea
|
||||
class="h-80 w-full resize-y border border-surface-300 bg-white p-3 font-mono text-xs text-surface-900 focus:border-primary-500 focus:outline-none"
|
||||
bind:value={selectedFile.content}
|
||||
oninput={() => (dirty = true)}
|
||||
></textarea>
|
||||
{:else}
|
||||
<div class="flex h-80 items-center justify-center border border-surface-300 bg-surface-100 text-sm text-surface-600">
|
||||
选择一个文件或新建文件
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
{#if !skill.disabledAt}
|
||||
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
|
||||
禁用
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -10,6 +10,8 @@ export const TOOL_OPTIONS: ToolOption[] = [
|
||||
{ id: 'list_files', label: '列目录', group: '文件' },
|
||||
{ id: 'search_files', label: '搜索', group: '文件' },
|
||||
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
|
||||
{ id: 'web_fetch', label: 'WebFetch', group: '网络' },
|
||||
{ id: 'web_search', label: 'WebSearch', group: '网络' },
|
||||
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
|
||||
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
|
||||
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
|
||||
|
||||
@@ -32,9 +32,37 @@ export async function loadSession(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve org slug for org-scoped Feishu OAuth (`GET /auth/feishu/:orgSlug`).
|
||||
* Unscoped `/auth/feishu` is disabled unless allowLegacyFeishuOAuth is on.
|
||||
*/
|
||||
export function resolveLoginOrgSlug(): string | null {
|
||||
const path = window.location.pathname.split('/').filter(Boolean);
|
||||
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
|
||||
return decodeURIComponent(path[2]);
|
||||
}
|
||||
const q = new URLSearchParams(window.location.search).get('org');
|
||||
if (q && q.trim() !== '') return q.trim();
|
||||
|
||||
// Alpha Silo public host: <slug>.educraft.paradigm-edu.net (or educraft-dev)
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
|
||||
if (m?.[1]) return m[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function redirectToLogin(): void {
|
||||
const ret = encodeURIComponent(window.location.pathname + window.location.hash);
|
||||
window.location.href = `/auth/feishu?returnTo=${ret}`;
|
||||
if (window.location.pathname === '/admin/login') return;
|
||||
const ret = encodeURIComponent(
|
||||
window.location.pathname + window.location.search + window.location.hash,
|
||||
);
|
||||
const slug = resolveLoginOrgSlug();
|
||||
if (slug === null) {
|
||||
// Need an org slug for scoped OAuth — backend login helper can prompt.
|
||||
window.location.href = `/admin/login?returnTo=${ret}`;
|
||||
return;
|
||||
}
|
||||
window.location.href = `/auth/feishu/${encodeURIComponent(slug)}?returnTo=${ret}`;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
{ key: 'projects', label: '项目', icon: 'projects' as const },
|
||||
{ key: 'capacity', label: '容量', icon: 'overview' as const },
|
||||
{ key: 'provider', label: '供应方', icon: 'provider' as const },
|
||||
{ key: 'skills', label: '技能', icon: 'roles' as const },
|
||||
{ key: 'roles', label: '角色', icon: 'roles' as const },
|
||||
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
|
||||
{ key: 'capabilities', label: '能力', icon: 'provider' as const },
|
||||
];
|
||||
|
||||
function isAdmin(org: OrgMembership): boolean {
|
||||
@@ -310,11 +313,7 @@
|
||||
<div class="saas-shell">
|
||||
<div class="saas-main">
|
||||
<header class="saas-topbar">
|
||||
<a
|
||||
href={`/admin/org/${org.slug}/projects`}
|
||||
class="saas-btn-ghost px-2!"
|
||||
aria-label="返回项目列表"
|
||||
>
|
||||
<a href={`/admin/org/${org.slug}/projects`} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
|
||||
<Icon name="menu" class="h-5 w-5" />
|
||||
</a>
|
||||
<div class="min-w-0">
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type CapabilityConnection } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { Label } from 'bits-ui';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
const KNOWN_CAPABILITIES = [
|
||||
{ id: 'pdf_to_md_bundle', label: 'PDF → Markdown', description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)' },
|
||||
{ id: 'audio_video_to_text', label: '音视频 → 文本', description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)' },
|
||||
] as const;
|
||||
|
||||
let connections = $state<Map<string, CapabilityConnection>>(new Map());
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let editingCap = $state<string | null>(null);
|
||||
let accessKeyId = $state('');
|
||||
let accessKeySecret = $state('');
|
||||
let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com');
|
||||
let saving = $state(false);
|
||||
let disabling = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.capabilityConnections(slug);
|
||||
connections = new Map(res.connections.map((c) => [c.capabilityId, c]));
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(capId: string) {
|
||||
editingCap = capId;
|
||||
accessKeyId = '';
|
||||
accessKeySecret = '';
|
||||
endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingCap = null;
|
||||
}
|
||||
|
||||
async function save(capId: string) {
|
||||
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
|
||||
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
const result = await api.rotateCapabilityConnection(slug, capId, {
|
||||
accessKeyId: accessKeyId.trim(),
|
||||
accessKeySecret: accessKeySecret.trim(),
|
||||
endpoint: endpoint.trim(),
|
||||
});
|
||||
connections.set(capId, result);
|
||||
connections = new Map(connections);
|
||||
editingCap = null;
|
||||
toastSuccess('能力凭据已保存');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disable(capId: string) {
|
||||
if (!confirm('停用后该能力将不可用,确定停用?')) return;
|
||||
disabling = capId;
|
||||
try {
|
||||
const result = await api.disableCapabilityConnection(slug, capId);
|
||||
connections.set(capId, result);
|
||||
connections = new Map(connections);
|
||||
toastSuccess('已停用能力连接');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
disabling = null;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string): string {
|
||||
if (status === 'ACTIVE') return 'saas-badge-primary';
|
||||
if (status === 'DISABLED') return 'saas-badge-error';
|
||||
return 'saas-badge-muted';
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'ACTIVE') return '已启用';
|
||||
if (status === 'DISABLED') return '已停用';
|
||||
return '草稿';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="外部能力"
|
||||
description="管理文档/媒体转换服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
{#each KNOWN_CAPABILITIES as cap}
|
||||
{@const conn = connections.get(cap.id)}
|
||||
<div class="saas-card-pad">
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="saas-section-title">{cap.label}</h3>
|
||||
{#if conn}
|
||||
<span class={statusBadge(conn.status)}>{statusLabel(conn.status)}</span>
|
||||
{:else}
|
||||
<span class="saas-badge-muted">未配置</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="saas-muted mt-1 text-sm">{cap.description}</p>
|
||||
<p class="mt-0.5 font-mono text-xs text-surface-500">{cap.id}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if conn?.status === 'ACTIVE'}
|
||||
<button
|
||||
class="saas-btn-ghost text-sm"
|
||||
onclick={() => disable(cap.id)}
|
||||
disabled={disabling === cap.id}
|
||||
>
|
||||
{disabling === cap.id ? '停用中…' : '停用'}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="saas-btn-primary text-sm"
|
||||
onclick={() => startEdit(cap.id)}
|
||||
disabled={editingCap === cap.id}
|
||||
>
|
||||
{conn ? '轮换凭据' : '配置凭据'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if conn}
|
||||
<dl class="space-y-1.5 text-sm text-surface-700">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-surface-500">版本</dt>
|
||||
<dd class="font-mono">{conn.activeVersion ?? '—'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-surface-500">密钥 ID</dt>
|
||||
<dd class="font-mono text-xs">{conn.keyId ?? '—'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-surface-500">更新时间</dt>
|
||||
<dd>{fmtDate(conn.updatedAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
{#if editingCap === cap.id}
|
||||
<div class="mt-4 border-t border-surface-100 pt-4">
|
||||
<p class="saas-muted mb-3 text-sm">
|
||||
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
|
||||
</p>
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="ak-id-{cap.id}">AccessKey ID</Label.Root>
|
||||
<input id="ak-id-{cap.id}" class="saas-input font-mono text-sm" bind:value={accessKeyId} />
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root>
|
||||
<input id="ak-secret-{cap.id}" class="saas-input" type="password" bind:value={accessKeySecret} />
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="endpoint-{cap.id}">Endpoint</Label.Root>
|
||||
<input id="endpoint-{cap.id}" class="saas-input font-mono text-sm" bind:value={endpoint} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-end gap-3">
|
||||
<button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button>
|
||||
<button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -14,9 +14,7 @@
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
const org = $derived(
|
||||
($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null,
|
||||
);
|
||||
const org = $derived(($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null);
|
||||
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
|
||||
|
||||
let data = $state<ExplorerData | null>(null);
|
||||
@@ -32,6 +30,18 @@
|
||||
let projectName = $state('');
|
||||
let projectFolder = $state('');
|
||||
|
||||
function openFolderModal(parentId: string) {
|
||||
folderName = '';
|
||||
folderParent = parentId;
|
||||
showFolderModal = true;
|
||||
}
|
||||
|
||||
function openProjectModal(folderId: string) {
|
||||
projectName = '';
|
||||
projectFolder = folderId;
|
||||
showProjectModal = true;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -113,8 +123,8 @@
|
||||
{:else if isAdmin && data}
|
||||
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
|
||||
{#snippet actions()}
|
||||
<button class="saas-btn-secondary" onclick={() => (showFolderModal = true)}>新建文件夹</button>
|
||||
<button class="saas-btn-primary" onclick={() => (showProjectModal = true)}>新建项目</button>
|
||||
<button class="saas-btn-secondary" onclick={() => openFolderModal('')}>新建文件夹</button>
|
||||
<button class="saas-btn-primary" onclick={() => openProjectModal('')}>新建项目</button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
@@ -122,7 +132,14 @@
|
||||
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
|
||||
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
|
||||
{:else}
|
||||
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} />
|
||||
<FolderTree
|
||||
folders={data.folders}
|
||||
projects={data.projects}
|
||||
parentId={null}
|
||||
{slug}
|
||||
onCreateFolder={openFolderModal}
|
||||
onCreateProject={openProjectModal}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SelectField from '$lib/components/SelectField.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
@@ -158,7 +159,8 @@
|
||||
href={`/admin/org/${slug}/projects`}
|
||||
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-600"
|
||||
>
|
||||
← 返回项目列表
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
返回项目列表
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import RoleCard from '$lib/components/RoleCard.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let roles = $state<AgentRoleRow[]>([]);
|
||||
let models = $state<AgentModelRow[]>([]);
|
||||
let skills = $state<AgentSkillRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let newRoleId = $state('');
|
||||
let newLabel = $state('');
|
||||
let adding = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [r, s] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug)]);
|
||||
roles = r.roles;
|
||||
skills = s.skills;
|
||||
// Model fetch hits the provider API and may fail or be slow; load it
|
||||
// independently so roles remain editable even without a model list.
|
||||
models = [];
|
||||
api.agentModels(slug)
|
||||
.then((m) => { models = m.models; })
|
||||
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
const roleId = newRoleId.trim();
|
||||
const label = newLabel.trim();
|
||||
if (roleId === '' || label === '') {
|
||||
toastError('角色 ID 与显示名均为必填');
|
||||
return;
|
||||
}
|
||||
if (roles.some((r) => r.roleId === roleId)) {
|
||||
toastError(`角色 ID 已存在:${roleId}`);
|
||||
return;
|
||||
}
|
||||
adding = true;
|
||||
try {
|
||||
const created = await api.upsertAgentRole(slug, roleId, { label });
|
||||
roles = [...roles, created];
|
||||
newRoleId = '';
|
||||
newLabel = '';
|
||||
toastSuccess('角色已创建');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleUpdated(updated: AgentRoleRow) {
|
||||
roles = roles.map((x) => (x.roleId === updated.roleId ? { ...updated, skillNames: x.skillNames } : x));
|
||||
if (updated.isDefault) {
|
||||
roles = roles.map((x) => (x.roleId === updated.roleId ? x : { ...x, isDefault: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleSkillsChanged(roleId: string, skillNames: string[]) {
|
||||
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="角色"
|
||||
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。角色 ID 即飞书斜杠命令(如 /draft)。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="saas-card-pad mb-6">
|
||||
<h2 class="saas-section-title mb-4">新建角色</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
|
||||
<input
|
||||
class="saas-input font-mono text-sm"
|
||||
placeholder="角色 ID(如 draft)"
|
||||
bind:value={newRoleId}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') add();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
class="saas-input"
|
||||
placeholder="显示名(如 草稿)"
|
||||
bind:value={newLabel}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') add();
|
||||
}}
|
||||
/>
|
||||
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头。</p>
|
||||
</div>
|
||||
|
||||
{#if roles.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each roles as r (r.roleId)}
|
||||
<RoleCard {r} {models} {skills} {slug} onupdated={onRoleUpdated} onskillschanged={onRoleSkillsChanged} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SkillEditor from '$lib/components/SkillEditor.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let skills = $state<AgentSkillRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let showNewSkill = $state(false);
|
||||
let newSkillName = $state('');
|
||||
let newSkillVersion = $state('0.1.0');
|
||||
let newSkillDescription = $state('');
|
||||
let creating = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.agentSkills(slug);
|
||||
skills = res.skills;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSkill() {
|
||||
const name = newSkillName.trim();
|
||||
if (name === '') {
|
||||
toastError('技能名称不能为空');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
||||
toastError('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
|
||||
return;
|
||||
}
|
||||
const version = newSkillVersion.trim();
|
||||
if (version === '') {
|
||||
toastError('版本号不能为空');
|
||||
return;
|
||||
}
|
||||
creating = true;
|
||||
try {
|
||||
const manifest = buildManifest(name, newSkillDescription.trim());
|
||||
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
|
||||
const result = await api.installAgentSkill(slug, name, { version, files });
|
||||
toastSuccess(`技能 ${result.name} 已创建`);
|
||||
newSkillName = '';
|
||||
newSkillDescription = '';
|
||||
showNewSkill = false;
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildManifest(name: string, description: string): string {
|
||||
const desc = description === '' ? name : description;
|
||||
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
|
||||
}
|
||||
|
||||
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
|
||||
load();
|
||||
}
|
||||
|
||||
function onDisabled(_name: string) {
|
||||
load();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="技能"
|
||||
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="saas-card-pad mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="saas-section-title">新建技能</h2>
|
||||
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
|
||||
{showNewSkill ? '取消' : '+ 新建'}
|
||||
</button>
|
||||
</div>
|
||||
{#if showNewSkill}
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
|
||||
<input
|
||||
class="saas-input font-mono text-sm"
|
||||
placeholder="技能名(如 typst-help)"
|
||||
bind:value={newSkillName}
|
||||
/>
|
||||
<input
|
||||
class="saas-input text-sm"
|
||||
placeholder="版本号"
|
||||
bind:value={newSkillVersion}
|
||||
/>
|
||||
<input
|
||||
class="saas-input text-sm"
|
||||
placeholder="描述"
|
||||
bind:value={newSkillDescription}
|
||||
/>
|
||||
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
|
||||
{creating ? '创建中…' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-surface-600">
|
||||
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板。
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if skills.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each skills as skill (skill.id)}
|
||||
<SkillEditor {slug} {skill} oninstalled={onInstalled} ondisabled={onDisabled} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -8,6 +8,10 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8788',
|
||||
'/auth': 'http://127.0.0.1:8788',
|
||||
// Backend owns /admin/login (registered before the SPA fallback in
|
||||
// src/admin/static.ts). Proxy it in dev so the SPA doesn't re-render
|
||||
// its layout on that path and 401-redirect into a returnTo loop.
|
||||
'/admin/login': 'http://127.0.0.1:8788',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ flock /var/lock/cph-hub-release-publish bash -c '
|
||||
exit 0
|
||||
fi
|
||||
cd "$HUB_DIR"
|
||||
npm ci
|
||||
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
|
||||
npm ci --prefix admin-web
|
||||
npm run audit:production
|
||||
npm run build
|
||||
|
||||
@@ -63,7 +63,7 @@ if [ "$release_ready" = false ]; then
|
||||
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
|
||||
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
|
||||
"cd '$HUB_DIR' && npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||
fi
|
||||
|
||||
# 3. Ensure the service is installed (idempotent), then restart.
|
||||
|
||||
Generated
+351
-3
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.31",
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2.4.5",
|
||||
"@alicloud/docmind-api20220711": "^1.4.15",
|
||||
"@alicloud/tea-util": "^1.4.11",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@larksuiteoapi/node-sdk": "^1.70.0",
|
||||
@@ -74,6 +77,175 @@
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/credentials": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/credentials/-/credentials-2.4.5.tgz",
|
||||
"integrity": "sha512-od1ufCxOO7cP2R4EVFliOB0kGo9lUXCibyj/mzmI6yLhxeqhqsegTzVsx5p2NJJsceKJnYcmye7FWKyLJAFBkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alicloud/tea-typescript": "^1.8.0",
|
||||
"httpx": "^2.3.3",
|
||||
"ini": "^1.3.5",
|
||||
"kitx": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/darabonba-array": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-array/-/darabonba-array-0.1.2.tgz",
|
||||
"integrity": "sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@alicloud/tea-typescript": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/darabonba-encode-util": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.2.tgz",
|
||||
"integrity": "sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"moment": "^2.29.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/darabonba-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-map/-/darabonba-map-0.0.1.tgz",
|
||||
"integrity": "sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@alicloud/tea-typescript": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/darabonba-signature-util": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-signature-util/-/darabonba-signature-util-0.0.4.tgz",
|
||||
"integrity": "sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@alicloud/darabonba-encode-util": "^0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/darabonba-signature-util/node_modules/@alicloud/darabonba-encode-util": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.1.tgz",
|
||||
"integrity": "sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@alicloud/tea-typescript": "^1.7.1",
|
||||
"moment": "^2.29.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/darabonba-string": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-string/-/darabonba-string-1.0.3.tgz",
|
||||
"integrity": "sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@alicloud/tea-typescript": "^1.5.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/docmind-api20220711": {
|
||||
"version": "1.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/docmind-api20220711/-/docmind-api20220711-1.4.15.tgz",
|
||||
"integrity": "sha512-QmjSDPV52d2B5bd/Dk3Zeofu+cJFPKYS+MphIHqlulfYsOynLgOaDqTRMGTK/RhcmVCwG14CyZ/15GBF00GRFw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2.4.2",
|
||||
"@alicloud/openapi-core": "^1.0.0",
|
||||
"@darabonba/typescript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/endpoint-util": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz",
|
||||
"integrity": "sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@alicloud/tea-typescript": "^1.5.1",
|
||||
"kitx": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/gateway-pop": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/gateway-pop/-/gateway-pop-0.0.6.tgz",
|
||||
"integrity": "sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2",
|
||||
"@alicloud/darabonba-array": "^0.1.0",
|
||||
"@alicloud/darabonba-encode-util": "^0.0.2",
|
||||
"@alicloud/darabonba-map": "^0.0.1",
|
||||
"@alicloud/darabonba-signature-util": "^0.0.4",
|
||||
"@alicloud/darabonba-string": "^1.0.2",
|
||||
"@alicloud/endpoint-util": "^0.0.1",
|
||||
"@alicloud/gateway-spi": "^0.0.8",
|
||||
"@alicloud/openapi-util": "^0.3.2",
|
||||
"@alicloud/tea-typescript": "^1.7.1",
|
||||
"@alicloud/tea-util": "^1.4.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/gateway-spi": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz",
|
||||
"integrity": "sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2",
|
||||
"@alicloud/tea-typescript": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/openapi-core": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/openapi-core/-/openapi-core-1.0.8.tgz",
|
||||
"integrity": "sha512-xs8LdgMDcEUqv13kZ4nl+Vd+Fc1mixTF0g1lm5QSrNWDaLUUD5vqpuMtvUZnKNnq9PITfUQ+8KunAvNQJpFo8g==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2.4.2",
|
||||
"@alicloud/gateway-pop": "0.0.6",
|
||||
"@alicloud/gateway-spi": "^0.0.8",
|
||||
"@darabonba/typescript": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/openapi-util": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/openapi-util/-/openapi-util-0.3.3.tgz",
|
||||
"integrity": "sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@alicloud/tea-typescript": "^1.7.1",
|
||||
"@alicloud/tea-util": "^1.3.0",
|
||||
"kitx": "^2.1.0",
|
||||
"sm3": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/tea-typescript": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz",
|
||||
"integrity": "sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/node": "^12.0.2",
|
||||
"httpx": "^2.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@alicloud/tea-typescript/node_modules/@types/node": {
|
||||
"version": "12.20.55",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
|
||||
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@alicloud/tea-util": {
|
||||
"version": "1.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.11.tgz",
|
||||
"integrity": "sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@alicloud/tea-typescript": "^1.5.1",
|
||||
"@darabonba/typescript": "^1.0.0",
|
||||
"kitx": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz",
|
||||
@@ -234,6 +406,24 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@darabonba/typescript": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@darabonba/typescript/-/typescript-1.0.5.tgz",
|
||||
"integrity": "sha512-pfxHFVM8I3h8K2o8skpDQLMmR5iAeQ2eNpP1HrKDEm/9ZPF8aKwDKPwwEszT1NnIo0Y3++E7x1YsVkVpGjA/xw==",
|
||||
"license": "Apache License 2.0",
|
||||
"dependencies": {
|
||||
"@alicloud/tea-typescript": "^1.5.1",
|
||||
"http-proxy-agent": "^5.0.0",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"httpx": "^2.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"socks-proxy-agent": "^6.2.1",
|
||||
"ws": "^8.18.0",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
@@ -1396,6 +1586,15 @@
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
|
||||
"integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -2830,6 +3029,20 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tootallnate/once": "2",
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
@@ -2843,6 +3056,25 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/httpx": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz",
|
||||
"integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^20",
|
||||
"debug": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/httpx/node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
@@ -2867,12 +3099,17 @@
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
@@ -2972,6 +3209,15 @@
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/kitx": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/kitx/-/kitx-2.2.0.tgz",
|
||||
"integrity": "sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^22.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
@@ -3270,6 +3516,12 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.identity": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz",
|
||||
@@ -3357,6 +3609,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/moment-timezone": {
|
||||
"version": "0.5.48",
|
||||
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
|
||||
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"moment": "^2.29.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -3976,6 +4249,15 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
@@ -4193,6 +4475,50 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sm3": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sm3/-/sm3-1.0.3.tgz",
|
||||
"integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz",
|
||||
"integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^6.0.2",
|
||||
"debug": "^4.3.3",
|
||||
"socks": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
@@ -4700,6 +5026,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
|
||||
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
|
||||
+4
-1
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.33",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2.4.5",
|
||||
"@alicloud/docmind-api20220711": "^1.4.15",
|
||||
"@alicloud/tea-util": "^1.4.11",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@larksuiteoapi/node-sdk": "^1.70.0",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-- ADR-0026: append-only UsageFact ledger. AgentRun.costUsd/inputTokens/
|
||||
-- outputTokens become a derived rollup cache; the truth is in UsageFact.
|
||||
|
||||
CREATE TABLE "UsageFact" (
|
||||
"id" TEXT NOT NULL,
|
||||
"runId" TEXT NOT NULL,
|
||||
"occurredAt" TIMESTAMP(3) NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"model" TEXT,
|
||||
"inputTokens" INTEGER,
|
||||
"outputTokens" INTEGER,
|
||||
"quantity" DECIMAL(18, 6),
|
||||
"unit" TEXT,
|
||||
"costUsd" DECIMAL(18, 8),
|
||||
"costSource" TEXT NOT NULL,
|
||||
"capabilityId" TEXT,
|
||||
"correlationId" TEXT,
|
||||
"metadata" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "UsageFact_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "UsageFact_runId_occurredAt_idx" ON "UsageFact"("runId", "occurredAt");
|
||||
CREATE INDEX "UsageFact_runId_kind_idx" ON "UsageFact"("runId", "kind");
|
||||
CREATE INDEX "UsageFact_provider_model_occurredAt_idx"
|
||||
ON "UsageFact"("provider", "model", "occurredAt");
|
||||
CREATE INDEX "UsageFact_capabilityId_occurredAt_idx"
|
||||
ON "UsageFact"("capabilityId", "occurredAt");
|
||||
|
||||
ALTER TABLE "UsageFact"
|
||||
ADD CONSTRAINT "UsageFact_runId_fkey"
|
||||
FOREIGN KEY ("runId") REFERENCES "AgentRun"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill: one synthetic model_completion fact per AgentRun that has any
|
||||
-- recorded usage (cost or tokens). correlationId = run id marks these as
|
||||
-- backfill artefacts (real facts use an external correlation id or null).
|
||||
-- Runs with no recorded usage stay fact-less and remain runsWithoutCost,
|
||||
-- matching ADR-0022's "missing cost ≠ zero" rule and the pre-existing
|
||||
-- migration 20260709143000_agent_run_cost_tracking's "no backfill for the
|
||||
-- truly unrecorded" stance.
|
||||
INSERT INTO "UsageFact" (
|
||||
"id", "runId", "occurredAt", "kind", "provider", "model",
|
||||
"inputTokens", "outputTokens", "costUsd", "costSource",
|
||||
"correlationId", "metadata"
|
||||
)
|
||||
SELECT
|
||||
'usagefact_backfill_' || "AgentRun"."id",
|
||||
"AgentRun"."id",
|
||||
COALESCE("AgentRun"."finishedAt", "AgentRun"."startedAt", CURRENT_TIMESTAMP),
|
||||
'model_completion',
|
||||
"AgentRun"."provider",
|
||||
"AgentRun"."model",
|
||||
"AgentRun"."inputTokens",
|
||||
"AgentRun"."outputTokens",
|
||||
"AgentRun"."costUsd",
|
||||
COALESCE("AgentRun"."costSource", 'unknown'),
|
||||
"AgentRun"."id",
|
||||
'{}'::jsonb
|
||||
FROM "AgentRun"
|
||||
WHERE "AgentRun"."costUsd" IS NOT NULL
|
||||
OR "AgentRun"."inputTokens" IS NOT NULL
|
||||
OR "AgentRun"."outputTokens" IS NOT NULL;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- ADR-0027: org-scoped capability connections for external document/media
|
||||
-- transforms (PDF→MD, audio/video→text, …). Mirrors the Feishu Application
|
||||
-- Connection shape: reuses the ADR-0024 envelope machinery with its own
|
||||
-- payload schema and readiness probe, distinct from the model-provider
|
||||
-- connection.
|
||||
|
||||
CREATE TABLE "OrganizationCapabilityConnection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"capabilityId" TEXT NOT NULL,
|
||||
"status" "OrganizationConnectionStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"activeSecretVersionId" TEXT,
|
||||
"activatedAt" TIMESTAMP(3),
|
||||
"disabledAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "OrganizationCapabilityConnection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "OrganizationCapabilityConnection_organizationId_capabilityId_key"
|
||||
ON "OrganizationCapabilityConnection"("organizationId", "capabilityId");
|
||||
CREATE UNIQUE INDEX "OrganizationCapabilityConnection_activeSecretVersionId_key"
|
||||
ON "OrganizationCapabilityConnection"("activeSecretVersionId");
|
||||
CREATE INDEX "OrganizationCapabilityConnection_organizationId_status_idx"
|
||||
ON "OrganizationCapabilityConnection"("organizationId", "status");
|
||||
CREATE INDEX "OrganizationCapabilityConnection_capabilityId_status_idx"
|
||||
ON "OrganizationCapabilityConnection"("capabilityId", "status");
|
||||
|
||||
CREATE TABLE "CapabilityCredentialVersion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"envelopeVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
"keyId" TEXT NOT NULL,
|
||||
"envelope" JSONB NOT NULL,
|
||||
"createdByUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"retiredAt" TIMESTAMP(3),
|
||||
CONSTRAINT "CapabilityCredentialVersion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "CapabilityCredentialVersion_connectionId_version_key"
|
||||
ON "CapabilityCredentialVersion"("connectionId", "version");
|
||||
CREATE INDEX "CapabilityCredentialVersion_connectionId_retiredAt_idx"
|
||||
ON "CapabilityCredentialVersion"("connectionId", "retiredAt");
|
||||
CREATE INDEX "CapabilityCredentialVersion_keyId_idx"
|
||||
ON "CapabilityCredentialVersion"("keyId");
|
||||
CREATE INDEX "CapabilityCredentialVersion_createdByUserId_idx"
|
||||
ON "CapabilityCredentialVersion"("createdByUserId");
|
||||
|
||||
ALTER TABLE "OrganizationCapabilityConnection"
|
||||
ADD CONSTRAINT "OrganizationCapabilityConnection_organizationId_fkey"
|
||||
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "OrganizationCapabilityConnection"
|
||||
ADD CONSTRAINT "OrganizationCapabilityConnection_activeSecretVersionId_fkey"
|
||||
FOREIGN KEY ("activeSecretVersionId") REFERENCES "CapabilityCredentialVersion"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "CapabilityCredentialVersion"
|
||||
ADD CONSTRAINT "CapabilityCredentialVersion_connectionId_fkey"
|
||||
FOREIGN KEY ("connectionId") REFERENCES "OrganizationCapabilityConnection"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "CapabilityCredentialVersion"
|
||||
ADD CONSTRAINT "CapabilityCredentialVersion_createdByUserId_fkey"
|
||||
FOREIGN KEY ("createdByUserId") REFERENCES "User"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+108
-2
@@ -44,6 +44,7 @@ model Organization {
|
||||
externalDirectoryConnections ExternalDirectoryConnection[]
|
||||
providerConnections OrganizationProviderConnection[]
|
||||
feishuApplicationConnection OrganizationFeishuApplicationConnection?
|
||||
capabilityConnections OrganizationCapabilityConnection[]
|
||||
agentSkills OrganizationAgentSkill[]
|
||||
agentRoles OrganizationAgentRole[]
|
||||
projectGroupBindings ProjectGroupBinding[]
|
||||
@@ -198,6 +199,7 @@ model User {
|
||||
auditEntries AuditEntry[] @relation("auditActor")
|
||||
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
|
||||
feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator")
|
||||
capabilityCredentialVersions CapabilityCredentialVersion[] @relation("capabilityCredentialVersionCreator")
|
||||
feishuIdentities FeishuUserIdentity[]
|
||||
}
|
||||
|
||||
@@ -596,9 +598,13 @@ model AgentRun {
|
||||
summary String?
|
||||
inputTokens Int?
|
||||
outputTokens Int?
|
||||
/// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact.
|
||||
/// ADR-0026: derived rollup cache of UsageFact rows for this run. The truth
|
||||
/// is in UsageFact; this column is convenience for existing readers. Null
|
||||
/// means this run has no trusted cost fact (ADR-0022: missing cost ≠ zero).
|
||||
costUsd Decimal? @db.Decimal(18, 8)
|
||||
/// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback.
|
||||
/// ADR-0026: semantic source of the rolled-up costUsd (provider_reported |
|
||||
/// pricebook_derived | unknown). Mirrors the dominant CostSource of the
|
||||
/// run's facts; not a pricing-estimate fallback.
|
||||
costSource String?
|
||||
metadata Json
|
||||
error String?
|
||||
@@ -612,6 +618,7 @@ model AgentRun {
|
||||
projectLock ProjectAgentLock?
|
||||
messages AgentMessage[] @relation("runMessages")
|
||||
fileChanges AgentFileChange[] @relation("runFileChanges")
|
||||
usageFacts UsageFact[] @relation("runUsageFacts")
|
||||
|
||||
@@index([projectId, status])
|
||||
@@index([projectId, finishedAt])
|
||||
@@ -817,3 +824,102 @@ model AgentFileChange {
|
||||
@@index([projectId])
|
||||
@@index([path])
|
||||
}
|
||||
|
||||
// --- Usage fact ledger (ADR-0026) ----------------------------------------
|
||||
|
||||
/// ADR-0026: one billable consumption event inside an AgentRun. Append-only;
|
||||
/// the run's AgentRun.costUsd/inputTokens/outputTokens are a derived rollup
|
||||
/// of these rows, not the truth. kind=external_capability carries capabilityId
|
||||
/// for media transforms (PDF→MD, audio/video→text, …). costUsd=null means
|
||||
/// unknown, not zero (ADR-0022). The kind set is OPEN: new kinds must be
|
||||
/// surfaced, not silently folded in.
|
||||
model UsageFact {
|
||||
id String @id @default(cuid())
|
||||
runId String
|
||||
/// When the consumption happened. Pricebook derivation uses this, not
|
||||
/// AgentRun.finishedAt, because an external capability may finish before
|
||||
/// the run ends.
|
||||
occurredAt DateTime
|
||||
/// model_completion | external_capability | tool_proxy. OPEN set.
|
||||
kind String
|
||||
/// e.g. openrouter, mineru, openai_whisper.
|
||||
provider String
|
||||
model String?
|
||||
inputTokens Int?
|
||||
outputTokens Int?
|
||||
/// Non-token meter (pages, audio_seconds, invocations). Coexists with tokens.
|
||||
quantity Decimal? @db.Decimal(18, 6)
|
||||
unit String?
|
||||
/// USD. Null = unknown, NOT zero (ADR-0022). When null, costSource=unknown.
|
||||
costUsd Decimal? @db.Decimal(18, 8)
|
||||
/// provider_reported | pricebook_derived | unknown. Required even when
|
||||
/// costUsd is null, so a reader can distinguish "reported zero" from
|
||||
/// "no report at all".
|
||||
costSource String
|
||||
/// For kind=external_capability, the registered capability id
|
||||
/// (e.g. pdf_to_md_bundle, audio_video_to_text). Null for model_completion.
|
||||
capabilityId String?
|
||||
/// External request id for reconciliation / idempotency. Not an aggregation key.
|
||||
correlationId String?
|
||||
metadata Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
run AgentRun @relation("runUsageFacts", fields: [runId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([runId, occurredAt])
|
||||
@@index([runId, kind])
|
||||
@@index([provider, model, occurredAt])
|
||||
@@index([capabilityId, occurredAt])
|
||||
}
|
||||
|
||||
// --- External capability connections (ADR-0027) -------------------------
|
||||
|
||||
/// ADR-0027: org-scoped credential connection for an external capability
|
||||
/// (PDF→MD, audio/video→text, …). Structurally mirrors the Feishu Application
|
||||
/// Connection: reuses the ADR-0024 envelope (KEK→DEK→AES-256-GCM, AAD-bound)
|
||||
/// but has its own payload schema and readiness probe, distinct from the
|
||||
/// model-provider connection. Unique by (organizationId, capabilityId).
|
||||
model OrganizationCapabilityConnection {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
capabilityId String
|
||||
status OrganizationConnectionStatus @default(DRAFT)
|
||||
activeSecretVersionId String? @unique
|
||||
activatedAt DateTime?
|
||||
disabledAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
secretVersions CapabilityCredentialVersion[] @relation("capabilityCredentialVersions")
|
||||
activeSecretVersion CapabilityCredentialVersion? @relation("activeCapabilityCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([organizationId, capabilityId])
|
||||
@@index([organizationId, status])
|
||||
@@index([capabilityId, status])
|
||||
}
|
||||
|
||||
/// ADR-0024/0027: one immutable authenticated envelope per capability secret
|
||||
/// version. Same encryption machinery as Provider/Feishu credential versions;
|
||||
/// the payload inside is CapabilitySecretPayloadV1 (baseUrl, apiToken,
|
||||
/// optional projectId).
|
||||
model CapabilityCredentialVersion {
|
||||
id String @id @default(cuid())
|
||||
connectionId String
|
||||
version Int
|
||||
envelopeVersion Int @default(1)
|
||||
keyId String
|
||||
envelope Json
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
retiredAt DateTime?
|
||||
|
||||
connection OrganizationCapabilityConnection @relation("capabilityCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
|
||||
activeFor OrganizationCapabilityConnection? @relation("activeCapabilityCredentialVersion")
|
||||
createdBy User? @relation("capabilityCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([connectionId, version])
|
||||
@@index([connectionId, retiredAt])
|
||||
@@index([keyId])
|
||||
@@index([createdByUserId])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { config } from "dotenv";
|
||||
config();
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bootstrapAlphaSilo } from "../src/deployment/bootstrap-silo.js";
|
||||
import { loadLocalSecretKeyring, LocalSecretEnvelope } from "../src/security/secretEnvelope.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const keyringFile = process.env["HUB_SECRET_KEYRING_FILE"];
|
||||
if (!keyringFile) throw new Error("HUB_SECRET_KEYRING_FILE is required");
|
||||
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring());
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const result = await bootstrapAlphaSilo(
|
||||
prisma,
|
||||
secrets,
|
||||
{
|
||||
organization: {
|
||||
id: "org_default",
|
||||
slug: "default",
|
||||
name: "Default Organization",
|
||||
},
|
||||
owner: {
|
||||
openId: process.env["FEISHU_BOT_OPEN_ID"] ?? "dev-owner",
|
||||
displayName: "Dev Owner",
|
||||
},
|
||||
feishu: {
|
||||
appId: process.env["FEISHU_APP_ID"] ?? "dev-app",
|
||||
appSecret: process.env["FEISHU_APP_SECRET"] ?? "dev-secret",
|
||||
botOpenId: process.env["FEISHU_BOT_OPEN_ID"] ?? "dev-bot",
|
||||
},
|
||||
provider: {
|
||||
providerId: "openrouter",
|
||||
baseUrl: process.env["ANTHROPIC_BASE_URL"] ?? "https://openrouter.ai/api",
|
||||
authToken: process.env["ANTHROPIC_AUTH_TOKEN"] ?? "dev-token",
|
||||
},
|
||||
},
|
||||
{
|
||||
feishu: async () => undefined,
|
||||
provider: async () => undefined,
|
||||
},
|
||||
);
|
||||
console.log("bootstrap ok:", JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error("[dev-bootstrap] failed:", error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Smoke test: real Aliyun docmind API call using createReadStream.
|
||||
* Run: node --experimental-strip-types scripts/smoke-docmind.ts <pdf-path>
|
||||
*/
|
||||
import DocmindClient from "@alicloud/docmind-api20220711";
|
||||
import { RuntimeOptions } from "@alicloud/tea-util";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
|
||||
const accessKeyId = process.env["ALIBABA_CLOUD_ACCESS_KEY_ID"];
|
||||
const accessKeySecret = process.env["ALIBABA_CLOUD_ACCESS_KEY_SECRET"];
|
||||
if (accessKeyId === undefined || accessKeySecret === undefined) {
|
||||
console.error("Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET env vars.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pdfPath = process.argv[2] ?? "../examples/trial/prime_sieve.pdf";
|
||||
console.log(`Parsing: ${pdfPath}`);
|
||||
|
||||
const client = new DocmindClient.default({
|
||||
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
type: "access_key",
|
||||
regionId: "cn-hangzhou",
|
||||
} as never);
|
||||
|
||||
const fileStream = createReadStream(pdfPath);
|
||||
const runtime = new RuntimeOptions({});
|
||||
|
||||
console.log("Submitting job...");
|
||||
const submitResp = await client.submitDocParserJobAdvance(
|
||||
new DocmindClient.SubmitDocParserJobAdvanceRequest({
|
||||
fileUrlObject: fileStream,
|
||||
fileName: basename(pdfPath),
|
||||
outputFormat: ["markdown"],
|
||||
formulaEnhancement: true,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
const jobId = submitResp.body?.data?.id;
|
||||
console.log("Job ID:", jobId);
|
||||
|
||||
if (jobId === undefined) {
|
||||
console.error("No job id:", JSON.stringify(submitResp.body));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Polling...");
|
||||
const deadline = Date.now() + 5 * 60_000;
|
||||
let status = "";
|
||||
let markdownUrl = "";
|
||||
let pageCount = 0;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 10_000));
|
||||
const resp = await client.queryDocParserStatus(
|
||||
new DocmindClient.QueryDocParserStatusRequest({ id: jobId }),
|
||||
);
|
||||
const data = (resp.body as { data?: { status?: string; pageCountEstimate?: number; outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }> } }).data;
|
||||
status = data?.status ?? "";
|
||||
console.log(` status: ${status}`);
|
||||
if (status === "success") {
|
||||
const md = data?.outputFormatResult?.find((r) => r.outputType === "markdown");
|
||||
markdownUrl = md?.outputFileUrl ?? "";
|
||||
pageCount = data?.pageCountEstimate ?? 0;
|
||||
break;
|
||||
}
|
||||
if (status === "fail") {
|
||||
console.error("Job failed!");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== "success" || markdownUrl === "") {
|
||||
console.error("Failed or timed out:", status);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Downloading markdown from OSS...");
|
||||
const mdResp = await fetch(markdownUrl);
|
||||
const markdown = await mdResp.text();
|
||||
|
||||
console.log("--- Result ---");
|
||||
console.log("Pages:", pageCount);
|
||||
console.log("Cost (USD, est):", ((pageCount > 0 ? pageCount : 1) * 0.0056).toFixed(4));
|
||||
console.log("Job ID:", jobId);
|
||||
console.log("Markdown length:", markdown.length, "chars");
|
||||
console.log("--- Markdown (first 1000 chars) ---");
|
||||
console.log(markdown.slice(0, 1000));
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: pdf-to-md
|
||||
description: >
|
||||
Convert PDF documents to Markdown bundles using the convert_pdf_to_md tool.
|
||||
Handles PDFs from Feishu messages, local workspace files, and produces
|
||||
high-quality Markdown with LaTeX formulas and extracted images.
|
||||
---
|
||||
|
||||
# PDF to Markdown Conversion
|
||||
|
||||
## When to use
|
||||
|
||||
Use this skill when the user asks to convert a PDF to Markdown, extract text
|
||||
from a PDF, or turn a PDF document into an editable format.
|
||||
|
||||
## How it works
|
||||
|
||||
The `convert_pdf_to_md` tool (provided by the `cph_hub` MCP server) calls
|
||||
Alibaba Cloud Document Mind to parse the PDF. It:
|
||||
|
||||
- Extracts text in reading order (handles multi-column, scanned, and
|
||||
multi-language documents)
|
||||
- Converts mathematical formulas to **LaTeX** (`$...$` inline, `$$...$$` block)
|
||||
- Extracts tables as Markdown tables
|
||||
- Downloads embedded images into the output directory
|
||||
- Writes a single `document.md` file plus image files
|
||||
|
||||
## Workflow
|
||||
|
||||
### PDF from a Feishu message
|
||||
|
||||
1. Use `feishu_read_context` to find the `file_key` of the PDF attachment.
|
||||
2. Use `feishu_download_resource` to download it into the workspace.
|
||||
3. Use `convert_pdf_to_md` with the downloaded file path and an output directory.
|
||||
|
||||
### PDF already in the workspace
|
||||
|
||||
1. Use `convert_pdf_to_md` directly with the file path and an output directory.
|
||||
|
||||
## Important rules
|
||||
|
||||
- **Always** use `convert_pdf_to_md` for PDF→Markdown. Do NOT attempt to parse
|
||||
PDFs yourself with Read, Bash, Python, or any other method. The tool provides
|
||||
accurate formula, table, and image extraction that manual methods cannot
|
||||
match.
|
||||
- If `convert_pdf_to_md` fails because no capability connection is configured,
|
||||
tell the user to ask their organization admin to configure the Aliyun
|
||||
docmind credential in the admin web UI (组织后台 → 能力).
|
||||
- The output directory will be created if it does not exist.
|
||||
- After conversion, use `send_file` to send the generated markdown back to the
|
||||
user if they requested it.
|
||||
|
||||
## Output
|
||||
|
||||
The tool returns a list of generated files:
|
||||
- `document.md` — the main markdown file
|
||||
- `*.jpg` / `*.png` — extracted images, referenced from the markdown
|
||||
|
||||
## Cost
|
||||
|
||||
The conversion is billed per page (0.04 CNY/page ≈ $0.0056/page for the
|
||||
enhanced formula mode). The cost is automatically recorded on the run's
|
||||
usage ledger.
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
|
||||
*
|
||||
* Surface for browsing and editing Organization-scoped Agent roles, the skills
|
||||
* bound to them, and the model picker. The model list is **derived** from the
|
||||
* org's ACTIVE provider connection via OpenRouter's /v1/models API — there is
|
||||
* no separate model table to maintain. When no ACTIVE provider exists, the
|
||||
* route falls back to the env-default model registry so the admin surface
|
||||
* remains usable.
|
||||
*
|
||||
* Skill management (create, read, edit, disable) is performed in-band through
|
||||
* the deep module `OrganizationAgentConfiguration`, which writes to the same
|
||||
* content-addressed persistent store used by the host-console CLI. All skill
|
||||
* content flows through `importSkillFromFiles` → `inspectSkillDirectory` →
|
||||
* `commitSkillContent`, so the web path and CLI path share one ingestion
|
||||
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file
|
||||
* / 16-byte limits, symlink rejection).
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { OrganizationAgentConfiguration } from "../../agent/configuration.js";
|
||||
import { ProviderModelCatalog } from "../../connections/providerModelCatalog.js";
|
||||
import { createDefaultModelRegistry } from "../../settings/runtime.js";
|
||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
export interface AgentConfigRouteConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
readonly skillStoreRoot: string;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
}
|
||||
|
||||
export async function registerAgentConfigRoutes(
|
||||
app: FastifyInstance,
|
||||
config: AgentConfigRouteConfig,
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
const agentConfig = new OrganizationAgentConfiguration(config.prisma, config.skillStoreRoot);
|
||||
const modelCatalog = new ProviderModelCatalog(config.prisma, config.secretEnvelope);
|
||||
|
||||
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const roles = await agentConfig.listRoles({ organizationId: auth.organization.id });
|
||||
return { roles };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/org/:orgSlug/agent-roles/:roleId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as {
|
||||
label?: unknown;
|
||||
defaultModel?: unknown;
|
||||
systemPrompt?: unknown;
|
||||
tools?: unknown;
|
||||
sortOrder?: unknown;
|
||||
isDefault?: unknown;
|
||||
};
|
||||
if (typeof body.label !== "string" || body.label.trim() === "") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "label is required" },
|
||||
});
|
||||
}
|
||||
const role = await agentConfig.upsertRole({
|
||||
organizationId: auth.organization.id,
|
||||
roleId,
|
||||
label: body.label,
|
||||
...(body.defaultModel === null || typeof body.defaultModel === "string"
|
||||
? { defaultModel: body.defaultModel as string | null }
|
||||
: {}),
|
||||
...(body.systemPrompt === null || typeof body.systemPrompt === "string"
|
||||
? { systemPrompt: body.systemPrompt as string | null }
|
||||
: {}),
|
||||
...(body.tools === null || Array.isArray(body.tools)
|
||||
? { tools: body.tools as readonly string[] | null }
|
||||
: {}),
|
||||
...(typeof body.sortOrder === "number" ? { sortOrder: body.sortOrder } : {}),
|
||||
...(typeof body.isDefault === "boolean" ? { isDefault: body.isDefault } : {}),
|
||||
});
|
||||
return role;
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/org/:orgSlug/agent-roles/:roleId/skills", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { skillNames?: unknown };
|
||||
if (!Array.isArray(body.skillNames) || body.skillNames.some((n) => typeof n !== "string")) {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "skillNames must be a string array" },
|
||||
});
|
||||
}
|
||||
await agentConfig.setRoleSkills({
|
||||
organizationId: auth.organization.id,
|
||||
roleId,
|
||||
skillNames: body.skillNames as readonly string[],
|
||||
});
|
||||
return { skillNames: body.skillNames as string[] };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/org/:orgSlug/agent-skills", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
|
||||
return { skills };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/org/:orgSlug/agent-skills/:name/files", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
|
||||
const skill = skills.find((s) => s.name === name && s.disabledAt === null);
|
||||
if (skill === undefined) {
|
||||
return reply.status(404).send({
|
||||
error: { code: "not_found", message: `skill not found: ${name}` },
|
||||
});
|
||||
}
|
||||
const files = await agentConfig.readSkillFiles({
|
||||
organizationId: auth.organization.id,
|
||||
contentDigest: skill.contentDigest,
|
||||
});
|
||||
return { files };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { version?: unknown; files?: unknown };
|
||||
if (typeof body.version !== "string" || body.version.trim() === "") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "version is required" },
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(body.files) || body.files.length === 0) {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "files must be a non-empty array" },
|
||||
});
|
||||
}
|
||||
const skillFiles: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
|
||||
for (const entry of body.files) {
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "each file must be an object" },
|
||||
});
|
||||
}
|
||||
const e = entry as { path?: unknown; content?: unknown };
|
||||
if (typeof e.path !== "string" || typeof e.content !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "each file needs string path and content" },
|
||||
});
|
||||
}
|
||||
skillFiles.push({ path: e.path, bytes: Buffer.from(e.content, "utf8") });
|
||||
}
|
||||
const result = await agentConfig.installSkillFromFiles({
|
||||
organizationId: auth.organization.id,
|
||||
files: skillFiles,
|
||||
version: body.version,
|
||||
});
|
||||
// The manifest name is authoritative — reject if it doesn't match the
|
||||
// URL param, so clients can't silently rename a skill under a different
|
||||
// route key.
|
||||
if (result.name !== name) {
|
||||
return reply.status(400).send({
|
||||
error: {
|
||||
code: "bad_request",
|
||||
message: `skill manifest name "${result.name}" does not match route "${name}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { id: result.id, name: result.name, contentDigest: result.contentDigest };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { description?: unknown; disabled?: unknown };
|
||||
if (body.disabled === true) {
|
||||
await agentConfig.disableSkill({ organizationId: auth.organization.id, name });
|
||||
return { disabled: true };
|
||||
}
|
||||
if (typeof body.description === "string") {
|
||||
await agentConfig.updateSkillDescription({
|
||||
organizationId: auth.organization.id,
|
||||
name,
|
||||
description: body.description,
|
||||
});
|
||||
return { updated: true };
|
||||
}
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "provide description or disabled: true" },
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/org/:orgSlug/agent-models", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const providerModels = await modelCatalog.listModels(auth.organization.id);
|
||||
// Fall back to env-default registry when the org has no ACTIVE provider
|
||||
// (or the provider API is unreachable on first load).
|
||||
const models = providerModels.length > 0
|
||||
? providerModels
|
||||
: createDefaultModelRegistry(process.env).listModels();
|
||||
return { models };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -433,8 +433,10 @@ async function resolvePostLoginRedirect(
|
||||
});
|
||||
if (intended === null) return "/admin?error=not_an_active_org_member";
|
||||
const orgRoot = `/admin/org/${intended.organization.slug}`;
|
||||
// Default / missing returnTo sanitizes to "/admin". Always land in the org
|
||||
// admin SPA (not the legacy static "close this tab" complete page).
|
||||
if (returnTo === "/admin") {
|
||||
return `/auth/feishu/complete?org=${encodeURIComponent(intended.organization.name)}`;
|
||||
return orgRoot;
|
||||
}
|
||||
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* ADR-0027: Admin routes for organization-scoped capability connections.
|
||||
* GET /api/org/:orgSlug/capability-connections — list all
|
||||
* GET /api/org/:orgSlug/capability-connections/:capId — read one
|
||||
* PUT /api/org/:orgSlug/capability-connections/:capId — rotate/create
|
||||
* DELETE /api/org/:orgSlug/capability-connections/:capId — disable
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { CapabilityConnectionService } from "../../capability/capabilityConnectionService.js";
|
||||
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
|
||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
export interface CapabilityConnectionRouteConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
readonly readinessProbe?: CapabilityReadinessProbe;
|
||||
}
|
||||
|
||||
export async function registerCapabilityConnectionRoutes(
|
||||
app: FastifyInstance,
|
||||
config: CapabilityConnectionRouteConfig,
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
const connections = new CapabilityConnectionService(
|
||||
config.prisma,
|
||||
config.secretEnvelope,
|
||||
config.readinessProbe,
|
||||
);
|
||||
|
||||
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return { connections: await connections.list(auth.organization.id) };
|
||||
} catch (error) {
|
||||
request.log.error({ requestId: request.id, operation: "capability_connection.list" }, "list failed");
|
||||
return handleRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return { connection: await connections.read(auth.organization.id, capabilityId) };
|
||||
} catch (error) {
|
||||
request.log.error({ requestId: request.id, operation: "capability_connection.read" }, "read failed");
|
||||
return handleRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = parseBody(request.body);
|
||||
const result = await connections.rotate({
|
||||
organizationId: auth.organization.id,
|
||||
capabilityId,
|
||||
actorUserId: auth.user.id,
|
||||
...body,
|
||||
});
|
||||
request.log.info({
|
||||
organizationId: auth.organization.id,
|
||||
capabilityId,
|
||||
connectionId: result.id,
|
||||
status: result.status,
|
||||
secretVersion: result.activeVersion,
|
||||
}, result.created ? "Capability Connection created" : "Capability Connection rotated");
|
||||
const { created, ...metadata } = result;
|
||||
return reply.status(created ? 201 : 200).send(metadata);
|
||||
} catch (error) {
|
||||
const facts = error instanceof CapabilityReadinessError
|
||||
? {
|
||||
errorCode: error.code,
|
||||
failureCategory: error.category,
|
||||
...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}),
|
||||
}
|
||||
: { errorCode: "capability_connection_write_failed" };
|
||||
request.log.error({ requestId: request.id, operation: "capability_connection.rotate", ...facts }, "rotate failed");
|
||||
return handleRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const result = await connections.disable({
|
||||
organizationId: auth.organization.id,
|
||||
capabilityId,
|
||||
actorUserId: auth.user.id,
|
||||
});
|
||||
request.log.info({
|
||||
organizationId: auth.organization.id,
|
||||
capabilityId,
|
||||
connectionId: result.id,
|
||||
status: result.status,
|
||||
}, "Capability Connection disabled");
|
||||
return reply.send(result);
|
||||
} catch (error) {
|
||||
request.log.error({ requestId: request.id, operation: "capability_connection.disable" }, "disable failed");
|
||||
return handleRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error("invalid capability credential body");
|
||||
}
|
||||
const body = value as Record<string, unknown>;
|
||||
for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) {
|
||||
if (typeof body[name] !== "string" || (body[name] as string).trim() === "") {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
accessKeyId: body["accessKeyId"] as string,
|
||||
accessKeySecret: body["accessKeySecret"] as string,
|
||||
endpoint: body["endpoint"] as string,
|
||||
};
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export async function registerExplorerRoutes(
|
||||
workspaceRoot: config.projectWorkspaceRoot,
|
||||
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
|
||||
});
|
||||
return reply.status(201).send(result);
|
||||
return reply.status(201).send({ id: result.projectId, name: body.name });
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
|
||||
@@ -16,10 +16,14 @@ import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
||||
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
||||
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
||||
import { registerCapacityRoutes } from "./capacityRoutes.js";
|
||||
import { readSkillStoreRoot } from "../../agent/skillStore.js";
|
||||
import { registerAgentConfigRoutes } from "./agentConfigRoutes.js";
|
||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
||||
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
|
||||
import { registerFeishuApplicationConnectionRoutes } from "./feishuApplicationConnectionRoutes.js";
|
||||
import { registerCapabilityConnectionRoutes } from "./capabilityConnectionRoutes.js";
|
||||
import type { CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
|
||||
|
||||
export interface OrgRouteConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
@@ -28,6 +32,7 @@ export interface OrgRouteConfig {
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
readonly providerReadinessProbe?: ProviderReadinessProbe;
|
||||
readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe;
|
||||
readonly capabilityReadinessProbe?: CapabilityReadinessProbe;
|
||||
}
|
||||
|
||||
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
|
||||
@@ -110,6 +115,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
});
|
||||
await registerAgentConfigRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
skillStoreRoot: readSkillStoreRoot(),
|
||||
secretEnvelope: config.secretEnvelope,
|
||||
});
|
||||
await registerSessionsAndUsageRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
@@ -130,4 +141,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
||||
? { readinessProbe: config.feishuConnectionReadinessProbe }
|
||||
: {}),
|
||||
});
|
||||
await registerCapabilityConnectionRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
secretEnvelope: config.secretEnvelope,
|
||||
...(config.capabilityReadinessProbe !== undefined
|
||||
? { readinessProbe: config.capabilityReadinessProbe }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
+273
-17
@@ -1,10 +1,37 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { assertSupportedRoleTools } from "./roleTools.js";
|
||||
import { importSkillDirectory } from "./skillStore.js";
|
||||
import { importSkillDirectory, importSkillFromFiles, readSkillFiles, type SkillFileInput, type SkillFileOutput } from "./skillStore.js";
|
||||
|
||||
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
export interface AgentRoleRow {
|
||||
readonly id: string;
|
||||
readonly roleId: string;
|
||||
readonly label: string;
|
||||
readonly defaultModel: string | null;
|
||||
readonly systemPrompt: string | null;
|
||||
readonly tools: readonly string[] | null;
|
||||
readonly sortOrder: number;
|
||||
readonly isDefault: boolean;
|
||||
readonly disabledAt: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly skillNames: readonly string[];
|
||||
}
|
||||
|
||||
export interface AgentSkillRow {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly description: string | null;
|
||||
readonly contentDigest: string;
|
||||
readonly disabledAt: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly boundRoleIds: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep module for controlled host-console Agent configuration. It owns the
|
||||
* filesystem/DB ordering, Organization checks and role-skill composition so
|
||||
@@ -13,43 +40,227 @@ const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
export class OrganizationAgentConfiguration {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly skillStoreRoot: string,
|
||||
private readonly skillStoreRoot: string | null,
|
||||
) {}
|
||||
|
||||
async listRoles(input: { readonly organizationId: string }): Promise<readonly AgentRoleRow[]> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const roles = await this.prisma.organizationAgentRole.findMany({
|
||||
where: { organizationId: input.organizationId, disabledAt: null },
|
||||
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
|
||||
include: {
|
||||
skillBindings: {
|
||||
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
|
||||
select: { skill: { select: { name: true, disabledAt: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
return roles.map((role) => toRoleRow(role));
|
||||
}
|
||||
|
||||
async listSkills(input: { readonly organizationId: string }): Promise<readonly AgentSkillRow[]> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const skills = await this.prisma.organizationAgentSkill.findMany({
|
||||
where: { organizationId: input.organizationId, disabledAt: null },
|
||||
orderBy: [{ name: "asc" }],
|
||||
include: {
|
||||
roleBindings: {
|
||||
where: { role: { disabledAt: null } },
|
||||
select: { role: { select: { roleId: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
return skills.map((skill) => ({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
version: skill.version,
|
||||
description: skill.description,
|
||||
contentDigest: skill.contentDigest,
|
||||
disabledAt: skill.disabledAt === null ? null : skill.disabledAt.toISOString(),
|
||||
createdAt: skill.createdAt.toISOString(),
|
||||
updatedAt: skill.updatedAt.toISOString(),
|
||||
boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
}));
|
||||
}
|
||||
|
||||
async installSkill(input: {
|
||||
readonly organizationId: string;
|
||||
readonly sourceDir: string;
|
||||
readonly version: string;
|
||||
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
||||
const storeRoot = this.requireSkillStoreRoot();
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const version = nonEmpty(input.version, "skill version");
|
||||
const imported = await importSkillDirectory({
|
||||
sourceDir: input.sourceDir,
|
||||
storeRoot: this.skillStoreRoot,
|
||||
storeRoot,
|
||||
});
|
||||
return this.commitInstalledSkill({
|
||||
organizationId: input.organizationId,
|
||||
imported,
|
||||
version,
|
||||
action: "agent_skill.installed",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Install or replace a skill from an in-memory file list (web upload path).
|
||||
* Same content-addressed storage, same session archival semantics as
|
||||
* `installSkill`; only the ingestion source differs.
|
||||
*/
|
||||
async installSkillFromFiles(input: {
|
||||
readonly organizationId: string;
|
||||
readonly files: readonly SkillFileInput[];
|
||||
readonly version: string;
|
||||
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
||||
const storeRoot = this.requireSkillStoreRoot();
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const version = nonEmpty(input.version, "skill version");
|
||||
const imported = await importSkillFromFiles({
|
||||
files: input.files,
|
||||
storeRoot,
|
||||
});
|
||||
return this.commitInstalledSkill({
|
||||
organizationId: input.organizationId,
|
||||
imported,
|
||||
version,
|
||||
action: "agent_skill.installed",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all files from the stored skill version identified by content digest.
|
||||
* Returns UTF-8 content for each file; the web editor uses this to populate
|
||||
* its file tree.
|
||||
*/
|
||||
async readSkillFiles(input: {
|
||||
readonly organizationId: string;
|
||||
readonly contentDigest: string;
|
||||
}): Promise<readonly SkillFileOutput[]> {
|
||||
const storeRoot = this.requireSkillStoreRoot();
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const skill = await this.prisma.organizationAgentSkill.findFirst({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
contentDigest: input.contentDigest,
|
||||
},
|
||||
select: { id: true, disabledAt: true },
|
||||
});
|
||||
if (skill === null) {
|
||||
throw new Error(`skill not found in organization for digest: ${input.contentDigest}`);
|
||||
}
|
||||
return readSkillFiles({
|
||||
storeRoot,
|
||||
contentDigest: input.contentDigest,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a skill (soft-delete). Sets `disabledAt` and archives active
|
||||
* sessions of every role bound to this skill, since the execution surface
|
||||
* changes when a bound skill disappears.
|
||||
*/
|
||||
async disableSkill(input: { readonly organizationId: string; readonly name: string }): Promise<void> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const skill = await tx.organizationAgentSkill.findUnique({
|
||||
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
|
||||
select: { id: true, disabledAt: true, roleBindings: { select: { role: { select: { roleId: true } } } } },
|
||||
});
|
||||
if (skill === null) throw new Error(`active skill not found in organization: ${input.name}`);
|
||||
if (skill.disabledAt !== null) return;
|
||||
await tx.organizationAgentSkill.update({
|
||||
where: { id: skill.id },
|
||||
data: { disabledAt: new Date() },
|
||||
});
|
||||
await archiveRoleSessions(
|
||||
tx,
|
||||
input.organizationId,
|
||||
skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
);
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_skill.disabled",
|
||||
metadata: { name: input.name },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the `description` label of a skill. This is a label change,
|
||||
* not an execution-surface change — no session archival (ADR-0017).
|
||||
*/
|
||||
async updateSkillDescription(input: {
|
||||
readonly organizationId: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
}): Promise<void> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const description = input.description.trim();
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const skill = await tx.organizationAgentSkill.findUnique({
|
||||
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
|
||||
select: { id: true, disabledAt: true },
|
||||
});
|
||||
if (skill === null || skill.disabledAt !== null) {
|
||||
throw new Error(`active skill not found in organization: ${input.name}`);
|
||||
}
|
||||
await tx.organizationAgentSkill.update({
|
||||
where: { id: skill.id },
|
||||
data: { description: description === "" ? null : description },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_skill.description_updated",
|
||||
metadata: { name: input.name, description: description === "" ? null : description },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private requireSkillStoreRoot(): string {
|
||||
if (this.skillStoreRoot === null) {
|
||||
throw new Error("skill store root is not configured for this OrganizationAgentConfiguration");
|
||||
}
|
||||
return this.skillStoreRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared DB commit for an imported skill: upsert the skill record, archive
|
||||
* affected sessions if the content digest changed, and write an audit entry.
|
||||
*/
|
||||
private async commitInstalledSkill(input: {
|
||||
readonly organizationId: string;
|
||||
readonly imported: { readonly name: string; readonly description: string | undefined; readonly contentDigest: string };
|
||||
readonly version: string;
|
||||
readonly action: string;
|
||||
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.organizationAgentSkill.findUnique({
|
||||
where: { organizationId_name: { organizationId: input.organizationId, name: imported.name } },
|
||||
where: { organizationId_name: { organizationId: input.organizationId, name: input.imported.name } },
|
||||
select: { contentDigest: true },
|
||||
});
|
||||
const skill = await tx.organizationAgentSkill.upsert({
|
||||
where: {
|
||||
organizationId_name: {
|
||||
organizationId: input.organizationId,
|
||||
name: imported.name,
|
||||
name: input.imported.name,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
name: imported.name,
|
||||
version,
|
||||
description: imported.description ?? null,
|
||||
contentDigest: imported.contentDigest,
|
||||
name: input.imported.name,
|
||||
version: input.version,
|
||||
description: input.imported.description ?? null,
|
||||
contentDigest: input.imported.contentDigest,
|
||||
},
|
||||
update: {
|
||||
version,
|
||||
description: imported.description ?? null,
|
||||
contentDigest: imported.contentDigest,
|
||||
version: input.version,
|
||||
description: input.imported.description ?? null,
|
||||
contentDigest: input.imported.contentDigest,
|
||||
disabledAt: null,
|
||||
},
|
||||
select: {
|
||||
@@ -69,10 +280,10 @@ export class OrganizationAgentConfiguration {
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_skill.installed",
|
||||
action: input.action,
|
||||
metadata: {
|
||||
name: skill.name,
|
||||
version,
|
||||
version: input.version,
|
||||
contentDigest: skill.contentDigest,
|
||||
},
|
||||
},
|
||||
@@ -90,7 +301,7 @@ export class OrganizationAgentConfiguration {
|
||||
readonly tools?: readonly string[] | null | undefined;
|
||||
readonly sortOrder?: number | undefined;
|
||||
readonly isDefault?: boolean | undefined;
|
||||
}): Promise<{ readonly id: string; readonly roleId: string }> {
|
||||
}): Promise<AgentRoleRow> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`);
|
||||
const label = nonEmpty(input.label, "role label");
|
||||
@@ -150,7 +361,12 @@ export class OrganizationAgentConfiguration {
|
||||
isDefault: effectiveIsDefault,
|
||||
disabledAt: null,
|
||||
},
|
||||
select: { id: true, roleId: true },
|
||||
include: {
|
||||
skillBindings: {
|
||||
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
|
||||
select: { skill: { select: { name: true, disabledAt: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const executionSurfaceChanged = previous !== null && (
|
||||
(input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) ||
|
||||
@@ -182,7 +398,7 @@ export class OrganizationAgentConfiguration {
|
||||
},
|
||||
},
|
||||
});
|
||||
return role;
|
||||
return toRoleRow(role);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,3 +511,43 @@ function normalizeOptionalText(value: string | null | undefined): string | null
|
||||
const normalized = value.trim();
|
||||
return normalized === "" ? null : normalized;
|
||||
}
|
||||
|
||||
function parseTools(value: Prisma.JsonValue): readonly string[] | null {
|
||||
if (value === null) return null;
|
||||
if (!Array.isArray(value)) return null;
|
||||
return value.filter((t): t is string => typeof t === "string");
|
||||
}
|
||||
|
||||
function toRoleRow(role: {
|
||||
readonly id: string;
|
||||
readonly roleId: string;
|
||||
readonly label: string;
|
||||
readonly defaultModel: string | null;
|
||||
readonly systemPrompt: string | null;
|
||||
readonly tools: Prisma.JsonValue;
|
||||
readonly sortOrder: number;
|
||||
readonly isDefault: boolean;
|
||||
readonly disabledAt: Date | null;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
readonly skillBindings: ReadonlyArray<{
|
||||
readonly skill: { readonly name: string; readonly disabledAt: Date | null };
|
||||
}>;
|
||||
}): AgentRoleRow {
|
||||
return {
|
||||
id: role.id,
|
||||
roleId: role.roleId,
|
||||
label: role.label,
|
||||
defaultModel: role.defaultModel,
|
||||
systemPrompt: role.systemPrompt,
|
||||
tools: parseTools(role.tools),
|
||||
sortOrder: role.sortOrder,
|
||||
isDefault: role.isDefault,
|
||||
disabledAt: role.disabledAt === null ? null : role.disabledAt.toISOString(),
|
||||
createdAt: role.createdAt.toISOString(),
|
||||
updatedAt: role.updatedAt.toISOString(),
|
||||
skillNames: role.skillBindings
|
||||
.filter((binding) => binding.skill.disabledAt === null)
|
||||
.map((binding) => binding.skill.name),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = ["Read", "Write", "Bash", "Glob", "Grep"] as const;
|
||||
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Bash",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
] as const;
|
||||
|
||||
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
|
||||
export const CPH_HUB_MCP_TOOL_IDS = [
|
||||
@@ -6,6 +14,7 @@ export const CPH_HUB_MCP_TOOL_IDS = [
|
||||
"feishu_read_context",
|
||||
"feishu_download_resource",
|
||||
"request_approval",
|
||||
"convert_pdf_to_md",
|
||||
] as const;
|
||||
|
||||
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
|
||||
@@ -26,11 +35,15 @@ const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
|
||||
// would need a separate command-policy layer.
|
||||
["cph_check", ["Bash"]],
|
||||
["cph_build", ["Bash"]],
|
||||
["web_fetch", ["WebFetch"]],
|
||||
["web_search", ["WebSearch"]],
|
||||
["Read", ["Read"]],
|
||||
["Write", ["Write"]],
|
||||
["Bash", ["Bash"]],
|
||||
["Glob", ["Glob"]],
|
||||
["Grep", ["Grep"]],
|
||||
["WebFetch", ["WebFetch"]],
|
||||
["WebSearch", ["WebSearch"]],
|
||||
]);
|
||||
|
||||
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
|
||||
@@ -38,10 +51,12 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
|
||||
["feishu_read_context", "feishu_read_context"],
|
||||
["feishu_download_resource", "feishu_download_resource"],
|
||||
["request_approval", "request_approval"],
|
||||
["convert_pdf_to_md", "convert_pdf_to_md"],
|
||||
["mcp__cph_hub__send_file", "send_file"],
|
||||
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
|
||||
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"],
|
||||
["mcp__cph_hub__request_approval", "request_approval"],
|
||||
["mcp__cph_hub__convert_pdf_to_md", "convert_pdf_to_md"],
|
||||
]);
|
||||
|
||||
const SUPPORTED_ROLE_TOOLS = new Set([
|
||||
|
||||
+102
-8
@@ -34,40 +34,134 @@ export async function importSkillDirectory(input: {
|
||||
readonly sourceDir: string;
|
||||
readonly storeRoot: string;
|
||||
}): Promise<ImportedSkillContent> {
|
||||
const source = await inspectSkillDirectory(input.sourceDir);
|
||||
return commitSkillContent({
|
||||
storeRoot: input.storeRoot,
|
||||
prepare: async (dir) => {
|
||||
await cp(input.sourceDir, dir, { recursive: true, force: false, errorOnExist: true });
|
||||
return inspectSkillDirectory(dir);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a skill from an in-memory file list (web upload path). Each file
|
||||
* path is relative to the skill root and must be a simple relative path
|
||||
* (no absolute, no `..`). A `SKILL.md` manifest is required.
|
||||
*/
|
||||
export async function importSkillFromFiles(input: {
|
||||
readonly files: readonly SkillFileInput[];
|
||||
readonly storeRoot: string;
|
||||
}): Promise<ImportedSkillContent> {
|
||||
const seen = new Set<string>();
|
||||
for (const file of input.files) {
|
||||
validateSkillFilePath(file.path);
|
||||
if (seen.has(file.path)) throw new Error(`duplicate skill file path: ${file.path}`);
|
||||
seen.add(file.path);
|
||||
}
|
||||
return commitSkillContent({
|
||||
storeRoot: input.storeRoot,
|
||||
prepare: async (dir) => {
|
||||
for (const file of input.files) {
|
||||
const dest = join(dir, file.path);
|
||||
const parent = join(dest, "..");
|
||||
await mkdir(parent, { recursive: true, mode: 0o750 });
|
||||
await writeFile(dest, file.bytes, { mode: 0o640 });
|
||||
}
|
||||
return inspectSkillDirectory(dir);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all files from a stored skill version (by content digest). Returns
|
||||
* relative paths and UTF-8 decoded content for the web editor.
|
||||
*/
|
||||
export async function readSkillFiles(input: {
|
||||
readonly storeRoot: string;
|
||||
readonly contentDigest: string;
|
||||
}): Promise<readonly SkillFileOutput[]> {
|
||||
if (!DIGEST_PATTERN.test(input.contentDigest)) {
|
||||
throw new Error(`invalid content digest: ${input.contentDigest}`);
|
||||
}
|
||||
const dir = join(input.storeRoot, "versions", input.contentDigest);
|
||||
const files: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
|
||||
await walk(resolve(dir), resolve(dir), files);
|
||||
return files.sort((a, b) => a.path.localeCompare(b.path)).map((f) => ({
|
||||
path: f.path,
|
||||
content: f.bytes.toString("utf8"),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface SkillFileInput {
|
||||
readonly path: string;
|
||||
readonly bytes: Buffer;
|
||||
}
|
||||
|
||||
export interface SkillFileOutput {
|
||||
readonly path: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared commit logic: populate a temp dir, inspect it, deduplicate against
|
||||
* an existing `versions/<digest>/` destination, and atomically rename.
|
||||
*
|
||||
* `prepare(dir)` writes the skill content into `dir` and returns the inspected
|
||||
* result (name, description, contentDigest). The caller owns the write;
|
||||
* commitSkillContent owns the move.
|
||||
*/
|
||||
async function commitSkillContent(input: {
|
||||
readonly storeRoot: string;
|
||||
readonly prepare: (dir: string) => Promise<ImportedSkillContent>;
|
||||
}): Promise<ImportedSkillContent> {
|
||||
const versionsRoot = join(input.storeRoot, "versions");
|
||||
await mkdir(versionsRoot, { recursive: true, mode: 0o750 });
|
||||
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
|
||||
|
||||
let source: ImportedSkillContent;
|
||||
try {
|
||||
source = await input.prepare(temporary);
|
||||
} catch (error) {
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const destination = join(versionsRoot, source.contentDigest);
|
||||
|
||||
// If the destination already exists with identical content, reuse it.
|
||||
try {
|
||||
const existing = await inspectSkillDirectory(destination);
|
||||
if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) {
|
||||
throw new Error(`stored skill content digest mismatch: ${source.name}`);
|
||||
}
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
return source;
|
||||
} catch (error) {
|
||||
if (!isMissingPath(error)) throw error;
|
||||
}
|
||||
|
||||
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
|
||||
try {
|
||||
await cp(input.sourceDir, temporary, { recursive: true, force: false, errorOnExist: true });
|
||||
const copied = await inspectSkillDirectory(temporary);
|
||||
if (copied.contentDigest !== source.contentDigest || copied.name !== source.name) {
|
||||
throw new Error(`skill changed while importing: ${source.name}`);
|
||||
}
|
||||
await rename(temporary, destination);
|
||||
} catch (error) {
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
if (isDestinationExists(error)) {
|
||||
const existing = await inspectSkillDirectory(destination);
|
||||
if (existing.contentDigest === source.contentDigest && existing.name === source.name) return source;
|
||||
if (existing.contentDigest === source.contentDigest && existing.name === source.name) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function validateSkillFilePath(path: string): void {
|
||||
if (path === "") throw new Error("skill file path is empty");
|
||||
if (path.startsWith("/")) throw new Error(`skill file path must be relative: ${path}`);
|
||||
if (path.includes("..")) throw new Error(`skill file path must not escape: ${path}`);
|
||||
if (path.startsWith("\\")) throw new Error(`skill file path must be relative: ${path}`);
|
||||
}
|
||||
|
||||
export async function prepareRunSkillPlugin(input: {
|
||||
readonly storeRoot: string;
|
||||
readonly runId: string;
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* ADR-0027: Organization-scoped capability connection service. Manages the
|
||||
* lifecycle (rotate / read / disable) of capability credentials stored in
|
||||
* ADR-0024 encrypted envelopes with purpose="capability".
|
||||
*
|
||||
* Mirrors FeishuApplicationConnectionService, but keyed by (organizationId,
|
||||
* capabilityId) instead of 1:1 — an org may have multiple capabilities.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { lockActiveOrganization } from "../org/status.js";
|
||||
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
|
||||
import type { CapabilitySecretPayload } from "./types.js";
|
||||
|
||||
const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const KNOWN_CAPABILITY_IDS = new Set(["pdf_to_md_bundle", "audio_video_to_text"]);
|
||||
|
||||
export interface CapabilityCredentialInput {
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
export interface RotateCapabilityInput extends CapabilityCredentialInput {
|
||||
readonly organizationId: string;
|
||||
readonly capabilityId: string;
|
||||
readonly actorUserId: string;
|
||||
}
|
||||
|
||||
export interface CapabilityConnectionMetadata {
|
||||
readonly id: string;
|
||||
readonly capabilityId: string;
|
||||
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
||||
readonly activeVersion: number | null;
|
||||
readonly keyId: string | null;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CapabilityConnectionWriteResult extends CapabilityConnectionMetadata {
|
||||
readonly created: boolean;
|
||||
}
|
||||
|
||||
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
|
||||
|
||||
export class CapabilityConnectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly secrets: LocalSecretEnvelope,
|
||||
private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential,
|
||||
) {}
|
||||
|
||||
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
|
||||
if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) {
|
||||
throw new Error(`invalid capabilityId: ${input.capabilityId}`);
|
||||
}
|
||||
const payload = validateCredential(input);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await requireCapabilityAdmin(tx, input);
|
||||
});
|
||||
await this.readinessProbe({
|
||||
endpoint: payload.endpoint,
|
||||
accessKeyId: payload.accessKeyId,
|
||||
accessKeySecret: payload.accessKeySecret,
|
||||
});
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await requireCapabilityAdmin(tx, input);
|
||||
const connection = await tx.organizationCapabilityConnection.upsert({
|
||||
where: {
|
||||
organizationId_capabilityId: {
|
||||
organizationId: input.organizationId,
|
||||
capabilityId: input.capabilityId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
id: randomUUID(),
|
||||
organizationId: input.organizationId,
|
||||
capabilityId: input.capabilityId,
|
||||
status: "DRAFT",
|
||||
},
|
||||
});
|
||||
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
|
||||
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
|
||||
where: { id: connection.id },
|
||||
include: {
|
||||
activeSecretVersion: true,
|
||||
secretVersions: { orderBy: { version: "desc" }, take: 1, select: { version: true } },
|
||||
},
|
||||
});
|
||||
if (locked.organizationId !== input.organizationId) {
|
||||
throw new Error("Capability Connection scope changed during rotation");
|
||||
}
|
||||
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
|
||||
const secretVersionId = randomUUID();
|
||||
const envelope = this.secrets.encryptJson(
|
||||
{
|
||||
purpose: "capability",
|
||||
organizationId: input.organizationId,
|
||||
connectionId: locked.id,
|
||||
secretVersionId,
|
||||
},
|
||||
payload,
|
||||
);
|
||||
const now = new Date();
|
||||
const secretVersion = await tx.capabilityCredentialVersion.create({
|
||||
data: {
|
||||
id: secretVersionId,
|
||||
connectionId: locked.id,
|
||||
version,
|
||||
envelopeVersion: envelope.version,
|
||||
keyId: envelope.keyId,
|
||||
envelope: envelope as unknown as Prisma.InputJsonValue,
|
||||
createdByUserId: input.actorUserId,
|
||||
},
|
||||
});
|
||||
if (locked.activeSecretVersion !== null) {
|
||||
await tx.capabilityCredentialVersion.update({
|
||||
where: { id: locked.activeSecretVersion.id },
|
||||
data: { retiredAt: now },
|
||||
});
|
||||
}
|
||||
const activated = await tx.organizationCapabilityConnection.update({
|
||||
where: { id: locked.id },
|
||||
data: {
|
||||
status: "ACTIVE",
|
||||
activeSecretVersionId: secretVersion.id,
|
||||
activatedAt: now,
|
||||
disabledAt: null,
|
||||
},
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: version === 1 ? "capability.created" : "capability.rotated",
|
||||
metadata: {
|
||||
connectionId: locked.id,
|
||||
capabilityId: input.capabilityId,
|
||||
status: "ACTIVE",
|
||||
secretVersion: version,
|
||||
keyId: envelope.keyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
...toMetadata(activated, { version, keyId: secretVersion.keyId }),
|
||||
created: version === 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<CapabilityConnectionMetadata[]> {
|
||||
const connections = await this.prisma.organizationCapabilityConnection.findMany({
|
||||
where: { organizationId },
|
||||
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||
orderBy: { capabilityId: "asc" },
|
||||
});
|
||||
return connections.map((c) => toMetadata(c, c.activeSecretVersion));
|
||||
}
|
||||
|
||||
async read(organizationId: string, capabilityId: string): Promise<CapabilityConnectionMetadata | null> {
|
||||
const connection = await this.prisma.organizationCapabilityConnection.findFirst({
|
||||
where: { organizationId, capabilityId },
|
||||
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||
});
|
||||
return connection === null ? null : toMetadata(connection, connection.activeSecretVersion);
|
||||
}
|
||||
|
||||
async disable(input: {
|
||||
readonly organizationId: string;
|
||||
readonly capabilityId: string;
|
||||
readonly actorUserId: string;
|
||||
}): Promise<CapabilityConnectionMetadata> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await requireCapabilityAdmin(tx, input);
|
||||
const connection = await tx.organizationCapabilityConnection.findFirst({
|
||||
where: { organizationId: input.organizationId, capabilityId: input.capabilityId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (connection === null) throw new Error("Capability Connection not found");
|
||||
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
|
||||
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
|
||||
where: { id: connection.id },
|
||||
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||
});
|
||||
if (locked.status === "DISABLED") return toMetadata(locked, locked.activeSecretVersion);
|
||||
const disabled = await tx.organizationCapabilityConnection.update({
|
||||
where: { id: locked.id },
|
||||
data: { status: "DISABLED", disabledAt: new Date() },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: "capability.disabled",
|
||||
metadata: {
|
||||
connectionId: locked.id,
|
||||
capabilityId: input.capabilityId,
|
||||
previousStatus: locked.status,
|
||||
status: "DISABLED",
|
||||
},
|
||||
},
|
||||
});
|
||||
return toMetadata(disabled, locked.activeSecretVersion);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 {
|
||||
if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) {
|
||||
throw new Error(`unsupported capabilityId: ${input.capabilityId}`);
|
||||
}
|
||||
const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId");
|
||||
const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret");
|
||||
const endpoint = nonEmpty(input.endpoint, "endpoint");
|
||||
return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint };
|
||||
}
|
||||
|
||||
function toMetadata(
|
||||
connection: {
|
||||
readonly id: string;
|
||||
readonly capabilityId: string;
|
||||
readonly status: string;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
},
|
||||
secret: { readonly version: number; readonly keyId: string } | null,
|
||||
): CapabilityConnectionMetadata {
|
||||
return {
|
||||
id: connection.id,
|
||||
capabilityId: connection.capabilityId,
|
||||
status: connection.status as CapabilityConnectionMetadata["status"],
|
||||
activeVersion: secret?.version ?? null,
|
||||
keyId: secret?.keyId ?? null,
|
||||
createdAt: connection.createdAt,
|
||||
updatedAt: connection.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireCapabilityAdmin(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: { readonly organizationId: string; readonly actorUserId: string },
|
||||
): Promise<void> {
|
||||
await lockActiveOrganization(tx, input.organizationId);
|
||||
const membership = await tx.organizationMembership.findFirst({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
userId: input.actorUserId,
|
||||
role: { in: ["OWNER", "ADMIN"] },
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (membership === null) {
|
||||
throw new Error("only Organization OWNER or ADMIN may manage capability connections");
|
||||
}
|
||||
}
|
||||
|
||||
function nonEmpty(value: string, label: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") throw new Error(`${label} must not be empty`);
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* ADR-0027: org-scoped capability credential resolver. Reuses the ADR-0024
|
||||
* envelope decryption machinery (LocalSecretEnvelope) with
|
||||
* purpose="capability", distinct from the model-provider and Feishu
|
||||
* application connections. Fail-closed: no process-global fallback.
|
||||
*
|
||||
* Mirrors the Feishu application connection resolver shape, minus the
|
||||
* readiness probe (capability probes are per-capability and injected by the
|
||||
* adapter wiring, not this resolver).
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
|
||||
import {
|
||||
CapabilityConnectionUnavailable,
|
||||
type CapabilitySecretPayload,
|
||||
type CapabilityId,
|
||||
} from "./types.js";
|
||||
|
||||
const CAPABILITY_PURPOSE = "capability";
|
||||
|
||||
export interface ResolvedCapabilityCredential extends CapabilitySecretPayload {
|
||||
readonly connectionId: string;
|
||||
readonly organizationId: string;
|
||||
readonly capabilityId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active capability credential for an organization. Throws
|
||||
* CapabilityConnectionUnavailable when the org has no ACTIVE connection
|
||||
* (fail-closed, ADR-0024). Decrypted plaintext exists only in the returned
|
||||
* object for the duration of the capability call; it is never cached, logged,
|
||||
* or passed to the Agent process.
|
||||
*/
|
||||
export async function resolveCapabilityCredential(
|
||||
prisma: PrismaClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
input: { readonly organizationId: string; readonly capabilityId: CapabilityId },
|
||||
): Promise<ResolvedCapabilityCredential> {
|
||||
const connection = await prisma.organizationCapabilityConnection.findFirst({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
capabilityId: input.capabilityId,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
include: { activeSecretVersion: true },
|
||||
});
|
||||
if (connection === null || connection.activeSecretVersion === null) {
|
||||
throw new CapabilityConnectionUnavailable(input.capabilityId, input.organizationId);
|
||||
}
|
||||
const version = connection.activeSecretVersion;
|
||||
const binding = {
|
||||
purpose: CAPABILITY_PURPOSE,
|
||||
organizationId: connection.organizationId,
|
||||
connectionId: connection.id,
|
||||
secretVersionId: version.id,
|
||||
};
|
||||
const payload = secrets.decryptJson<CapabilitySecretPayload>(binding, version.envelope as unknown as SecretEnvelopeV1);
|
||||
if (payload.schemaVersion !== 1) {
|
||||
throw new Error(`unsupported capability secret schemaVersion: ${payload.schemaVersion}`);
|
||||
}
|
||||
return {
|
||||
connectionId: connection.id,
|
||||
organizationId: connection.organizationId,
|
||||
capabilityId: connection.capabilityId,
|
||||
schemaVersion: 1,
|
||||
accessKeyId: payload.accessKeyId,
|
||||
accessKeySecret: payload.accessKeySecret,
|
||||
endpoint: payload.endpoint,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* ADR-0027: Capability readiness probe. Validates the Alibaba Cloud docmind
|
||||
* credential by calling QueryDocParserStatus with a dummy id — a 400 (bad
|
||||
* request) means the credential is valid (the API accepted auth but rejected
|
||||
* the id); a 401/403 means the credential is bad.
|
||||
*/
|
||||
import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js";
|
||||
|
||||
export interface CapabilityReadinessInput {
|
||||
readonly endpoint: string;
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
}
|
||||
|
||||
export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise<void>;
|
||||
|
||||
export class CapabilityReadinessError extends Error {
|
||||
constructor(
|
||||
readonly code: "capability_readiness_unsupported" | "capability_readiness_unreachable" | "capability_readiness_rejected",
|
||||
message: string,
|
||||
readonly category: NetworkFailureCategory | "configuration" | "http",
|
||||
readonly upstreamStatus?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CapabilityReadinessError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the Alibaba Cloud docmind credential. We call QueryDocParserStatus
|
||||
* with a dummy id. The API will return:
|
||||
* - 400 (InvalidParameter) → credential valid, just a bad id → probe passes
|
||||
* - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails
|
||||
* - network error → unreachable
|
||||
*/
|
||||
export const probeDocmindCredential: CapabilityReadinessProbe = async (input) => {
|
||||
const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`;
|
||||
const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { authorization: authHeader, accept: "application/json" },
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_unreachable",
|
||||
"docmind credential readiness check could not reach the API",
|
||||
classifyNetworkFailure(error),
|
||||
);
|
||||
}
|
||||
await response.body?.cancel();
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_rejected",
|
||||
`docmind credential rejected: status ${response.status}`,
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string {
|
||||
const credentials = Buffer.from(`${accessKeyId}:${accessKeySecret}`).toString("base64");
|
||||
return `Basic ${credentials}`;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* ADR-0027: Alibaba Cloud Document Mind (docmind) client.
|
||||
*
|
||||
* Uses the official @alicloud/docmind-api20220711 SDK to call the document
|
||||
* parsing (large model version) API. The API is asynchronous:
|
||||
* 1. SubmitDocParserJobAdvance — upload local file as a stream, get job id
|
||||
* 2. QueryDocParserStatus — poll until data.status === "success";
|
||||
* the markdown output URL is returned in outputFormatResult
|
||||
* 3. Download markdown from OSS, extract and download referenced images
|
||||
*
|
||||
* Pricing (2025-07, aliyun docmind):
|
||||
* - 图文文档基础链路: 0.02元/页
|
||||
* - 图文文档增强链路 (含公式 LaTeX): 0.04元/页
|
||||
* - 视频: 0.002元/秒
|
||||
* - 音频: 0.00035元/秒
|
||||
*/
|
||||
import $DocmindClient, {
|
||||
SubmitDocParserJobAdvanceRequest,
|
||||
QueryDocParserStatusRequest,
|
||||
} from "@alicloud/docmind-api20220711";
|
||||
import { RuntimeOptions } from "@alicloud/tea-util";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import type { CapabilitySecretPayload } from "./types.js";
|
||||
|
||||
/** A single extracted image downloaded from the markdown's OSS image URLs. */
|
||||
export interface DocmindExtractedImage {
|
||||
readonly filename: string;
|
||||
readonly data: Uint8Array;
|
||||
}
|
||||
|
||||
/** The structured result of parsing one document. */
|
||||
export interface DocmindParseResult {
|
||||
readonly markdown: string;
|
||||
readonly images: readonly DocmindExtractedImage[];
|
||||
readonly pageCount: number;
|
||||
readonly costUsd: number | null;
|
||||
readonly requestId: string | null;
|
||||
}
|
||||
|
||||
export interface DocmindParseOptions {
|
||||
readonly inputFilePath: string;
|
||||
}
|
||||
|
||||
export interface CapabilityProviderClient {
|
||||
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
|
||||
}
|
||||
|
||||
export class DocmindClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: "docmind_unreachable" | "docmind_rejected" | "docmind_invalid_response" | "docmind_no_output" | "docmind_timeout",
|
||||
readonly upstreamStatus?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DocmindClientError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Cost per page in USD (0.04 CNY/page ≈ 0.0056 USD). */
|
||||
const COST_PER_PAGE_USD = 0.0056;
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
const POLL_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
|
||||
|
||||
export class AliyunDocmindClient implements CapabilityProviderClient {
|
||||
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
|
||||
const config: DocmindConfig = {
|
||||
endpoint: credential.endpoint,
|
||||
accessKeyId: credential.accessKeyId,
|
||||
accessKeySecret: credential.accessKeySecret,
|
||||
type: "access_key",
|
||||
regionId: "cn-hangzhou",
|
||||
} as DocmindConfig;
|
||||
const client = new $DocmindClient.default(config);
|
||||
|
||||
// 1. Submit job with local file as a ReadStream (not a Buffer — the SDK
|
||||
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
|
||||
// can't read; a Stream is uploaded as multipart form data).
|
||||
const fileName = basename(options.inputFilePath);
|
||||
const fileStream = createReadStream(options.inputFilePath);
|
||||
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
|
||||
fileUrlObject: fileStream,
|
||||
fileName,
|
||||
outputFormat: ["markdown"],
|
||||
formulaEnhancement: true,
|
||||
});
|
||||
const runtime = new RuntimeOptions({});
|
||||
let submitResponse;
|
||||
try {
|
||||
submitResponse = await client.submitDocParserJobAdvance(advanceRequest, runtime);
|
||||
} catch (e) {
|
||||
throw new DocmindClientError(
|
||||
e instanceof Error ? e.message : String(e),
|
||||
"docmind_unreachable",
|
||||
);
|
||||
}
|
||||
const jobId = submitResponse.body?.data?.id;
|
||||
if (jobId === undefined || jobId === null || jobId === "") {
|
||||
throw new DocmindClientError("docmind submit returned no job id", "docmind_invalid_response");
|
||||
}
|
||||
|
||||
// 2. Poll QueryDocParserStatus until data.status === "success" or "fail".
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
let status = "";
|
||||
let markdownUrl: string | null = null;
|
||||
let pageCount = 0;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
const statusReq = new QueryDocParserStatusRequest({ id: jobId });
|
||||
const statusResponse = await client.queryDocParserStatus(statusReq);
|
||||
const body = statusResponse.body as {
|
||||
data?: {
|
||||
status?: string;
|
||||
pageCountEstimate?: number;
|
||||
outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }>;
|
||||
};
|
||||
};
|
||||
status = body.data?.status ?? "";
|
||||
if (status === "success") {
|
||||
const mdResult = body.data?.outputFormatResult?.find((r) => r.outputType === "markdown");
|
||||
markdownUrl = mdResult?.outputFileUrl ?? null;
|
||||
pageCount = body.data?.pageCountEstimate ?? 0;
|
||||
break;
|
||||
}
|
||||
if (status === "fail") {
|
||||
throw new DocmindClientError(`docmind job ${jobId} failed`, "docmind_rejected");
|
||||
}
|
||||
}
|
||||
if (status !== "success") {
|
||||
throw new DocmindClientError(`docmind job ${jobId} timed out (status: ${status})`, "docmind_timeout");
|
||||
}
|
||||
if (markdownUrl === null) {
|
||||
throw new DocmindClientError("docmind returned no markdown output URL", "docmind_no_output");
|
||||
}
|
||||
|
||||
// 3. Download the markdown file from OSS.
|
||||
let markdown: string;
|
||||
try {
|
||||
const mdResp = await fetch(markdownUrl);
|
||||
if (!mdResp.ok) {
|
||||
throw new DocmindClientError(`failed to download markdown: HTTP ${mdResp.status}`, "docmind_unreachable");
|
||||
}
|
||||
markdown = await mdResp.text();
|
||||
} catch (e) {
|
||||
if (e instanceof DocmindClientError) throw e;
|
||||
throw new DocmindClientError(
|
||||
e instanceof Error ? e.message : String(e),
|
||||
"docmind_unreachable",
|
||||
);
|
||||
}
|
||||
if (markdown === "") {
|
||||
throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output");
|
||||
}
|
||||
|
||||
// 4. Download images referenced in the markdown (OSS URLs).
|
||||
// Markdown contains  entries.
|
||||
// We rewrite them to local relative paths and download the images.
|
||||
const images: DocmindExtractedImage[] = [];
|
||||
const imageRefRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
|
||||
const localMarkdown = markdown.replace(imageRefRegex, (match, altText: string, url: string) => {
|
||||
const idx = images.findIndex((img) => img.filename === extractFilename(altText, url, images.length));
|
||||
if (idx >= 0) {
|
||||
const img = images[idx]!;
|
||||
return ``;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// Collect all image URLs first, then download.
|
||||
const imageUrls: Array<{ url: string; altText: string }> = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const collectRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
|
||||
while ((match = collectRegex.exec(markdown)) !== null) {
|
||||
imageUrls.push({ url: match[2]!, altText: match[1]! });
|
||||
}
|
||||
|
||||
for (let i = 0; i < imageUrls.length; i++) {
|
||||
const { url, altText } = imageUrls[i]!;
|
||||
const filename = extractFilename(altText, url, i);
|
||||
try {
|
||||
const imgResp = await fetch(url);
|
||||
if (!imgResp.ok) continue;
|
||||
const data = new Uint8Array(await imgResp.arrayBuffer());
|
||||
images.push({ filename, data });
|
||||
} catch {
|
||||
// Best-effort: skip images that fail to download.
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite markdown with local image paths.
|
||||
let finalMarkdown = markdown;
|
||||
let imageIdx = 0;
|
||||
finalMarkdown = finalMarkdown.replace(imageRefRegex, (match, altText: string, _url: string) => {
|
||||
if (imageIdx < images.length) {
|
||||
const img = images[imageIdx]!;
|
||||
imageIdx++;
|
||||
return ``;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
if (pageCount === 0) {
|
||||
pageCount = Math.max(1, images.length);
|
||||
}
|
||||
const costUsd = (pageCount > 0 ? pageCount : 1) * COST_PER_PAGE_USD;
|
||||
|
||||
return { markdown: finalMarkdown, images, pageCount, costUsd, requestId: jobId };
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/** Derive a clean filename from the alt text or URL. */
|
||||
function extractFilename(altText: string, url: string, index: number): string {
|
||||
// Try alt text first (docmind often puts the original filename).
|
||||
if (altText !== "" && altText.length < 100) {
|
||||
const cleaned = altText.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
if (cleaned.length > 0) return cleaned;
|
||||
}
|
||||
// Fall back to URL path.
|
||||
const urlPath = new URL(url).pathname;
|
||||
const base = basename(urlPath);
|
||||
if (base !== "" && base !== "/") return base;
|
||||
return `image_${index + 1}.png`;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* ADR-0027: pdf_to_md_bundle capability adapter.
|
||||
*
|
||||
* Converts a PDF in the run's workspace into a Markdown bundle (md + extracted
|
||||
* images) by calling the MinerU document parsing service, writing outputs into
|
||||
* the workspace, and recording consumption on a UsageFact (ADR-0026).
|
||||
*
|
||||
* Invariants (ADR-0027):
|
||||
* 1. Credential isolation — the capability credential is resolved in Hub and
|
||||
* never reaches the Agent process. The MineruClient receives it as a
|
||||
* call argument, not from the environment.
|
||||
* 2. Workspace containment — input and output paths are confined to the
|
||||
* run's workspace dir (ADR-0018 AgentSurface). Escapes are rejected.
|
||||
* 3. Mandatory fact — a successful invocation always writes ≥1 UsageFact
|
||||
* with kind=external_capability, even when costUsd is null (ADR-0022:
|
||||
* missing cost ≠ zero).
|
||||
*/
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join, resolve, relative, isAbsolute } from "node:path";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { resolveCapabilityCredential } from "./capabilityConnections.js";
|
||||
import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js";
|
||||
import {
|
||||
CAPABILITIES,
|
||||
type CapabilityAdapter,
|
||||
type CapabilityInvocationInput,
|
||||
type CapabilityInvocationResult,
|
||||
type CapabilityOutputArtifact,
|
||||
} from "./types.js";
|
||||
|
||||
const CAPABILITY_ID = "pdf_to_md_bundle" as const;
|
||||
const PROVIDER_ID = "aliyun_docmind";
|
||||
|
||||
/** Thrown when a requested path escapes the workspace root (ADR-0018). */
|
||||
export class CapabilityPathEscape extends Error {
|
||||
constructor(readonly requested: string, readonly workspaceDir: string) {
|
||||
super(`capability path escapes workspace: ${requested} (root ${workspaceDir})`);
|
||||
this.name = "CapabilityPathEscape";
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a workspace-relative path, rejecting escapes (ADR-0018 AgentSurface). */
|
||||
function confineToWorkspace(requestedPath: string, workspaceDir: string): string {
|
||||
if (isAbsolute(requestedPath)) {
|
||||
const rel = relative(workspaceDir, requestedPath);
|
||||
if (rel.startsWith("..") || rel === "") {
|
||||
throw new CapabilityPathEscape(requestedPath, workspaceDir);
|
||||
}
|
||||
return requestedPath;
|
||||
}
|
||||
const resolved = resolve(workspaceDir, requestedPath);
|
||||
const rel = relative(workspaceDir, resolved);
|
||||
if (rel.startsWith("..")) {
|
||||
throw new CapabilityPathEscape(requestedPath, workspaceDir);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export interface PdfToMdBundleDeps {
|
||||
readonly secrets: LocalSecretEnvelope;
|
||||
readonly client: CapabilityProviderClient;
|
||||
readonly prisma: PrismaClient;
|
||||
}
|
||||
|
||||
/** Build the pdf_to_md_bundle adapter. The client is injectable for testing. */
|
||||
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
|
||||
return {
|
||||
capabilityId: CAPABILITY_ID,
|
||||
async invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult> {
|
||||
const descriptor = CAPABILITIES[CAPABILITY_ID];
|
||||
// 1. Resolve org-scoped credential (fail-closed, ADR-0024/0027).
|
||||
const credential = await resolveCapabilityCredential(deps.prisma, deps.secrets, {
|
||||
organizationId: input.organizationId,
|
||||
capabilityId: CAPABILITY_ID,
|
||||
});
|
||||
|
||||
// 2. Confine input + output paths to the workspace (ADR-0018).
|
||||
const absoluteInput = confineToWorkspace(input.inputPath, input.workspaceDir);
|
||||
const absoluteOutputDir = confineToWorkspace(input.outputDir, input.workspaceDir);
|
||||
await mkdir(absoluteOutputDir, { recursive: true });
|
||||
|
||||
// 3. Call the backing service.
|
||||
let result;
|
||||
try {
|
||||
result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
|
||||
} catch (e) {
|
||||
if (e instanceof DocmindClientError) throw e;
|
||||
throw new DocmindClientError(
|
||||
e instanceof Error ? e.message : String(e),
|
||||
"docmind_unreachable",
|
||||
);
|
||||
}
|
||||
if (result.markdown === "") {
|
||||
throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output");
|
||||
}
|
||||
|
||||
// 4. Write outputs into the workspace.
|
||||
const artifacts: CapabilityOutputArtifact[] = [];
|
||||
const mdPath = join(absoluteOutputDir, "document.md");
|
||||
await writeFile(mdPath, result.markdown, "utf8");
|
||||
artifacts.push({ path: relative(input.workspaceDir, mdPath), kind: "markdown" });
|
||||
|
||||
for (const image of result.images) {
|
||||
const imagePath = join(absoluteOutputDir, image.filename);
|
||||
const rel = relative(absoluteOutputDir, imagePath);
|
||||
if (rel.startsWith("..")) {
|
||||
// Defensive: image filename must not escape the output dir.
|
||||
throw new CapabilityPathEscape(image.filename, absoluteOutputDir);
|
||||
}
|
||||
await writeFile(imagePath, image.data);
|
||||
artifacts.push({ path: relative(input.workspaceDir, imagePath), kind: "image" });
|
||||
}
|
||||
|
||||
// 5. Write the UsageFact (ADR-0026/0027). Always written on success;
|
||||
// costUsd null means unknown, NOT zero (ADR-0022).
|
||||
const occurredAt = new Date();
|
||||
await deps.prisma.usageFact.create({
|
||||
data: {
|
||||
runId: input.runId,
|
||||
occurredAt,
|
||||
kind: "external_capability",
|
||||
provider: PROVIDER_ID,
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
quantity: result.pageCount,
|
||||
unit: descriptor.meteringUnit,
|
||||
costUsd: result.costUsd,
|
||||
costSource: result.costUsd !== null ? "provider_reported" : "unknown",
|
||||
capabilityId: CAPABILITY_ID,
|
||||
correlationId: result.requestId,
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
artifacts,
|
||||
consumption: {
|
||||
provider: PROVIDER_ID,
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
quantity: result.pageCount,
|
||||
unit: descriptor.meteringUnit,
|
||||
costUsd: result.costUsd,
|
||||
correlationId: result.requestId,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* ADR-0027: External capability types shared across the adapter layer.
|
||||
*
|
||||
* A capability is a platform-registered, org-enabled document/media transform
|
||||
* invoked as a side effect of an AgentRun. The adapter resolves the org's
|
||||
* active capability connection, calls the backing service via an injectable
|
||||
* client, writes output into the run's workspace (AgentSurface, ADR-0018),
|
||||
* and records consumption on a UsageFact (ADR-0026).
|
||||
*/
|
||||
import type { PrismaClient, Prisma } from "@prisma/client";
|
||||
|
||||
/** Stable capability identifiers registered with the platform (ADR-0027). */
|
||||
export const CAPABILITY_IDS = [
|
||||
"pdf_to_md_bundle",
|
||||
"audio_video_to_text",
|
||||
] as const;
|
||||
|
||||
export type CapabilityId = (typeof CAPABILITY_IDS)[number];
|
||||
|
||||
/** Non-token metering unit for a capability (ADR-0026/0027). */
|
||||
export interface CapabilityDescriptor {
|
||||
readonly id: CapabilityId;
|
||||
readonly meteringUnit: string;
|
||||
}
|
||||
|
||||
/** Known capabilities and their metering units. Code-level registry. */
|
||||
export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = {
|
||||
pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" },
|
||||
audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" },
|
||||
};
|
||||
|
||||
/** Input passed to a capability adapter invocation. */
|
||||
export interface CapabilityInvocationInput {
|
||||
readonly runId: string;
|
||||
readonly organizationId: string;
|
||||
readonly projectId: string;
|
||||
/** Absolute workspace dir of the run's project (ADR-0018 surface root). */
|
||||
readonly workspaceDir: string;
|
||||
/** Workspace-relative path to the input file (PDF, audio, …). */
|
||||
readonly inputPath: string;
|
||||
/** Workspace-relative directory to write outputs into. Created if absent. */
|
||||
readonly outputDir: string;
|
||||
/** Prisma client for UsageFact writes. */
|
||||
readonly prisma: PrismaClient;
|
||||
}
|
||||
|
||||
/** A successfully produced output artifact (file written into workspace). */
|
||||
export interface CapabilityOutputArtifact {
|
||||
/** Workspace-relative path of the written artifact. */
|
||||
readonly path: string;
|
||||
readonly kind: "markdown" | "image" | "metadata" | "other";
|
||||
}
|
||||
|
||||
/** Consumption recorded for one invocation (written to UsageFact). */
|
||||
export interface CapabilityConsumption {
|
||||
/** The backing service provider id (e.g. "mineru", "openai_whisper"). */
|
||||
readonly provider: string;
|
||||
/** Model id if the service reports one; null for non-model services. */
|
||||
readonly model: string | null;
|
||||
readonly inputTokens: number | null;
|
||||
readonly outputTokens: number | null;
|
||||
/** Non-token meter (page count, audio seconds). */
|
||||
readonly quantity: number;
|
||||
readonly unit: string;
|
||||
/** USD cost if the service reported one; null = unknown (ADR-0022). */
|
||||
readonly costUsd: number | null;
|
||||
/** External request id for reconciliation. */
|
||||
readonly correlationId: string | null;
|
||||
}
|
||||
|
||||
/** Result of a successful capability invocation. */
|
||||
export interface CapabilityInvocationResult {
|
||||
readonly artifacts: readonly CapabilityOutputArtifact[];
|
||||
readonly consumption: CapabilityConsumption;
|
||||
}
|
||||
|
||||
/** A capability adapter: resolves credentials, calls the service, writes output. */
|
||||
export interface CapabilityAdapter {
|
||||
readonly capabilityId: CapabilityId;
|
||||
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
|
||||
}
|
||||
|
||||
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
|
||||
* Alibaba Cloud Document Mind (docmind) uses AccessKey ID + Secret + endpoint. */
|
||||
export interface CapabilitySecretPayload {
|
||||
readonly schemaVersion: 1;
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
/** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */
|
||||
export class CapabilityConnectionUnavailable extends Error {
|
||||
constructor(readonly capabilityId: string, readonly organizationId: string) {
|
||||
super(`no ACTIVE capability connection for ${capabilityId} in org ${organizationId}`);
|
||||
this.name = "CapabilityConnectionUnavailable";
|
||||
}
|
||||
}
|
||||
|
||||
/** Prisma transaction client type alias (for resolver signatures). */
|
||||
export type TxClient = Prisma.TransactionClient;
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Provider model catalog: fetches the list of models available to an
|
||||
* Organization from its ACTIVE provider connection (OpenRouter), with an
|
||||
* in-memory TTL cache so the admin model picker stays responsive without
|
||||
* hammering the upstream API on every page load.
|
||||
*
|
||||
* The model list is **derived** from the provider connection — there is no
|
||||
* separate model table to maintain. When an org activates a provider, its
|
||||
* models become selectable; when the provider is disabled, the list empties.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { decryptStoredProviderCredential } from "./providerConnections.js";
|
||||
|
||||
/** A model entry surfaced to the admin model picker. */
|
||||
export interface ProviderModelEntry {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly toolCapable: boolean;
|
||||
}
|
||||
|
||||
/** A model entry as returned by OpenRouter's /v1/models endpoint. */
|
||||
interface OpenRouterModel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly supported_parameters: readonly string[];
|
||||
}
|
||||
|
||||
interface OpenRouterModelsResponse {
|
||||
readonly data: readonly OpenRouterModel[];
|
||||
}
|
||||
|
||||
/** Cache entry: models + expiry timestamp. */
|
||||
interface CacheEntry {
|
||||
readonly models: readonly ProviderModelEntry[];
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Per-organization model catalog cache. Keyed by organizationId so that
|
||||
* multiple orgs in the same Silo don't cross-pollute. The cache is intentionally
|
||||
* process-local: it survives for the lifetime of the Hub process and is
|
||||
* invalidated by TTL, not by DB events. A cache miss re-fetches from the
|
||||
* provider API.
|
||||
*/
|
||||
export class ProviderModelCatalog {
|
||||
private readonly cache = new Map<string, CacheEntry>();
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly secrets: LocalSecretEnvelope,
|
||||
private readonly ttlMs: number = DEFAULT_TTL_MS,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
) {
|
||||
this.fetchImpl = fetchImpl;
|
||||
}
|
||||
|
||||
/**
|
||||
* List tool-capable models available to the organization's ACTIVE provider
|
||||
* connection. Returns cached results when fresh; otherwise fetches from the
|
||||
* provider API. Falls back to an empty list (not an error) when the org has
|
||||
* no ACTIVE provider — the admin sees the env-default model from the
|
||||
* separate env fallback in the route.
|
||||
*/
|
||||
async listModels(organizationId: string): Promise<readonly ProviderModelEntry[]> {
|
||||
const cached = this.cache.get(organizationId);
|
||||
if (cached !== undefined && cached.expiresAt > Date.now()) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
const credential = await this.resolveActiveProviderCredential(organizationId);
|
||||
if (credential === null) return [];
|
||||
|
||||
const models = await this.fetchModelsFromProvider(credential);
|
||||
this.cache.set(organizationId, {
|
||||
models,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
});
|
||||
return models;
|
||||
}
|
||||
|
||||
/** Force a cache invalidation (e.g. after provider connection changes). */
|
||||
invalidate(organizationId: string): void {
|
||||
this.cache.delete(organizationId);
|
||||
}
|
||||
|
||||
private async resolveActiveProviderCredential(
|
||||
organizationId: string,
|
||||
): Promise<{ readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string } | null> {
|
||||
const connection = await this.prisma.organizationProviderConnection.findFirst({
|
||||
where: { organizationId, status: "ACTIVE" },
|
||||
include: { activeSecretVersion: true },
|
||||
});
|
||||
const version = connection?.activeSecretVersion;
|
||||
if (connection === null || version === null || version === undefined || version.retiredAt !== null) {
|
||||
return null;
|
||||
}
|
||||
const payload = decryptStoredProviderCredential(this.secrets, {
|
||||
organizationId,
|
||||
connectionId: connection.id,
|
||||
providerId: connection.providerId,
|
||||
secretVersionId: version.id,
|
||||
envelopeVersion: version.envelopeVersion,
|
||||
keyId: version.keyId,
|
||||
envelope: version.envelope,
|
||||
});
|
||||
return {
|
||||
baseUrl: payload.baseUrl,
|
||||
authToken: payload.authToken,
|
||||
anthropicApiKey: payload.anthropicApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchModelsFromProvider(
|
||||
credential: { readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string },
|
||||
): Promise<readonly ProviderModelEntry[]> {
|
||||
const base = credential.baseUrl.endsWith("/") ? credential.baseUrl : `${credential.baseUrl}/`;
|
||||
// Filter to models that support tool calling — the agent runner requires it.
|
||||
const url = new URL("v1/models?supported_parameters=tools", base);
|
||||
const headers: Record<string, string> = {
|
||||
authorization: `Bearer ${credential.authToken}`,
|
||||
accept: "application/json",
|
||||
};
|
||||
if (credential.anthropicApiKey !== "") headers["x-api-key"] = credential.anthropicApiKey;
|
||||
|
||||
const response = await this.fetchImpl(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`provider models request failed: status ${response.status}`);
|
||||
}
|
||||
const body = (await response.json()) as OpenRouterModelsResponse;
|
||||
return body.data.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.name,
|
||||
toolCapable: m.supported_parameters.includes("tools"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,27 @@
|
||||
/**
|
||||
* Silo-wide HTTP request rate limit (ADR-0022 `requestRate`).
|
||||
*
|
||||
* Counts dynamic traffic only: APIs, auth, and other application handlers.
|
||||
* Static SPA assets and the org-admin HTML shell are exempt so a single page
|
||||
* load (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
|
||||
*/
|
||||
|
||||
/** Paths that must not consume the silo HTTP request-rate budget. */
|
||||
export function isSiloHttpRateLimitExempt(url: string): boolean {
|
||||
const path = (url.split("?", 1)[0] ?? url) || "/";
|
||||
|
||||
if (path === "/api/healthz") return true;
|
||||
|
||||
// SvelteKit build output and top-level static files (see admin/static.ts).
|
||||
if (path === "/_app" || path.startsWith("/_app/")) return true;
|
||||
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
|
||||
|
||||
// SPA index shell for client-side routes (not an API).
|
||||
if (path === "/admin" || path.startsWith("/admin/")) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export class SiloFixedWindowRateLimiter {
|
||||
private windowStartedAt: number;
|
||||
private used = 0;
|
||||
|
||||
@@ -42,6 +42,8 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
request_approval: "thumb-up-filled",
|
||||
feishu_read_context: "search-filled",
|
||||
feishu_download_resource: "download-filled",
|
||||
webfetch: "link-copy-filled",
|
||||
websearch: "search-filled",
|
||||
};
|
||||
|
||||
function toolIcon(toolName: string): string {
|
||||
|
||||
@@ -7,11 +7,16 @@ import { readFeishuContext } from "./read.js";
|
||||
import type { ApprovalManager } from "./approval.js";
|
||||
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
|
||||
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { createPdfToMdBundleAdapter } from "../capability/pdfToMdBundle.js";
|
||||
import { AliyunDocmindClient } from "../capability/docmindClient.js";
|
||||
|
||||
export interface FileDeliveryToolOptions {
|
||||
readonly rt: FeishuRuntime;
|
||||
readonly chatId: string;
|
||||
readonly projectId: string;
|
||||
readonly organizationId: string;
|
||||
readonly runId: string;
|
||||
readonly workspaceRoot?: string | undefined;
|
||||
readonly workspaceDir: string;
|
||||
@@ -20,6 +25,8 @@ export interface FileDeliveryToolOptions {
|
||||
readonly approvalManager: ApprovalManager;
|
||||
readonly onDelivered?: (path: string) => void;
|
||||
readonly tools?: readonly CphHubMcpToolId[] | undefined;
|
||||
readonly prisma: PrismaClient;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
}
|
||||
|
||||
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
|
||||
@@ -182,18 +189,18 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
tools.push(
|
||||
tool(
|
||||
"request_approval",
|
||||
"Send an interactive Feishu approval/confirmation card to the current chat and wait for a button click.",
|
||||
"Send an interactive Feishu approval/confirmation card in the current chat and wait for the user's button click.",
|
||||
{
|
||||
title: z.string().describe("Card title."),
|
||||
body: z.string().describe("Markdown card body explaining what needs approval or confirmation."),
|
||||
options: z
|
||||
.array(z.object({
|
||||
label: z.string().describe("Button label shown to the user."),
|
||||
value: z.string().describe("Action value returned to the agent when this button is clicked."),
|
||||
style: z.enum(["primary", "default", "danger"]).optional().describe("Optional Feishu button style."),
|
||||
value: z.string().describe("Stable option value returned after the user clicks."),
|
||||
style: z.enum(["default", "primary", "danger"]).optional(),
|
||||
}))
|
||||
.min(1)
|
||||
.describe("Button choices for the approval card."),
|
||||
.describe("One or more response options."),
|
||||
},
|
||||
async (args) => {
|
||||
const messageId = await sendApprovalCard(
|
||||
@@ -230,6 +237,51 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
);
|
||||
}
|
||||
|
||||
if (enabledTools.has("convert_pdf_to_md")) {
|
||||
const adapter = createPdfToMdBundleAdapter({
|
||||
secrets: options.secretEnvelope,
|
||||
client: new AliyunDocmindClient(),
|
||||
prisma: options.prisma,
|
||||
});
|
||||
tools.push(
|
||||
tool(
|
||||
"convert_pdf_to_md",
|
||||
"Convert a PDF file in the workspace to a Markdown bundle (markdown + extracted images) using Alibaba Cloud Document Mind. The PDF must already be in the workspace (use feishu_download_resource first if it came from Feishu). Returns the path to the generated markdown file and the list of extracted image paths. Mathematical formulas are converted to LaTeX.",
|
||||
{
|
||||
input_path: z.string().describe("Relative path to the input PDF within the workspace."),
|
||||
output_dir: z.string().describe("Relative directory within the workspace to write the markdown and images into. Will be created if it does not exist."),
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const result = await adapter.invoke({
|
||||
runId: options.runId,
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
workspaceDir: options.workspaceDir,
|
||||
inputPath: args.input_path,
|
||||
outputDir: args.output_dir,
|
||||
prisma: options.prisma,
|
||||
});
|
||||
const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`];
|
||||
for (const artifact of result.artifacts) {
|
||||
lines.push(` - ${artifact.path} (${artifact.kind})`);
|
||||
}
|
||||
lines.push(`Pages: ${result.consumption.quantity}, Cost: $${(result.consumption.costUsd ?? 0).toFixed(4)}`);
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
|
||||
};
|
||||
}
|
||||
},
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const instructions = mcpInstructions(enabledTools);
|
||||
return createSdkMcpServer({
|
||||
name: "cph_hub",
|
||||
@@ -260,5 +312,12 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
||||
if (enabledTools.has("request_approval")) {
|
||||
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
|
||||
}
|
||||
if (enabledTools.has("convert_pdf_to_md")) {
|
||||
instructions.push(
|
||||
"Use convert_pdf_to_md when the user asks to convert a PDF to Markdown.",
|
||||
"If the PDF came from a Feishu message, first use feishu_download_resource to save it to the workspace, then call convert_pdf_to_md.",
|
||||
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md for accurate text, formula, and image extraction.",
|
||||
);
|
||||
}
|
||||
return instructions.join(" ");
|
||||
}
|
||||
|
||||
@@ -92,7 +92,18 @@ export function createSlashCommandRegistry(
|
||||
finishedAt: { not: null },
|
||||
},
|
||||
orderBy: { finishedAt: "asc" },
|
||||
select: { model: true, provider: true, inputTokens: true, outputTokens: true, costUsd: true },
|
||||
select: {
|
||||
usageFacts: {
|
||||
select: {
|
||||
provider: true,
|
||||
model: true,
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
costUsd: true,
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions);
|
||||
},
|
||||
@@ -118,18 +129,24 @@ async function currentRoleSessionIds(
|
||||
return sessions.map((session) => session.id);
|
||||
}
|
||||
|
||||
interface UsageRun {
|
||||
readonly model: string;
|
||||
readonly provider: string;
|
||||
readonly inputTokens: number | null;
|
||||
readonly outputTokens: number | null;
|
||||
readonly costUsd: unknown;
|
||||
interface UsageRunWithFacts {
|
||||
readonly usageFacts: readonly {
|
||||
readonly provider: string;
|
||||
readonly model: string | null;
|
||||
readonly inputTokens: number | null;
|
||||
readonly outputTokens: number | null;
|
||||
readonly costUsd: unknown;
|
||||
}[];
|
||||
}
|
||||
|
||||
function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "project"): string {
|
||||
function formatUsageReport(runs: readonly UsageRunWithFacts[], scope: "current" | "project"): string {
|
||||
if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`;
|
||||
// Bucket by (fact.provider, fact.model) — ADR-0026: external capabilities
|
||||
// carry their own provider/model and contribute their own meter, so a run
|
||||
// with a main loop + an external call lands in two buckets. A run with no
|
||||
// cost-bearing fact is "unrecorded" (ADR-0022: missing cost ≠ zero).
|
||||
const buckets = new Map<string, {
|
||||
provider: string; model: string; runs: number; inputTokens: number; outputTokens: number; costUsd: number;
|
||||
provider: string; model: string; facts: number; inputTokens: number; outputTokens: number; costUsd: number;
|
||||
}>();
|
||||
let recordedRuns = 0;
|
||||
let unrecordedRuns = 0;
|
||||
@@ -137,21 +154,34 @@ function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "projec
|
||||
let totalOutputTokens = 0;
|
||||
let totalCostUsd = 0;
|
||||
for (const run of runs) {
|
||||
const costUsd = decimalToNumberOrNull(run.costUsd);
|
||||
if (costUsd === null) { unrecordedRuns++; continue; }
|
||||
const facts = run.usageFacts;
|
||||
if (facts.length === 0 || !facts.some((f) => f.costUsd !== null && f.costUsd !== undefined)) {
|
||||
unrecordedRuns++;
|
||||
// Tokens from unrecorded runs still count toward totals (matches pre-0026).
|
||||
for (const f of facts) {
|
||||
totalInputTokens += f.inputTokens ?? 0;
|
||||
totalOutputTokens += f.outputTokens ?? 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
recordedRuns++;
|
||||
const key = `${run.provider}\u0000${run.model}`;
|
||||
const bucket = buckets.get(key) ?? {
|
||||
provider: run.provider, model: run.model, runs: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
|
||||
};
|
||||
bucket.runs++;
|
||||
bucket.inputTokens += run.inputTokens ?? 0;
|
||||
bucket.outputTokens += run.outputTokens ?? 0;
|
||||
bucket.costUsd += costUsd;
|
||||
buckets.set(key, bucket);
|
||||
totalInputTokens += run.inputTokens ?? 0;
|
||||
totalOutputTokens += run.outputTokens ?? 0;
|
||||
totalCostUsd += costUsd;
|
||||
for (const f of facts) {
|
||||
const costUsd = decimalToNumberOrNull(f.costUsd);
|
||||
if (costUsd === null) continue;
|
||||
const model = f.model ?? "(unknown model)";
|
||||
const key = `${f.provider}\u0000${model}`;
|
||||
const bucket = buckets.get(key) ?? {
|
||||
provider: f.provider, model, facts: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
|
||||
};
|
||||
bucket.facts += 1;
|
||||
bucket.inputTokens += f.inputTokens ?? 0;
|
||||
bucket.outputTokens += f.outputTokens ?? 0;
|
||||
bucket.costUsd += costUsd;
|
||||
buckets.set(key, bucket);
|
||||
totalInputTokens += f.inputTokens ?? 0;
|
||||
totalOutputTokens += f.outputTokens ?? 0;
|
||||
totalCostUsd += costUsd;
|
||||
}
|
||||
}
|
||||
const lines = [
|
||||
`${scope === "current" ? "当前角色会话" : "当前项目"}用量`,
|
||||
@@ -160,7 +190,7 @@ function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "projec
|
||||
];
|
||||
if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`);
|
||||
for (const bucket of buckets.values()) {
|
||||
lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}`);
|
||||
lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.facts)} 次, ${formatUsd(bucket.costUsd)}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { join } from "node:path";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import {
|
||||
sendText,
|
||||
sendTextMessage,
|
||||
@@ -93,6 +94,7 @@ interface TriggerDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly settings: RuntimeSettings;
|
||||
readonly logger: FastifyBaseLogger;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
|
||||
readonly authorizer?: PermissionAuthorizer | undefined;
|
||||
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
|
||||
@@ -486,6 +488,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
// Streaming agent card: single interactive card through the full run
|
||||
// lifecycle (thinking → tool calls → streaming text → complete).
|
||||
// Shows tool-use trace panel + reasoning panel + answer text.
|
||||
const deliveredFiles: string[] = [];
|
||||
const card = new StreamingAgentCard({
|
||||
runId: run.id,
|
||||
rt,
|
||||
@@ -494,11 +497,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
patchIntervalMs: undefined,
|
||||
maxMessageLength: undefined,
|
||||
});
|
||||
const deliveredFiles: string[] = [];
|
||||
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
||||
rt,
|
||||
chatId,
|
||||
projectId,
|
||||
organizationId: siloOrganizationId,
|
||||
runId: run.id,
|
||||
workspaceRoot: projectWorkspaceRoot,
|
||||
workspaceDir: project.workspaceDir,
|
||||
@@ -506,6 +509,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
sendOptions,
|
||||
approvalManager,
|
||||
tools: cphHubMcpToolsForRole(roleTools),
|
||||
prisma: deps.prisma,
|
||||
secretEnvelope: deps.secretEnvelope,
|
||||
onDelivered: (path) => {
|
||||
deliveredFiles.push(path);
|
||||
},
|
||||
@@ -604,6 +609,32 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
data: { metadata: mergeSessionMetadata(session.metadata, metadataPatch) },
|
||||
});
|
||||
}
|
||||
// ADR-0026: the UsageFact ledger is the truth; AgentRun.costUsd /
|
||||
// inputTokens / outputTokens are a derived rollup cache for existing
|
||||
// readers. We write the fact first, then mirror it onto the run.
|
||||
// They are separate statements (not one transaction) so the AgentRun
|
||||
// row lock is held for the shortest possible window and concurrent
|
||||
// workspace teardown cannot deadlock on the FK ShareLock. A crash
|
||||
// between the two leaves the cache stale, but the cache is derived
|
||||
// (the usage service re-reads facts), so staleness is recoverable;
|
||||
// the reverse order would lose the truth and is unrecoverable.
|
||||
const finishedAt = new Date();
|
||||
const reportedCost = result.costUsd;
|
||||
const costSource = reportedCost !== undefined ? "provider_reported" : "unknown";
|
||||
await deps.prisma.usageFact.create({
|
||||
data: {
|
||||
runId: run.id,
|
||||
occurredAt: finishedAt,
|
||||
kind: "model_completion",
|
||||
provider: providerId,
|
||||
model,
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
costUsd: reportedCost ?? null,
|
||||
costSource,
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
await deps.prisma.agentRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
@@ -618,9 +649,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
: "FAILED",
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
...(result.costUsd !== undefined ? { costUsd: result.costUsd, costSource: "provider_reported" } : {}),
|
||||
costUsd: reportedCost ?? null,
|
||||
costSource,
|
||||
error: result.error ?? null,
|
||||
finishedAt: new Date(),
|
||||
finishedAt,
|
||||
},
|
||||
});
|
||||
await writeAudit(deps.prisma, {
|
||||
|
||||
+5
-2
@@ -14,7 +14,7 @@ import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuAppl
|
||||
import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js";
|
||||
import { readServerBinding } from "./settings/server.js";
|
||||
import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js";
|
||||
import { SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
|
||||
import { isSiloHttpRateLimitExempt, SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
@@ -42,7 +42,9 @@ export async function startHub(): Promise<void> {
|
||||
const app = Fastify({ logger: true, bodyLimit: httpBodyLimit });
|
||||
const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000);
|
||||
app.addHook("onRequest", async (request, reply) => {
|
||||
if (request.url === "/api/healthz") return;
|
||||
// Static SPA assets / admin HTML shell / healthz do not count toward the
|
||||
// silo requestRate budget (a full admin load can pull dozens of chunks).
|
||||
if (isSiloHttpRateLimitExempt(request.url)) return;
|
||||
const decision = requestLimiter.consume();
|
||||
if (!decision.allowed) {
|
||||
await reply
|
||||
@@ -160,6 +162,7 @@ export async function startHub(): Promise<void> {
|
||||
prisma,
|
||||
settings: runtimeSettings,
|
||||
logger: app.log,
|
||||
secretEnvelope,
|
||||
projectWorkspaceRoot,
|
||||
publicBaseUrl,
|
||||
siloOrganizationId: siloOrganization.id,
|
||||
|
||||
@@ -73,9 +73,28 @@ export async function getSessionDetail(
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
costUsd: true,
|
||||
costSource: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
error: true,
|
||||
usageFacts: {
|
||||
select: {
|
||||
id: true,
|
||||
occurredAt: true,
|
||||
kind: true,
|
||||
provider: true,
|
||||
model: true,
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
quantity: true,
|
||||
unit: true,
|
||||
costUsd: true,
|
||||
costSource: true,
|
||||
capabilityId: true,
|
||||
correlationId: true,
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
},
|
||||
},
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 100,
|
||||
@@ -106,9 +125,25 @@ export async function getSessionDetail(
|
||||
inputTokens: r.inputTokens,
|
||||
outputTokens: r.outputTokens,
|
||||
costUsd: r.costUsd === null ? null : Number(r.costUsd),
|
||||
costSource: r.costSource,
|
||||
startedAt: r.startedAt.toISOString(),
|
||||
finishedAt: r.finishedAt?.toISOString() ?? null,
|
||||
error: r.error,
|
||||
usageFacts: r.usageFacts.map((f) => ({
|
||||
id: f.id,
|
||||
occurredAt: f.occurredAt.toISOString(),
|
||||
kind: f.kind,
|
||||
provider: f.provider,
|
||||
model: f.model,
|
||||
inputTokens: f.inputTokens,
|
||||
outputTokens: f.outputTokens,
|
||||
quantity: f.quantity === null ? null : Number(f.quantity),
|
||||
unit: f.unit,
|
||||
costUsd: f.costUsd === null ? null : Number(f.costUsd),
|
||||
costSource: f.costSource,
|
||||
capabilityId: f.capabilityId,
|
||||
correlationId: f.correlationId,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
+87
-17
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* Org / project usage rollups from AgentRun cost facts (ADR-0021).
|
||||
* Org / project usage rollups from the UsageFact ledger (ADR-0021, ADR-0026).
|
||||
* Operational usage accounting, not payment collection. Organizations may use
|
||||
* either their own provider credentials or a distinct platform-managed
|
||||
* connection; commercial billing remains outside the pilot scope.
|
||||
*
|
||||
* The truth is in `UsageFact`; `AgentRun.costUsd / inputTokens / outputTokens`
|
||||
* are a derived rollup cache. This service reads `UsageFact` directly so that
|
||||
* external-capability consumption (PDF→MD, ASR, …) is attributed correctly,
|
||||
* not just the main model loop. A run with no cost-bearing fact is
|
||||
* `runsWithoutCost` — ADR-0022: missing cost ≠ zero.
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
|
||||
@@ -32,6 +38,53 @@ export interface UsageReport {
|
||||
};
|
||||
}
|
||||
|
||||
type FactRow = {
|
||||
readonly inputTokens: number | null;
|
||||
readonly outputTokens: number | null;
|
||||
readonly costUsd: unknown;
|
||||
};
|
||||
|
||||
type RunWithFacts = {
|
||||
readonly projectId: string;
|
||||
readonly usageFacts: readonly FactRow[];
|
||||
};
|
||||
|
||||
/** A run is "recorded with cost" if any of its facts carries a known costUsd. */
|
||||
function runHasRecordedCost(facts: readonly FactRow[]): boolean {
|
||||
return facts.some((f) => f.costUsd !== null && f.costUsd !== undefined);
|
||||
}
|
||||
|
||||
/** Decimal→number for Prisma Decimal; null/undefined → null. */
|
||||
function factCostUsdToNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
interface RunRollup {
|
||||
readonly hasCost: boolean;
|
||||
readonly inputTokens: number;
|
||||
readonly outputTokens: number;
|
||||
readonly costUsd: number | null;
|
||||
}
|
||||
|
||||
function rollupRun(facts: readonly FactRow[]): RunRollup {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let costUsd: number | null = null;
|
||||
let hasCost = false;
|
||||
for (const f of facts) {
|
||||
inputTokens += f.inputTokens ?? 0;
|
||||
outputTokens += f.outputTokens ?? 0;
|
||||
const c = factCostUsdToNumber(f.costUsd);
|
||||
if (c !== null) {
|
||||
hasCost = true;
|
||||
costUsd = (costUsd ?? 0) + c;
|
||||
}
|
||||
}
|
||||
return { hasCost, inputTokens, outputTokens, costUsd };
|
||||
}
|
||||
|
||||
export async function getOrgUsage(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
@@ -54,8 +107,9 @@ export async function getOrgUsage(
|
||||
return emptyReport(input.from, input.to);
|
||||
}
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
const runWhere: Prisma.AgentRunWhereInput = {
|
||||
projectId: { in: projects.map((p) => p.id) },
|
||||
projectId: { in: projectIds },
|
||||
...(input.from !== undefined || input.to !== undefined
|
||||
? {
|
||||
startedAt: {
|
||||
@@ -66,20 +120,25 @@ export async function getOrgUsage(
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Date range filters by run.startedAt, matching the pre-ADR-0026 semantics:
|
||||
// a run is in or out of the window based on when it started, and all of its
|
||||
// facts come along. Filtering facts by occurredAt independently would let a
|
||||
// run contribute partial cost to a window it doesn't belong to.
|
||||
const runs = await prisma.agentRun.findMany({
|
||||
where: runWhere,
|
||||
select: {
|
||||
projectId: true,
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
costUsd: true,
|
||||
usageFacts: {
|
||||
select: { inputTokens: true, outputTokens: true, costUsd: true },
|
||||
orderBy: { occurredAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type MutableRow = {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
folderId: string | null;
|
||||
readonly projectId: string;
|
||||
readonly projectName: string;
|
||||
readonly folderId: string | null;
|
||||
runCount: number;
|
||||
runsWithCost: number;
|
||||
runsWithoutCost: number;
|
||||
@@ -103,22 +162,33 @@ export async function getOrgUsage(
|
||||
});
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
for (const run of runs as readonly RunWithFacts[]) {
|
||||
const row = byProject.get(run.projectId);
|
||||
if (row === undefined) continue;
|
||||
const roll = rollupRun(run.usageFacts);
|
||||
row.runCount += 1;
|
||||
row.inputTokens += run.inputTokens ?? 0;
|
||||
row.outputTokens += run.outputTokens ?? 0;
|
||||
if (run.costUsd === null) {
|
||||
row.runsWithoutCost += 1;
|
||||
} else {
|
||||
if (roll.hasCost) {
|
||||
row.runsWithCost += 1;
|
||||
const cost = Number(run.costUsd);
|
||||
row.costUsd = (row.costUsd ?? 0) + cost;
|
||||
row.costUsd = (row.costUsd ?? 0) + (roll.costUsd ?? 0);
|
||||
} else {
|
||||
row.runsWithoutCost += 1;
|
||||
}
|
||||
row.inputTokens += roll.inputTokens;
|
||||
row.outputTokens += roll.outputTokens;
|
||||
}
|
||||
|
||||
const projectsOut: ProjectUsageRow[] = [...byProject.values()];
|
||||
const projectsOut: ProjectUsageRow[] = [...byProject.values()].map((r) => ({
|
||||
projectId: r.projectId,
|
||||
projectName: r.projectName,
|
||||
folderId: r.folderId,
|
||||
runCount: r.runCount,
|
||||
runsWithCost: r.runsWithCost,
|
||||
runsWithoutCost: r.runsWithoutCost,
|
||||
inputTokens: r.inputTokens,
|
||||
outputTokens: r.outputTokens,
|
||||
costUsd: r.costUsd,
|
||||
}));
|
||||
|
||||
let runCount = 0;
|
||||
let runsWithCost = 0;
|
||||
let runsWithoutCost = 0;
|
||||
|
||||
@@ -182,12 +182,16 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
|
||||
throw new Error(`organization ${project.organization.id} must have exactly one active default Agent role`);
|
||||
}
|
||||
|
||||
// The model list is the env-based fallback used when a role has no
|
||||
// explicit defaultModel. Role defaultModels are chosen from the provider
|
||||
// model catalog (admin surface) and may not be in the env list — we don't
|
||||
// validate against it at runtime; the admin already validated by selection.
|
||||
const defaults = await this.envSettings.modelRegistry(scope);
|
||||
const enabledModels = new Set(defaults.listModels().map((model) => model.id));
|
||||
const fallbackModels = defaults.listModels();
|
||||
const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({
|
||||
id: role.roleId,
|
||||
label: role.label,
|
||||
defaultModel: validateRoleModel(role.roleId, role.defaultModel, enabledModels),
|
||||
defaultModel: role.defaultModel ?? undefined,
|
||||
...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}),
|
||||
...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}),
|
||||
skills: role.skillBindings.map((binding): RoleSkillEntry => {
|
||||
@@ -210,7 +214,7 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
|
||||
};
|
||||
}),
|
||||
}));
|
||||
return new InMemoryModelRegistry(defaults.listModels(), roles);
|
||||
return new InMemoryModelRegistry(fallbackModels, roles);
|
||||
}
|
||||
|
||||
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
||||
@@ -225,15 +229,6 @@ function roleToolsFromJson(roleId: string, value: Prisma.JsonValue): readonly st
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function validateRoleModel(
|
||||
roleId: string,
|
||||
model: string | null,
|
||||
enabledModels: ReadonlySet<string>,
|
||||
): string | undefined {
|
||||
if (model === null) return undefined;
|
||||
if (!enabledModels.has(model)) throw new Error(`role ${roleId} selects unavailable model ${model}`);
|
||||
return model;
|
||||
}
|
||||
|
||||
async function loadActiveProviderSecret(
|
||||
tx: Prisma.TransactionClient,
|
||||
|
||||
@@ -346,14 +346,7 @@ describe("admin auth + org API guards", () => {
|
||||
headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
|
||||
});
|
||||
expect(defaultCallback.statusCode).toBe(302);
|
||||
expect(defaultCallback.headers.location).toBe(
|
||||
"/auth/feishu/complete?org=Test%20Default%20Organization",
|
||||
);
|
||||
const complete = await app.inject({ method: "GET", url: defaultCallback.headers.location! });
|
||||
expect(complete.statusCode).toBe(200);
|
||||
expect(complete.headers["content-type"]).toContain("text/html");
|
||||
expect(complete.body).toContain("登录并加入组织成功");
|
||||
expect(complete.body).toContain("Test Default Organization");
|
||||
expect(defaultCallback.headers.location).toBe("/admin/org/test-default");
|
||||
|
||||
await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
|
||||
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { prisma, resetDb, seedTestOrganization, testSecretEnvelope, DEFAULT_ORG_ID } from "./helpers.js";
|
||||
import { createPdfToMdBundleAdapter, CapabilityPathEscape } from "../../src/capability/pdfToMdBundle.js";
|
||||
import { DocmindClientError, type CapabilityProviderClient, type DocmindParseResult } from "../../src/capability/docmindClient.js";
|
||||
import type { CapabilitySecretPayload } from "../../src/capability/types.js";
|
||||
|
||||
const CAPABILITY_ID = "pdf_to_md_bundle";
|
||||
const PROVIDER_ID = "aliyun_docmind";
|
||||
|
||||
/**
|
||||
* ADR-0027: pdf_to_md_bundle adapter contract tests. Proves:
|
||||
* - workspace containment (input + output confined to run workspace)
|
||||
* - fail-closed credential resolution (no ACTIVE connection → error)
|
||||
* - UsageFact attribution with non-token meter (pages), costSource rules
|
||||
* - outputs (md + images) written into workspace
|
||||
* - docmind client errors propagate
|
||||
* The client is mocked; the prisma + envelope + filesystem are real.
|
||||
*/
|
||||
describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
|
||||
let workspaceRoot: string;
|
||||
let runId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
await seedTestOrganization();
|
||||
workspaceRoot = await mkdtemp(join(tmpdir(), "cph-cap-"));
|
||||
runId = "run-cap-test";
|
||||
// AgentRun is required for the UsageFact FK.
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: "proj-cap",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "Cap Test",
|
||||
workspaceDir: workspaceRoot,
|
||||
},
|
||||
});
|
||||
await prisma.agentRun.create({
|
||||
data: {
|
||||
id: runId,
|
||||
projectId: "proj-cap",
|
||||
entrypoint: "FEISHU",
|
||||
provider: "openrouter",
|
||||
model: "mock-model",
|
||||
status: "ACTIVE",
|
||||
prompt: "convert this pdf",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspaceRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedActiveCapabilityConnection(): Promise<void> {
|
||||
const payload: CapabilitySecretPayload = {
|
||||
schemaVersion: 1,
|
||||
accessKeyId: "LTAI-test-key-id",
|
||||
accessKeySecret: "test-secret-never-log",
|
||||
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
|
||||
};
|
||||
const connection = await prisma.organizationCapabilityConnection.create({
|
||||
data: {
|
||||
id: "cap-conn-1",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
capabilityId: CAPABILITY_ID,
|
||||
status: "ACTIVE",
|
||||
activatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const envelope = testSecretEnvelope.encryptJson(
|
||||
{
|
||||
purpose: "capability",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
connectionId: connection.id,
|
||||
secretVersionId: "cap-sv-1",
|
||||
},
|
||||
payload,
|
||||
);
|
||||
await prisma.capabilityCredentialVersion.create({
|
||||
data: {
|
||||
id: "cap-sv-1",
|
||||
connectionId: connection.id,
|
||||
version: 1,
|
||||
envelope: envelope as object,
|
||||
keyId: envelope.keyId,
|
||||
},
|
||||
});
|
||||
await prisma.organizationCapabilityConnection.update({
|
||||
where: { id: connection.id },
|
||||
data: { activeSecretVersionId: "cap-sv-1" },
|
||||
});
|
||||
}
|
||||
|
||||
function mockDocmindClient(result: Partial<DocmindParseResult> = {}): CapabilityProviderClient {
|
||||
const full: DocmindParseResult = {
|
||||
markdown: "# Parsed Document\n\nHello world.\n\n\n",
|
||||
images: [
|
||||
{ filename: "page_1.jpg", data: new Uint8Array([0xff, 0xd8, 0xff, 0xe0]) },
|
||||
],
|
||||
pageCount: 3,
|
||||
costUsd: 0.0168,
|
||||
requestId: "docmind-job-abc",
|
||||
...result,
|
||||
};
|
||||
return {
|
||||
parse: vi.fn(async (): Promise<DocmindParseResult> => full),
|
||||
};
|
||||
}
|
||||
|
||||
function makeAdapter(client: CapabilityProviderClient) {
|
||||
return createPdfToMdBundleAdapter({
|
||||
secrets: testSecretEnvelope,
|
||||
client,
|
||||
prisma,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedInputPdf(name: string = "input.pdf"): Promise<string> {
|
||||
const dir = join(workspaceRoot, "sources");
|
||||
await mkdir(dir, { recursive: true });
|
||||
const path = join(dir, name);
|
||||
await writeFile(path, "%PDF-1.4 fake pdf bytes");
|
||||
return join("sources", name);
|
||||
}
|
||||
|
||||
it("writes md + images into the workspace and records a UsageFact", async () => {
|
||||
await seedActiveCapabilityConnection();
|
||||
const inputPath = await seedInputPdf();
|
||||
const client = mockDocmindClient();
|
||||
const adapter = makeAdapter(client);
|
||||
|
||||
const result = await adapter.invoke({
|
||||
runId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
projectId: "proj-cap",
|
||||
workspaceDir: workspaceRoot,
|
||||
inputPath,
|
||||
outputDir: "output",
|
||||
prisma,
|
||||
});
|
||||
|
||||
// Outputs landed in workspace.
|
||||
const md = await readFile(join(workspaceRoot, "output", "document.md"), "utf8");
|
||||
expect(md).toContain("# Parsed Document");
|
||||
expect(result.artifacts.some((a) => a.kind === "markdown" && a.path === "output/document.md")).toBe(true);
|
||||
expect(result.artifacts.some((a) => a.kind === "image" && a.path === "output/page_1.jpg")).toBe(true);
|
||||
|
||||
// Client received the decrypted credential, not the env.
|
||||
const call = (client.parse as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as CapabilitySecretPayload;
|
||||
expect(call.accessKeyId).toBe("LTAI-test-key-id");
|
||||
expect(call.endpoint).toBe("docmind-api.cn-hangzhou.aliyuncs.com");
|
||||
|
||||
// UsageFact written with non-token meter and provider_reported cost.
|
||||
const facts = await prisma.usageFact.findMany({ where: { runId } });
|
||||
expect(facts).toHaveLength(1);
|
||||
const fact = facts[0]!;
|
||||
expect(fact.kind).toBe("external_capability");
|
||||
expect(fact.provider).toBe(PROVIDER_ID);
|
||||
expect(fact.capabilityId).toBe(CAPABILITY_ID);
|
||||
expect(Number(fact.quantity)).toBe(3);
|
||||
expect(fact.unit).toBe("pages");
|
||||
expect(Number(fact.costUsd!)).toBeCloseTo(0.0168, 8);
|
||||
expect(fact.costSource).toBe("provider_reported");
|
||||
expect(fact.correlationId).toBe("docmind-job-abc");
|
||||
});
|
||||
|
||||
it("writes UsageFact with costSource=unknown when docmind reports no cost", async () => {
|
||||
await seedActiveCapabilityConnection();
|
||||
const inputPath = await seedInputPdf();
|
||||
const client = mockDocmindClient({ costUsd: null, requestId: null });
|
||||
const adapter = makeAdapter(client);
|
||||
|
||||
await adapter.invoke({
|
||||
runId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
projectId: "proj-cap",
|
||||
workspaceDir: workspaceRoot,
|
||||
inputPath,
|
||||
outputDir: "output",
|
||||
prisma,
|
||||
});
|
||||
|
||||
const fact = (await prisma.usageFact.findFirstOrThrow({ where: { runId } }));
|
||||
expect(fact.costUsd).toBeNull();
|
||||
expect(fact.costSource).toBe("unknown");
|
||||
// quantity still recorded — missing cost ≠ zero consumption (ADR-0022).
|
||||
expect(Number(fact.quantity)).toBe(3);
|
||||
});
|
||||
|
||||
it("fails closed when the org has no ACTIVE capability connection", async () => {
|
||||
// No connection seeded.
|
||||
const inputPath = await seedInputPdf();
|
||||
const adapter = makeAdapter(mockDocmindClient());
|
||||
|
||||
await expect(adapter.invoke({
|
||||
runId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
projectId: "proj-cap",
|
||||
workspaceDir: workspaceRoot,
|
||||
inputPath,
|
||||
outputDir: "output",
|
||||
prisma,
|
||||
})).rejects.toThrow(/no ACTIVE capability connection/);
|
||||
|
||||
// No UsageFact written for a failed resolution.
|
||||
const facts = await prisma.usageFact.findMany({ where: { runId } });
|
||||
expect(facts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects an input path that escapes the workspace", async () => {
|
||||
await seedActiveCapabilityConnection();
|
||||
const adapter = makeAdapter(mockDocmindClient());
|
||||
|
||||
await expect(adapter.invoke({
|
||||
runId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
projectId: "proj-cap",
|
||||
workspaceDir: workspaceRoot,
|
||||
inputPath: "../../../etc/passwd",
|
||||
outputDir: "output",
|
||||
prisma,
|
||||
})).rejects.toBeInstanceOf(CapabilityPathEscape);
|
||||
});
|
||||
|
||||
it("rejects an output path that escapes the workspace", async () => {
|
||||
await seedActiveCapabilityConnection();
|
||||
const inputPath = await seedInputPdf();
|
||||
const adapter = makeAdapter(mockDocmindClient());
|
||||
|
||||
await expect(adapter.invoke({
|
||||
runId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
projectId: "proj-cap",
|
||||
workspaceDir: workspaceRoot,
|
||||
inputPath,
|
||||
outputDir: "../../outside",
|
||||
prisma,
|
||||
})).rejects.toBeInstanceOf(CapabilityPathEscape);
|
||||
});
|
||||
|
||||
it("propagates docmind client errors without writing a UsageFact", async () => {
|
||||
await seedActiveCapabilityConnection();
|
||||
const inputPath = await seedInputPdf();
|
||||
const client: CapabilityProviderClient = {
|
||||
parse: vi.fn(async (): Promise<DocmindParseResult> => {
|
||||
throw new DocmindClientError("upstream 502", "docmind_rejected", 502);
|
||||
}),
|
||||
};
|
||||
const adapter = makeAdapter(client);
|
||||
|
||||
await expect(adapter.invoke({
|
||||
runId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
projectId: "proj-cap",
|
||||
workspaceDir: workspaceRoot,
|
||||
inputPath,
|
||||
outputDir: "output",
|
||||
prisma,
|
||||
})).rejects.toThrow(/upstream 502/);
|
||||
|
||||
const facts = await prisma.usageFact.findMany({ where: { runId } });
|
||||
expect(facts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects empty markdown output as a parse failure", async () => {
|
||||
await seedActiveCapabilityConnection();
|
||||
const inputPath = await seedInputPdf();
|
||||
const client = mockDocmindClient({ markdown: "" });
|
||||
const adapter = makeAdapter(client);
|
||||
|
||||
await expect(adapter.invoke({
|
||||
runId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
projectId: "proj-cap",
|
||||
workspaceDir: workspaceRoot,
|
||||
inputPath,
|
||||
outputDir: "output",
|
||||
prisma,
|
||||
})).rejects.toThrow(/empty markdown/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { prisma, resetDb, seedTestOrganization, DEFAULT_ORG_ID } from "./helpers.js";
|
||||
import { getOrgUsage, getProjectUsage } from "../../src/org/usage.js";
|
||||
|
||||
/**
|
||||
* ADR-0026: org/project usage rolls up from the UsageFact ledger, not from the
|
||||
* AgentRun scalar cache. These tests pin the fact-based aggregation contract:
|
||||
* - a run with a cost-bearing fact → runsWithCost, costUsd summed
|
||||
* - a run whose facts all have null costUsd → runsWithoutCost (≠ zero)
|
||||
* - non-token meter (quantity/unit) is stored but does not corrupt token sums
|
||||
* - multiple facts per run (main loop + external capability) are summed together
|
||||
* - the AgentRun rollup cache is NOT consulted by getOrgUsage
|
||||
*/
|
||||
describe("usage rollup from UsageFact (ADR-0026)", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
await seedTestOrganization();
|
||||
});
|
||||
|
||||
async function seedRun(opts: {
|
||||
readonly projectId: string;
|
||||
readonly projectName: string;
|
||||
readonly folderId?: string;
|
||||
readonly facts: ReadonlyArray<{
|
||||
readonly kind?: string;
|
||||
readonly provider?: string;
|
||||
readonly model?: string;
|
||||
readonly inputTokens?: number;
|
||||
readonly outputTokens?: number;
|
||||
readonly costUsd?: number | null;
|
||||
readonly costSource?: string;
|
||||
readonly capabilityId?: string;
|
||||
readonly quantity?: number | null;
|
||||
readonly unit?: string | null;
|
||||
}>;
|
||||
}): Promise<void> {
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
id: opts.projectId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: opts.projectName,
|
||||
workspaceDir: `/tmp/test-${opts.projectId}`,
|
||||
...(opts.folderId !== undefined ? { folderId: opts.folderId } : {}),
|
||||
},
|
||||
});
|
||||
const startedAt = new Date("2026-07-18T10:00:00Z");
|
||||
const finishedAt = new Date("2026-07-18T10:05:00Z");
|
||||
const run = await prisma.agentRun.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
entrypoint: "FEISHU",
|
||||
provider: "openrouter",
|
||||
model: "mock-model",
|
||||
metadata: {},
|
||||
status: "COMPLETED",
|
||||
prompt: "test",
|
||||
startedAt,
|
||||
finishedAt,
|
||||
// Deliberately set the rollup cache to values that differ from the
|
||||
// facts, to prove getOrgUsage reads facts, not the cache.
|
||||
inputTokens: 999,
|
||||
outputTokens: 999,
|
||||
costUsd: 999,
|
||||
costSource: "provider_reported",
|
||||
},
|
||||
});
|
||||
for (const [i, f] of opts.facts.entries()) {
|
||||
await prisma.usageFact.create({
|
||||
data: {
|
||||
runId: run.id,
|
||||
occurredAt: new Date(startedAt.getTime() + i * 1000),
|
||||
kind: f.kind ?? "model_completion",
|
||||
provider: f.provider ?? "openrouter",
|
||||
model: f.model ?? "mock-model",
|
||||
inputTokens: f.inputTokens ?? null,
|
||||
outputTokens: f.outputTokens ?? null,
|
||||
costUsd: f.costUsd ?? null,
|
||||
costSource: f.costSource ?? (f.costUsd !== undefined && f.costUsd !== null ? "provider_reported" : "unknown"),
|
||||
capabilityId: f.capabilityId ?? null,
|
||||
quantity: f.quantity ?? null,
|
||||
unit: f.unit ?? null,
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("sums cost and tokens from facts, ignoring the AgentRun rollup cache", async () => {
|
||||
await seedRun({
|
||||
projectId: "proj-a",
|
||||
projectName: "A",
|
||||
facts: [{ inputTokens: 10, outputTokens: 5, costUsd: 0.0023 }],
|
||||
});
|
||||
await seedRun({
|
||||
projectId: "proj-b",
|
||||
projectName: "B",
|
||||
facts: [{ inputTokens: 20, outputTokens: 10, costUsd: 0.01 }],
|
||||
});
|
||||
|
||||
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
|
||||
|
||||
expect(report.totals.runCount).toBe(2);
|
||||
expect(report.totals.runsWithCost).toBe(2);
|
||||
expect(report.totals.runsWithoutCost).toBe(0);
|
||||
expect(report.totals.inputTokens).toBe(30);
|
||||
expect(report.totals.outputTokens).toBe(15);
|
||||
expect(report.totals.costUsd).toBeCloseTo(0.0123, 8);
|
||||
});
|
||||
|
||||
it("treats a run with only null-cost facts as runsWithoutCost, never zero", async () => {
|
||||
await seedRun({
|
||||
projectId: "proj-unknown",
|
||||
projectName: "Unknown",
|
||||
facts: [{ inputTokens: 7, outputTokens: 3, costUsd: null, costSource: "unknown" }],
|
||||
});
|
||||
await seedRun({
|
||||
projectId: "proj-known",
|
||||
projectName: "Known",
|
||||
facts: [{ inputTokens: 4, outputTokens: 2, costUsd: 0.05 }],
|
||||
});
|
||||
|
||||
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
|
||||
|
||||
expect(report.totals.runCount).toBe(2);
|
||||
expect(report.totals.runsWithCost).toBe(1);
|
||||
expect(report.totals.runsWithoutCost).toBe(1);
|
||||
// The unknown-cost run still contributes its tokens.
|
||||
expect(report.totals.inputTokens).toBe(11);
|
||||
expect(report.totals.outputTokens).toBe(5);
|
||||
// And its cost is NOT counted as zero — only the known cost is summed.
|
||||
expect(report.totals.costUsd).toBeCloseTo(0.05, 8);
|
||||
});
|
||||
|
||||
it("sums multiple facts per run (main loop + external capability)", async () => {
|
||||
await seedRun({
|
||||
projectId: "proj-multi",
|
||||
projectName: "Multi",
|
||||
facts: [
|
||||
{ kind: "model_completion", inputTokens: 100, outputTokens: 50, costUsd: 0.02 },
|
||||
{
|
||||
kind: "external_capability",
|
||||
provider: "mineru",
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
costUsd: 0.015,
|
||||
costSource: "provider_reported",
|
||||
capabilityId: "pdf_to_md_bundle",
|
||||
quantity: 12,
|
||||
unit: "pages",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
|
||||
expect(report.totals.runCount).toBe(1);
|
||||
expect(report.totals.runsWithCost).toBe(1);
|
||||
expect(report.totals.inputTokens).toBe(100);
|
||||
expect(report.totals.outputTokens).toBe(50);
|
||||
expect(report.totals.costUsd).toBeCloseTo(0.035, 8);
|
||||
});
|
||||
|
||||
it("a run with no facts at all is runsWithoutCost and contributes nothing", async () => {
|
||||
await seedRun({ projectId: "proj-empty", projectName: "Empty", facts: [] });
|
||||
|
||||
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
|
||||
expect(report.totals.runCount).toBe(1);
|
||||
expect(report.totals.runsWithCost).toBe(0);
|
||||
expect(report.totals.runsWithoutCost).toBe(1);
|
||||
expect(report.totals.inputTokens).toBe(0);
|
||||
expect(report.totals.outputTokens).toBe(0);
|
||||
expect(report.totals.costUsd).toBeNull();
|
||||
});
|
||||
|
||||
it("getProjectUsage returns just that project's row", async () => {
|
||||
await seedRun({ projectId: "proj-x", projectName: "X", facts: [{ costUsd: 0.1, inputTokens: 1, outputTokens: 1 }] });
|
||||
await seedRun({ projectId: "proj-y", projectName: "Y", facts: [{ costUsd: 0.2, inputTokens: 2, outputTokens: 2 }] });
|
||||
|
||||
const row = await getProjectUsage(prisma, { organizationId: DEFAULT_ORG_ID, projectId: "proj-x" });
|
||||
expect(row.projectId).toBe("proj-x");
|
||||
expect(row.runCount).toBe(1);
|
||||
expect(row.costUsd).toBeCloseTo(0.1, 8);
|
||||
});
|
||||
|
||||
it("returns an empty report for an org with no projects", async () => {
|
||||
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
|
||||
expect(report.projects).toHaveLength(0);
|
||||
expect(report.totals.runCount).toBe(0);
|
||||
expect(report.totals.costUsd).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -290,6 +290,9 @@ function mockPrisma(): PrismaClient {
|
||||
findUnique: vi.fn(async () => null),
|
||||
update: vi.fn(async () => ({ id: "run-1" })),
|
||||
},
|
||||
usageFact: {
|
||||
create: vi.fn(async () => ({ id: "fact-1" })),
|
||||
},
|
||||
projectAgentLock: {
|
||||
findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })),
|
||||
create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { ProviderModelCatalog, type ProviderModelEntry } from "../../src/connections/providerModelCatalog.js";
|
||||
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
|
||||
const TEST_KEY = Buffer.alloc(32, "k");
|
||||
const secrets = new LocalSecretEnvelope({
|
||||
activeKeyId: "test-active",
|
||||
keys: new Map([["test-active", TEST_KEY]]),
|
||||
});
|
||||
|
||||
function makeEncryptedPayload(baseUrl: string, authToken: string, anthropicApiKey = "") {
|
||||
const payload = { schemaVersion: 1, baseUrl, authToken, anthropicApiKey };
|
||||
return secrets.encryptJson({ purpose: "provider-connection", organizationId: "org-1", connectionId: "conn-1", secretVersionId: "sv-1" }, payload);
|
||||
}
|
||||
|
||||
function mockPrismaWithConnection(overrides: Partial<{
|
||||
status: string;
|
||||
retiredAt: Date | null;
|
||||
providerId: string;
|
||||
}> = {}) {
|
||||
const envelope = makeEncryptedPayload("https://openrouter.ai/api", "sk-test-key", "sk-ant-test");
|
||||
return {
|
||||
organizationProviderConnection: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "conn-1",
|
||||
organizationId: "org-1",
|
||||
providerId: overrides.providerId ?? "openrouter",
|
||||
status: overrides.status ?? "ACTIVE",
|
||||
activeSecretVersion: {
|
||||
id: "sv-1",
|
||||
connectionId: "conn-1",
|
||||
envelopeVersion: 1,
|
||||
keyId: "test-active",
|
||||
envelope,
|
||||
retiredAt: overrides.retiredAt ?? null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetchResponse(models: Array<{ id: string; name: string; supported_parameters: string[] }>) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ data: models }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProviderModelCatalog", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("fetches tool-capable models from the provider API", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = mockFetchResponse([
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools", "temperature"] },
|
||||
{ id: "openai/gpt-4o", name: "GPT-4o", supported_parameters: ["tools", "temperature"] },
|
||||
{ id: "meta/llama-3", name: "Llama 3", supported_parameters: ["temperature"] },
|
||||
]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
const models = await catalog.listModels("org-1");
|
||||
|
||||
expect(models).toHaveLength(3);
|
||||
expect(models.map((m: ProviderModelEntry) => m.id)).toEqual([
|
||||
"anthropic/claude-sonnet-5",
|
||||
"openai/gpt-4o",
|
||||
"meta/llama-3",
|
||||
]);
|
||||
expect(models[0]).toMatchObject({ label: "Claude Sonnet 5", toolCapable: true });
|
||||
expect(models[2]).toMatchObject({ label: "Llama 3", toolCapable: false });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const calledUrl = String(fetchImpl.mock.calls[0][0]);
|
||||
expect(calledUrl).toContain("supported_parameters=tools");
|
||||
});
|
||||
|
||||
it("returns cached results on subsequent calls within TTL", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = mockFetchResponse([
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||
]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
await catalog.listModels("org-1");
|
||||
await catalog.listModels("org-1");
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-fetches after TTL expires", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = mockFetchResponse([
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||
]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
await catalog.listModels("org-1");
|
||||
vi.advanceTimersByTime(61_000);
|
||||
await catalog.listModels("org-1");
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns empty list when no ACTIVE provider connection exists", async () => {
|
||||
const prisma = {
|
||||
organizationProviderConnection: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
const fetchImpl = mockFetchResponse([]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
const models = await catalog.listModels("org-1");
|
||||
|
||||
expect(models).toEqual([]);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalidate forces a re-fetch on next call", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = mockFetchResponse([
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||
]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
await catalog.listModels("org-1");
|
||||
catalog.invalidate("org-1");
|
||||
await catalog.listModels("org-1");
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("sends auth token and anthropic API key in headers", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = mockFetchResponse([
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||
]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
await catalog.listModels("org-1");
|
||||
|
||||
const init = fetchImpl.mock.calls[0][1] as RequestInit;
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.authorization).toBe("Bearer sk-test-key");
|
||||
expect(headers["x-api-key"]).toBe("sk-ant-test");
|
||||
});
|
||||
|
||||
it("throws on non-200 response", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 401 });
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
await expect(catalog.listModels("org-1")).rejects.toThrow("provider models request failed: status 401");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SiloFixedWindowRateLimiter } from "../../src/deployment/siloRateLimit.js";
|
||||
import {
|
||||
isSiloHttpRateLimitExempt,
|
||||
SiloFixedWindowRateLimiter,
|
||||
} from "../../src/deployment/siloRateLimit.js";
|
||||
|
||||
describe("Silo fixed-window rate limiter", () => {
|
||||
it("returns an explicit retry interval and resets at the next window", () => {
|
||||
@@ -10,3 +13,24 @@ describe("Silo fixed-window rate limiter", () => {
|
||||
expect(limiter.consume(61_000)).toEqual({ allowed: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSiloHttpRateLimitExempt", () => {
|
||||
it("exempts healthz, SPA static assets, and the admin HTML shell", () => {
|
||||
expect(isSiloHttpRateLimitExempt("/api/healthz")).toBe(true);
|
||||
expect(isSiloHttpRateLimitExempt("/_app/version.json")).toBe(true);
|
||||
expect(isSiloHttpRateLimitExempt("/_app/immutable/chunks/foo.js?v=1")).toBe(true);
|
||||
expect(isSiloHttpRateLimitExempt("/favicon.ico")).toBe(true);
|
||||
expect(isSiloHttpRateLimitExempt("/favicon.svg")).toBe(true);
|
||||
expect(isSiloHttpRateLimitExempt("/robots.txt")).toBe(true);
|
||||
expect(isSiloHttpRateLimitExempt("/admin")).toBe(true);
|
||||
expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true);
|
||||
});
|
||||
|
||||
it("still rate-limits APIs and auth", () => {
|
||||
expect(isSiloHttpRateLimitExempt("/api/me")).toBe(false);
|
||||
expect(isSiloHttpRateLimitExempt("/api/org/x/members")).toBe(false);
|
||||
expect(isSiloHttpRateLimitExempt("/auth/feishu/x")).toBe(false);
|
||||
expect(isSiloHttpRateLimitExempt("/auth/feishu/callback?code=1")).toBe(false);
|
||||
expect(isSiloHttpRateLimitExempt("/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promis
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { importSkillDirectory, prepareRunSkillPlugin } from "../../src/agent/skillStore.js";
|
||||
import {
|
||||
importSkillDirectory,
|
||||
importSkillFromFiles,
|
||||
prepareRunSkillPlugin,
|
||||
readSkillFiles,
|
||||
} from "../../src/agent/skillStore.js";
|
||||
|
||||
describe("content-addressed Agent skill store", () => {
|
||||
const roots: string[] = [];
|
||||
@@ -55,6 +60,47 @@ describe("content-addressed Agent skill store", () => {
|
||||
})).rejects.toThrow(/content digest mismatch/);
|
||||
});
|
||||
|
||||
it("imports a skill from an in-memory file list and reads it back", async () => {
|
||||
const root = await makeRoot();
|
||||
const storeRoot = join(root, "store");
|
||||
const manifest = `---\nname: web-skill\ndescription: Web uploaded\n---\n# web-skill\n`;
|
||||
const files = [
|
||||
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||
{ path: "reference.md", bytes: Buffer.from("ref\n", "utf8") },
|
||||
];
|
||||
const installed = await importSkillFromFiles({ files, storeRoot });
|
||||
expect(installed).toMatchObject({ name: "web-skill", description: "Web uploaded" });
|
||||
expect(installed.contentDigest).toMatch(/^[a-f0-9]{64}$/);
|
||||
|
||||
const readBack = await readSkillFiles({ storeRoot, contentDigest: installed.contentDigest });
|
||||
expect(readBack).toHaveLength(2);
|
||||
const byPath = Object.fromEntries(readBack.map((f) => [f.path, f.content]));
|
||||
expect(byPath["SKILL.md"]).toContain("name: web-skill");
|
||||
expect(byPath["reference.md"]).toBe("ref\n");
|
||||
});
|
||||
|
||||
it("rejects file paths that escape the skill root", async () => {
|
||||
const root = await makeRoot();
|
||||
const storeRoot = join(root, "store");
|
||||
const manifest = `---\nname: escape\ndescription: test\n---\n# escape\n`;
|
||||
const files = [
|
||||
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||
{ path: "../escape.md", bytes: Buffer.from("x", "utf8") },
|
||||
];
|
||||
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/must not escape/);
|
||||
});
|
||||
|
||||
it("rejects duplicate file paths in importSkillFromFiles", async () => {
|
||||
const root = await makeRoot();
|
||||
const storeRoot = join(root, "store");
|
||||
const manifest = `---\nname: dup\ndescription: test\n---\n# dup\n`;
|
||||
const files = [
|
||||
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||
];
|
||||
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/duplicate skill file path/);
|
||||
});
|
||||
|
||||
async function makeRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-skill-store-"));
|
||||
roots.push(root);
|
||||
|
||||
@@ -10,6 +10,8 @@ import Spec.System.Agent.Run
|
||||
import Spec.System.Agent.AgentRole
|
||||
import Spec.System.Agent.Memory
|
||||
import Spec.System.Agent.AgentSurface
|
||||
import Spec.System.Agent.Usage
|
||||
import Spec.System.Agent.Capability
|
||||
import Spec.System.Lock
|
||||
import Spec.System.Permission
|
||||
import Spec.System.PermissionGrant
|
||||
@@ -39,11 +41,17 @@ likec4 已画出结构;这里补语义:
|
||||
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权。
|
||||
- `AgentSurface` —— agent 执行面被 run 的工作区所界定(ADR-0018);与 Lock 正交——
|
||||
Lock 限定并发,Surface 限定波及面。机制 OPEN。
|
||||
- `Usage` —— Run 内 append-only 用量计量事实账本(ADR-0026);主模型 completion、
|
||||
外部能力调用、代理网关旁路共用同一账本。fact 不持锁、不跨 run;`costUsd = none`
|
||||
表示未知而非零(ADR-0022)。Run 上的 cost/token 标量是其 rollup cache。
|
||||
- `Capability` —— 外部能力(PDF→MD、音视频→文本等)注册与调用(ADR-0027);
|
||||
org-scoped 凭证连接复用 ADR-0024 信封但独立于 model-provider;调用是 Run 内副作用,
|
||||
产物落 workspace,消耗记 UsageFact。capability credential 不进 Agent 进程。
|
||||
- `Permission` —— read⊂edit⊂manage 角色体系、能力推导、单调性;force-release 在格外。
|
||||
- `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)
|
||||
(ADR-0004);组合规则 OPEN。
|
||||
- `Audit` —— customer Project/Run 审计从简(内容 OPEN);Platform Audit 由
|
||||
`PlatformAdministration` 独立承载。
|
||||
|
||||
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0024。
|
||||
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0027。
|
||||
-/
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import Spec.Prelude
|
||||
import Spec.System.Agent.Run
|
||||
import Spec.System.Agent.Usage
|
||||
|
||||
/-!
|
||||
# Capability —— 外部能力注册与调用(ADR-0027)
|
||||
|
||||
`AgentRun` 内可能调用**外部能力**:PDF→MD bundle、音视频→文本、OCR 等。这些能力
|
||||
不是主 agent loop 的模型 completion,不持项目锁,不占 session 语义(ADR-0026 已拒绝
|
||||
nested Run)。它们是 Run 内的副作用:读 workspace 输入、调外部服务、产物落 workspace、
|
||||
写一条 `UsageFact`。
|
||||
|
||||
本模块 pin 三件事:
|
||||
|
||||
1. **ExternalCapability** —— 平台注册的、org 启用的文档/媒体转换服务,由稳定
|
||||
`capabilityId` 标识。
|
||||
2. **CapabilityConnection** —— org-scoped 凭证连接,复用 ADR-0024 信封机制但独立于
|
||||
model-provider connection。只有 `active` connection 可被 resolver 使用。
|
||||
3. **CapabilityInvocation 良构** —— 调用的输入/输出必须落在 run 的 workspace 内
|
||||
(ADR-0018 `AgentSurface`),且调用产生的消耗必须以 `UsageFact` 记录。
|
||||
|
||||
凭证不经 Agent 子进程:Agent 只通过 capability adapter 间接调用,adapter 在 Hub 侧
|
||||
解析 org-scoped 凭证并调用外部服务(ADR-0024 resolver boundary)。这与 model-provider
|
||||
的 loopback proxy 是不同的机制——capability 调用不走 agent 的网络面,走 Hub 直连。
|
||||
|
||||
不变式(`PINNED`, ADR-0027):
|
||||
|
||||
- **凭证隔离** —— capability credential 不进 Agent 进程环境;adapter 在 Hub 侧解析。
|
||||
- **workspace 边界** —— 输入路径与输出目录都必须落在 run 的 workspace 内(ADR-0018)。
|
||||
- **必记 fact** —— 一次成功调用必须产生 ≥1 条 `UsageFact`(`kind = externalCapability`),
|
||||
即使 `costUsd = none`(unknown ≠ 零,ADR-0022/0026)。
|
||||
|
||||
`CapabilityInvocation` 作为一等持久记录(status/retries/partial output)在 pilot 不做;
|
||||
`UsageFact` 的 `capabilityId + correlationId` 是唯一可追溯痕迹(ADR-0027 Deferred)。
|
||||
-/
|
||||
|
||||
namespace Spec.System.Agent
|
||||
|
||||
variable (I : Identifiers) (Path : Type)
|
||||
|
||||
/-- 外部能力的输入种类(`PINNED`, ADR-0027)。集合 `OPEN`——新增 kind 须 surface。 -/
|
||||
inductive CapabilityInputKind where
|
||||
/-- PDF 文档(`PINNED`)。 -/
|
||||
| pdf
|
||||
/-- 图片(`PINNED`)。 -/
|
||||
| image
|
||||
/-- 音频(`PINNED`)。 -/
|
||||
| audio
|
||||
/-- 视频(`PINNED`)。 -/
|
||||
| video
|
||||
|
||||
/-- 外部能力(`PINNED`, ADR-0027)。平台注册的文档/媒体转换服务,由 org 启用。
|
||||
|
||||
一个 capability 由稳定 `capabilityId` 标识(如 `pdf_to_md_bundle`),声明接受的输入
|
||||
种类与计量单位。org 通过 `CapabilityConnection` 启用它;adapter 是平台侧实现。 -/
|
||||
structure ExternalCapability where
|
||||
/-- 稳定能力标识(`PINNED`;如 `pdf_to_md_bundle`、`audio_video_to_text`)。 -/
|
||||
capabilityId : String
|
||||
/-- 接受的输入种类(`PINNED`, ADR-0027)。 -/
|
||||
inputKind : CapabilityInputKind
|
||||
/-- 计量单位(`OPEN`;如 `"pages"`、`"audio_seconds"`)。与 UsageFact.unit 对齐。 -/
|
||||
meteringUnit : String
|
||||
|
||||
/-- capability connection 的运行态(`PINNED`, ADR-0024/0027):与 model-provider /
|
||||
Feishu connection 同构,只有 `active` 可被 resolver 使用。 -/
|
||||
inductive CapabilityConnectionStatus where
|
||||
| draft
|
||||
| active
|
||||
| disabled
|
||||
|
||||
/-- Organization 的外部能力凭证连接(`PINNED`, ADR-0027)。复用 ADR-0024 信封机制
|
||||
但独立于 model-provider connection:capability 服务(如 MinerU、Whisper)有自己的 auth
|
||||
形状与 readiness probe,不走 OpenRouter `/v1/models`。唯一性键为
|
||||
`(organization, capabilityId)`。 -/
|
||||
structure OrganizationCapabilityConnection where
|
||||
/-- connection 所属 organization(`PINNED`, ADR-0027)。 -/
|
||||
organization : I.OrganizationId
|
||||
/-- 该 connection 启用的 capability(`PINNED`, ADR-0027)。 -/
|
||||
capability : ExternalCapability
|
||||
/-- 运行态(`PINNED`, ADR-0024/0027)。 -/
|
||||
status : CapabilityConnectionStatus
|
||||
|
||||
/-- 一次 capability 调用的良构约束(`PINNED`, ADR-0027)。输入路径与输出目录都必须落在
|
||||
发起 run 的 workspace 内(ADR-0018 `AgentSurface`);`runWorkspace` 与 `pathWithin`
|
||||
由平台提供(表示 `OPEN`)。逃逸即越权,拒绝。 -/
|
||||
structure CapabilityInvocation where
|
||||
/-- 发起调用的 run(`PINNED`;调用是 Run 内副作用,不跨 run,ADR-0026/0027)。 -/
|
||||
run : I.RunId
|
||||
/-- 被调用的 capability(`PINNED`)。 -/
|
||||
capability : ExternalCapability
|
||||
/-- 输入路径(workspace 内,`PINNED`, ADR-0018)。 -/
|
||||
inputPath : Path
|
||||
/-- 输出目录(workspace 内,`PINNED`, ADR-0018)。 -/
|
||||
outputDir : Path
|
||||
|
||||
/-- capability 调用良构:输入与输出路径都落在 run 的 workspace 内(`PINNED`,
|
||||
ADR-0018/0027)。这是 `AgentFileOp.Authorized` 在 capability 调用上的对应物。 -/
|
||||
def CapabilityInvocation.Authorized
|
||||
(inv : CapabilityInvocation I Path)
|
||||
(runWorkspace : I.RunId → Option Path)
|
||||
(pathWithin : Path → Path → Prop) : Prop :=
|
||||
∃ w, runWorkspace inv.run = some w ∧ pathWithin inv.inputPath w ∧ pathWithin inv.outputDir w
|
||||
|
||||
end Spec.System.Agent
|
||||
@@ -0,0 +1,92 @@
|
||||
import Spec.Prelude
|
||||
|
||||
/-!
|
||||
# Usage —— Run 内用量计量事实账本(ADR-0026)
|
||||
|
||||
一次 `AgentRun` 内可能产生**多条**可计费消耗:主模型 completion、外部能力调用
|
||||
(PDF→MD bundle、音视频转写等)、或代理网关侧旁路调用。这些消耗**不**是子 Run——
|
||||
它们不持项目锁、不占 session 语义、不复用 `AgentRun` 状态机。它们是 Run 内的
|
||||
append-only **计量事实**(`UsageFact`)。
|
||||
|
||||
`AgentRun` 上的 cost/token 标量是 facts 的派生 rollup cache,不是真相来源;真相在
|
||||
`UsageFact` 行。这一层抽象让"主模型"与"外部能力"两类消耗共享同一账本,而非给后者
|
||||
各开一种特化字段或 nested Run。
|
||||
|
||||
核心不变式(`PINNED`, ADR-0022 + ADR-0026):
|
||||
|
||||
- **append-only** —— fact 一旦写入只读不更不删;`costUsd` 修正走新 fact,不改旧行。
|
||||
- **`costUsd = none` 表示"未知",不是"零成本"** —— ADR-0022:missing provider cost
|
||||
remains unknown rather than zero。聚合时不得把 none 当 0 求和。
|
||||
- **fact 不持锁、不跨 run** —— 它是 Run 内的副作用账本,不是又一次 `@bot` 生命周期。
|
||||
外部能力调用亦同:它的语义边界是 `capabilityId` + `correlationId`,不是 `RunId`。
|
||||
- **价目回算是派生** —— `costSource = pricebookDerived` 时,`costUsd` 由
|
||||
`occurredAt × (provider, model)` 查价目表派生;provider 实报(`providerReported`)
|
||||
优先于派生。无价目可查时 `costSource = unknown`,`costUsd = none`。
|
||||
|
||||
外部能力(capability)注册表、价目表(pricebook)、商业结算均 `OPEN`,pilot 不做
|
||||
(ADR-0021 仍 defer commercial billing)。本模块只 pin **账本结构与不变式**。
|
||||
-/
|
||||
|
||||
namespace Spec.System.Agent
|
||||
|
||||
variable (I : Identifiers) (Time Cost Quantity : Type)
|
||||
|
||||
/-- 计量事实类型(`PINNED`, ADR-0026)。集合完整性 `OPEN`——新增 kind 须 surface,
|
||||
不得静默把外部消耗塞进既有 kind。 -/
|
||||
inductive UsageFactKind where
|
||||
/-- 主 agent loop 的模型 completion(`PINNED`)。 -/
|
||||
| modelCompletion
|
||||
/-- 外部能力调用(`PINNED`;如 pdf_to_md_bundle、audio_video_to_text)。 -/
|
||||
| externalCapability
|
||||
/-- 代理网关侧旁路调用(`PINNED`;非主 loop 的模型调用,如嵌入工具内的子请求)。 -/
|
||||
| toolProxy
|
||||
|
||||
/-- 成本来源(`PINNED`, ADR-0026)。优先级:provider 实报 > 价目派生 > unknown。 -/
|
||||
inductive CostSource where
|
||||
/-- provider/gateway 实报(`PINNED`)。 -/
|
||||
| providerReported
|
||||
/-- 用 `occurredAt × (provider, model)` 价目表派生(`PINNED`)。 -/
|
||||
| pricebookDerived
|
||||
/-- 缺失,而非零(`PINNED`;ADR-0022 missing cost ≠ zero)。 -/
|
||||
| unknown
|
||||
|
||||
/-- 一次可计费消耗的计量事实(`PINNED`, ADR-0026)。append-only;一次 run ≥0 条。
|
||||
|
||||
字段分两组:**归属与时间**(run/occurredAt/kind)用于聚合与价目回算;
|
||||
**计量与成本**(tokens/quantity/unit/costUsd/costSource)用于账目本身。
|
||||
非 token 计量(页/秒/次)走 `quantity + unit`,与 token 并存而非取代。 -/
|
||||
structure UsageFact where
|
||||
/-- 所属 run(`PINNED`;fact 不跨 run,fact 不持锁,ADR-0026)。 -/
|
||||
run : I.RunId
|
||||
/-- 消耗发生时刻(`PINNED`);价目回算依赖此字段而非 run.finishedAt,
|
||||
因为外部能力可能早于 run 结束。 -/
|
||||
occurredAt : Time
|
||||
/-- 事实类型(`PINNED`, ADR-0026)。 -/
|
||||
kind : UsageFactKind
|
||||
/-- provider 标识(`PINNED`;如 openrouter、mineru、openai_whisper)。 -/
|
||||
provider : String
|
||||
/-- 模型标识(`OPEN`;非模型计费可为 none)。 -/
|
||||
model : Option String
|
||||
/-- 输入 tokens(`OPEN`;非 token 计量可为 none)。 -/
|
||||
inputTokens : Option Nat
|
||||
/-- 输出 tokens(`OPEN`)。 -/
|
||||
outputTokens : Option Nat
|
||||
/-- 非 token 计量数值(`OPEN`;如页数、音频秒数)。与 tokens 并存。 -/
|
||||
quantity : Option Quantity
|
||||
/-- 计量单位(`OPEN`;如 "pages"、"audio_seconds"、"invocations")。 -/
|
||||
unit : Option String
|
||||
/-- 成本(`PINNED`;none = 未知,非零,ADR-0022)。 -/
|
||||
costUsd : Option Cost
|
||||
/-- 成本来源(`PINNED`;costUsd ≠ none 时必填,costUsd = none 时为 `unknown`)。 -/
|
||||
costSource : CostSource
|
||||
/-- 外部能力标识(`OPEN`;kind = externalCapability 时填,如 pdf_to_md_bundle)。 -/
|
||||
capabilityId : Option String
|
||||
/-- 外部请求关联 id(`OPEN`;对账/幂等用,不参与聚合)。 -/
|
||||
correlationId : Option String
|
||||
|
||||
/-- Fact 的成本是否**已知**(`PINNED`, ADR-0026)。聚合 rollup 时,只对已知成本求和;
|
||||
未知成本的 fact 不贡献 0,而是让汇总保持未知(若所有 fact 均未知)或部分未知。 -/
|
||||
def UsageFact.HasKnownCost (fact : UsageFact I Time Cost Quantity) : Prop :=
|
||||
fact.costUsd ≠ none
|
||||
|
||||
end Spec.System.Agent
|
||||
Reference in New Issue
Block a user