@nauticana/sail 1.1.35 → 1.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/package.json +1 -1
- package/src/agent/component/approval_inbox.html +69 -45
- package/src/agent/component/approval_inbox.ts +186 -42
- package/src/agent/component/assistant_activity.html +3 -3
- package/src/agent/component/assistant_activity.ts +4 -4
- package/src/agent/component/studio_page.ts +2 -2
- package/src/agent/component/turn_thread.html +2 -2
- package/src/agent/component/turn_thread.ts +16 -5
- package/src/agent/model/agent_studio.ts +6 -3
- package/src/agent/model/approval.ts +9 -1
- package/src/agent/model/conversation.ts +4 -1
- package/src/agent/model/decision.ts +8 -0
- package/src/agent/service/approval.service.ts +3 -3
- package/src/component/agency/agency_accept.ts +2 -8
- package/src/component/oauth_connection/oauth_connection_panel.ts +2 -1
- package/src/component/security/api_keys.html +15 -8
- package/src/component/security/api_keys.ts +13 -7
- package/src/model/access.ts +9 -0
- package/src/service/access.service.ts +3 -4
- package/src/service/auth.service.ts +2 -1
- package/src/util/errors.ts +5 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
|
|
4
4
|
|
|
5
|
-
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.35 adopts keel v1.2.112 (minor-unit money, the `currency` catalog, OAuth grant routes), sail v1.1.33 adopts keel v1.2.106 (`prompt` on the login page, new-device step-up, sign-in sessions), sail v1.1.32 adopts keel v1.2.101 (the OAuth connect callback ticket), sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
|
|
5
|
+
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.36 adopts keel v1.2.113 (API key expiry, caller-aware scope policy), sail v1.1.35 adopts keel v1.2.112 (minor-unit money, the `currency` catalog, OAuth grant routes), sail v1.1.33 adopts keel v1.2.106 (`prompt` on the login page, new-device step-up, sign-in sessions), sail v1.1.32 adopts keel v1.2.101 (the OAuth connect callback ticket), sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
|
|
6
6
|
|
|
7
7
|
**Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
|
|
8
8
|
|
|
@@ -414,7 +414,7 @@ The login flow redirects `twoFactorRequired` responses to `/login/2fa`. With `tw
|
|
|
414
414
|
|
|
415
415
|
`SessionsComponent` (`<sail-sessions>`) lists the sessions with their `sign_in_method` captions, marks the current one, and signs out any other.
|
|
416
416
|
|
|
417
|
-
`ApiKeysComponent` (`<sail-api-keys [scopes]>`) lists the partner's keys and uses the backend-provided `generate` and `restrict` table actions; controls appear only for granted actions. Pass the scopes the app offers (`LabelOption[]`) after filtering out scopes the caller may not mint. The role also needs generic `SELECT` and `DELETE` grants to list and revoke keys. `ConnectedAppsComponent` (`<sail-connected-apps>`) lists and disconnects OAuth clients through keel's `GrantHandler` routes (v1.2.112, `RestURL.oauthGrantsURL` and `oauthGrantRevokeURL`).
|
|
417
|
+
`ApiKeysComponent` (`<sail-api-keys [scopes]>`) lists the partner's keys and uses the backend-provided `generate` and `restrict` table actions; controls appear only for granted actions. Pass the scopes the app offers (`LabelOption[]`) after filtering out scopes the caller may not mint; with none, keel's `ScopePolicy` supplies them. A key may carry an expiry, shown in its own column. The role also needs generic `SELECT` and `DELETE` grants to list and revoke keys. `ConnectedAppsComponent` (`<sail-connected-apps>`) lists and disconnects OAuth clients through keel's `GrantHandler` routes (v1.2.112, `RestURL.oauthGrantsURL` and `oauthGrantRevokeURL`).
|
|
418
418
|
|
|
419
419
|
### Cross-origin cookie requirement
|
|
420
420
|
|
|
@@ -689,7 +689,7 @@ A complete screen is the pieces above assembled over `BillingService` — no met
|
|
|
689
689
|
|
|
690
690
|
## Agent UI (v1.1.20)
|
|
691
691
|
|
|
692
|
-
Agent Studio and agent-conversation UI live in a secondary entry point, `@nauticana/sail/agent`. Nothing in it is exported from `@nauticana/sail`, so an app that never imports it bundles none of it. It needs no additional peer dependency. The wire types match Scout v1.2.
|
|
692
|
+
Agent Studio and agent-conversation UI live in a secondary entry point, `@nauticana/sail/agent`. Nothing in it is exported from `@nauticana/sail`, so an app that never imports it bundles none of it. It needs no additional peer dependency. The wire types match Scout v1.2.14.
|
|
693
693
|
|
|
694
694
|
```ts
|
|
695
695
|
import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@nauticana/sail/agent';
|
|
@@ -699,10 +699,10 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
|
|
|
699
699
|
|---|---|
|
|
700
700
|
| `AgentStudioService` | Client for Scout's core Studio routes under `/api/agent-studio/` (fixed, unversioned paths): the `features` manifest, list, draft, settings, create, rename, archive, enable, test, publish, restore, reset, default, history, audit, release sections, models. Models mirror the backend's JSON names. |
|
|
701
701
|
| `sail-studio-page` | Embeddable Studio: an agent rail grouped by `[groupBy]` (agent type by default; pass a function for workflow or position), and Overview, Prompts, Settings (scoped bindings and what they resolve to), Test, and History (diff, restore, audit) tabs. Product tabs are `<ng-template sailStudioTab="id" label="Label" let-agent>`. A control shows only when the `agents` feature lists its action and the caller holds the `AGENT_STUDIO` grant at `agent:<name>` (`agent_type:<type>` for create); hiding is never the authorization. `hasUnsavedChanges()` serves a `canDeactivate` guard. |
|
|
702
|
-
| `sail-studio-page` Guardrails tab | Shown when the `features` manifest lists `guardrails`: inherited layers read-only, the agent's own rules as an editable JSON array saved against `expected_agent_revision`, and its safety events paged by `before`. Edit needs the feature's `EDIT` action. The rail captions Scout's fixed
|
|
703
|
-
| `sail-assistant-activity` | The tenant's decision records from Scout's `decisions` feature (`GET /api/agent-studio/decisions`, `AGENT_AUDIT` `VIEW`), newest first and paged by `next_before`: when, action, resource, outcome caption, who
|
|
704
|
-
| `sail-turn-thread` | The signed-in person's turns from `GET {base}turns` (`ConversationService.turns`), newest first, paged by `next_before` and optionally narrowed to `[workRef]`: `turn_status` caption, settled cost in minor units with its exponent, agent and release version. Each `task_kind` renders its result through `<ng-template sailTurnResult="kind" let-turn
|
|
705
|
-
| `sail-approval-inbox` / `ApprovalService` | The signed-in person's pending approvals (`GET /api/approval/pending`) and MCP confirmations (`GET /api/approval/confirmations`)
|
|
702
|
+
| `sail-studio-page` Guardrails tab | Shown when the `features` manifest lists `guardrails`: inherited layers read-only, the agent's own rules as an editable JSON array saved against `expected_agent_revision`, and its safety events paged by `before`. Edit needs the feature's `EDIT` action. The rail captions Scout's fixed `AgentReadiness` values (`AGENT_READINESS_CAPTIONS`). |
|
|
703
|
+
| `sail-assistant-activity` | The tenant's decision records from Scout's `decisions` feature (`GET /api/agent-studio/decisions`, `AGENT_AUDIT` `VIEW`), newest first and paged by `next_before`: when, action, resource, outcome caption, who, app, and whom it acted for, named by the product's Scout `Labeler` (kind and id, or the client ref, without one). `[category]` and `[outcome]` filter. |
|
|
704
|
+
| `sail-turn-thread` | The signed-in person's turns from `GET {base}turns` (`ConversationService.turns`), newest first, paged by `next_before` and optionally narrowed to `[workRef]`: `turn_status` caption, settled cost in minor units with its exponent, agent and release version. Each `task_kind` renders its result through `<ng-template sailTurnResult="kind" let-turn>` (`"*"` for any other kind; `let-turn` is a `ConversationTurn`); a failed turn without error text shows `[failedText]`; the first page reloads every `[pollMs]` while a turn is nonterminal. |
|
|
705
|
+
| `sail-approval-inbox` / `ApprovalService` | The signed-in person's pending approvals (`GET /api/approval/pending`) and MCP confirmations (`GET /api/approval/confirmations`) grouped as waiting, submitted, needing reconciliation and recent; each list loads independently and a `404` leaves it empty. Approvals are approved, rejected, or edited (`POST /api/approval/resolve`); confirmations are confirmed, declined, withdrawn, or reconciled as executed or failed through `/api/approval/confirmation/*`, each control shown only when Scout says the person may. Approving asks once more; reconciling requires a note of up to 500 characters. `[open]` selects an item by key or confirmation id (an emailed link). People, workspaces and apps show their Scout `Labeler` captions. A tool's preview renders through `<ng-template sailApprovalPreview="tool" let-c>` (`"*"` for any other tool), otherwise as JSON. A `404` or `409` reloads the list; `(decided)` and `(confirmationChanged)` emit the result. |
|
|
706
706
|
| `studioErrorMessage(err)` | One message for a failed Studio call: the conflict cause, the field errors, or the detail. |
|
|
707
707
|
| `studioConflictCause(err)` | `'revision'` (reload the draft), `'sealed'` (a layer sits under a sealed one) or `'other'` for a `409`. |
|
|
708
708
|
| `studioFieldErrors(err)` / `retryAfterSeconds(err)` | Field errors of a `400`; `Retry-After` of a `429`/`503`. |
|
|
@@ -713,7 +713,7 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
|
|
|
713
713
|
| `TurnStream` | Per-turn stream state: `accept(frame)` accepts Scout's zero-based frames and drops frames already rendered, `cursor()` is the next sequence wanted for inclusive replay, a suspension frame (`approval_pending`, even when marked final) sets `awaiting_approval` without advancing the cursor so the resumed frame at the same sequence is accepted, `markReplayExpired()` + `acceptStoredResult(frame)` handle an expired replay window, `markCancelRequested()` holds `cancelling` until the final frame. |
|
|
714
714
|
| `sail-turn-stream-view` | `TurnStream` + event list + status + cancel button (`cancelRequested`). |
|
|
715
715
|
| `sail-decision-timeline` | Read-only decision chain of one request, grouped by category. |
|
|
716
|
-
| `ConversationService` | Transport for Scout's conversation bridge: `submit`, `stream(requestId, turnStream)`, `cancel`. Mount point injected through `CONVERSATION_BASE_PATH
|
|
716
|
+
| `ConversationService` | Transport for Scout's conversation bridge: `submit`, `stream(requestId, turnStream)`, `cancel`. Mount point injected through `CONVERSATION_BASE_PATH`; the service is a root singleton, so provide the token at bootstrap, or both together on a component. |
|
|
717
717
|
| `SseParser` | Incremental `text/event-stream` parser: `push(chunk)` returns whole events out of chunks that split anywhere, including between the CR and LF of a CRLF; `finish()` flushes what the closing body left pending. |
|
|
718
718
|
|
|
719
719
|
`DecisionRecord` is Scout's `api.DecisionRecord` (outcome `A`, `D`, or `P`, captioned from `audit_decision_outcome`); the timeline takes the records of one request, the activity table pages the tenant's. Style the `.prompt-*`, `.release-*`, `.turn-*`, `.decision-*`, `.studio__*`, and `.approval-inbox__*` class hooks. Scout's short codes render through `sailCaption`, a pipe over the backend's constant captions.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.36",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -1,38 +1,42 @@
|
|
|
1
1
|
<div class="approval-inbox">
|
|
2
|
-
<
|
|
3
|
-
@
|
|
4
|
-
<
|
|
2
|
+
<nav class="approval-inbox__list" aria-label="Approvals">
|
|
3
|
+
@for (section of sections(); track section.title) {
|
|
4
|
+
<section [attr.aria-label]="section.title">
|
|
5
|
+
<h3>{{ section.title }} ({{ section.requests.length + section.confirmations.length }})</h3>
|
|
6
|
+
<ul>
|
|
7
|
+
@for (request of section.requests; track key(request)) {
|
|
8
|
+
<li>
|
|
9
|
+
<button type="button" class="approval-inbox__item" [class.selected]="key(request) === selectedKey()"
|
|
10
|
+
[attr.aria-current]="key(request) === selectedKey()" (click)="select(key(request))">
|
|
11
|
+
<span class="approval-inbox__summary">{{ request.summary || request.action }}</span>
|
|
12
|
+
@if (request.deadline_at) {
|
|
13
|
+
<span class="approval-inbox__deadline">Due {{ request.deadline_at | date: 'medium' }}</span>
|
|
14
|
+
}
|
|
15
|
+
</button>
|
|
16
|
+
</li>
|
|
17
|
+
}
|
|
18
|
+
@for (c of section.confirmations; track confirmationKey(c)) {
|
|
19
|
+
<li>
|
|
20
|
+
<button type="button" class="approval-inbox__item" [class.selected]="confirmationKey(c) === selectedKey()"
|
|
21
|
+
[attr.aria-current]="confirmationKey(c) === selectedKey()" (click)="select(confirmationKey(c))">
|
|
22
|
+
<span class="approval-inbox__summary">{{ c.tool }} · {{ c.status | sailCaption:'mcp_confirmation_status' }}</span>
|
|
23
|
+
@if (c.status === 'P') {
|
|
24
|
+
<span class="approval-inbox__deadline">Expires {{ c.expires_at | date: 'medium' }}</span>
|
|
25
|
+
}
|
|
26
|
+
</button>
|
|
27
|
+
</li>
|
|
28
|
+
}
|
|
29
|
+
</ul>
|
|
30
|
+
</section>
|
|
31
|
+
} @empty {
|
|
32
|
+
@if (!loading()) { <p class="approval-inbox__empty">Nothing is waiting for your decision.</p> }
|
|
5
33
|
}
|
|
6
|
-
<ul>
|
|
7
|
-
@for (request of requests(); track key(request)) {
|
|
8
|
-
<li>
|
|
9
|
-
<button type="button" class="approval-inbox__item" [class.selected]="key(request) === selectedKey()"
|
|
10
|
-
[attr.aria-current]="key(request) === selectedKey()" (click)="select(key(request))">
|
|
11
|
-
<span class="approval-inbox__summary">{{ request.summary || request.action }}</span>
|
|
12
|
-
@if (request.deadline_at) {
|
|
13
|
-
<span class="approval-inbox__deadline">Due {{ request.deadline_at | date: 'medium' }}</span>
|
|
14
|
-
}
|
|
15
|
-
</button>
|
|
16
|
-
</li>
|
|
17
|
-
}
|
|
18
|
-
@for (c of confirmations(); track confirmationKey(c)) {
|
|
19
|
-
<li>
|
|
20
|
-
<button type="button" class="approval-inbox__item" [class.selected]="confirmationKey(c) === selectedKey()"
|
|
21
|
-
[attr.aria-current]="confirmationKey(c) === selectedKey()" (click)="select(confirmationKey(c))">
|
|
22
|
-
<span class="approval-inbox__summary">{{ c.tool }} · {{ c.status | sailCaption:'mcp_confirmation_status' }}</span>
|
|
23
|
-
@if (c.status === 'P') {
|
|
24
|
-
<span class="approval-inbox__deadline">Expires {{ c.expires_at | date: 'medium' }}</span>
|
|
25
|
-
}
|
|
26
|
-
</button>
|
|
27
|
-
</li>
|
|
28
|
-
}
|
|
29
|
-
</ul>
|
|
30
34
|
<button matButton type="button" [disabled]="loading()" (click)="load()">Refresh</button>
|
|
31
|
-
</
|
|
35
|
+
</nav>
|
|
32
36
|
|
|
33
37
|
@if (selected(); as request) {
|
|
34
38
|
<section class="approval-inbox__decision" aria-label="Decision">
|
|
35
|
-
<h3>{{ request.summary || request.action }}</h3>
|
|
39
|
+
<h3 #heading tabindex="-1">{{ request.summary || request.action }}</h3>
|
|
36
40
|
<dl>
|
|
37
41
|
<dt>Action</dt><dd>{{ request.action }}</dd>
|
|
38
42
|
<dt>Resource</dt><dd>{{ request.resource }}</dd>
|
|
@@ -60,45 +64,64 @@
|
|
|
60
64
|
<textarea matInput rows="4" [value]="revisedArguments()" (input)="revisedArguments.set($any($event.target).value)"></textarea>
|
|
61
65
|
</mat-form-field>
|
|
62
66
|
|
|
63
|
-
<div class="approval-inbox__actions">
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
<div class="approval-inbox__actions" role="group" aria-label="Decide">
|
|
68
|
+
@if (confirming()) {
|
|
69
|
+
<span>Approve this action? It runs exactly as shown.</span>
|
|
70
|
+
<button #yes matButton="filled" type="button" [disabled]="loading()" (click)="decide('A')">Yes, approve</button>
|
|
71
|
+
<button matButton type="button" [disabled]="loading()" (click)="cancelApprove()">Cancel</button>
|
|
72
|
+
} @else {
|
|
73
|
+
<button matButton="filled" type="button" [disabled]="loading()" (click)="askApprove()">Approve</button>
|
|
74
|
+
<button matButton type="button" class="warn" [disabled]="loading()" (click)="decide('X')">Reject</button>
|
|
75
|
+
<button matButton type="button" [disabled]="loading() || !revisedArguments().trim()" (click)="decide('E')">Edit and run</button>
|
|
76
|
+
}
|
|
67
77
|
</div>
|
|
68
78
|
</section>
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
@if (selectedConfirmation(); as c) {
|
|
72
82
|
<section class="approval-inbox__decision" aria-label="Confirmation">
|
|
73
|
-
<h3>{{ c.tool }}</h3>
|
|
83
|
+
<h3 #heading tabindex="-1">{{ c.tool }}</h3>
|
|
74
84
|
<dl>
|
|
85
|
+
<dt>Workspace</dt><dd>{{ c.tenant_label || c.tenant_id }}</dd>
|
|
75
86
|
<dt>Status</dt><dd>{{ c.status | sailCaption:'mcp_confirmation_status' }}@if (c.expired) { (expired) }</dd>
|
|
76
|
-
<dt>Requested by</dt><dd>{{ c.maker
|
|
87
|
+
<dt>Requested by</dt><dd>{{ name(c.maker) }}@if (c.client_label || c.client_ref) { through {{ c.client_label || c.client_ref }} }</dd>
|
|
77
88
|
<dt>Requested</dt><dd>{{ c.created_at | date: 'medium' }} · expires {{ c.expires_at | date: 'medium' }}</dd>
|
|
78
89
|
@if (c.approval_required) { <dt>Maker-checker</dt><dd>A second person must approve</dd> }
|
|
79
|
-
@if (c.decider; as d) { <dt>Decided</dt><dd>{{ d
|
|
80
|
-
@if (c.
|
|
90
|
+
@if (c.decider; as d) { <dt>Decided</dt><dd>{{ name(d) }} · {{ c.decided_at | date: 'medium' }} {{ c.decision_note }}</dd> }
|
|
91
|
+
@if (c.completed_at) { <dt>Finished</dt><dd>{{ c.completed_at | date: 'medium' }}</dd> }
|
|
92
|
+
@if (c.reconciler; as r) { <dt>Reconciled</dt><dd>{{ name(r) }} {{ c.reconcile_note }}</dd> }
|
|
81
93
|
@if (c.error) { <dt>Error</dt><dd>{{ c.error }}</dd> }
|
|
82
94
|
</dl>
|
|
83
|
-
@if (c.
|
|
95
|
+
@if (previewTemplate(c.tool); as template) {
|
|
96
|
+
<ng-container [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{ $implicit: c }" />
|
|
97
|
+
} @else if (c.preview !== undefined) {
|
|
98
|
+
<pre class="approval-inbox__preview">{{ c.preview | json }}</pre>
|
|
99
|
+
}
|
|
84
100
|
|
|
85
101
|
@if (c.can_decide || c.can_reconcile) {
|
|
86
102
|
<mat-form-field>
|
|
87
|
-
<mat-label>Note (optional)</mat-label>
|
|
88
|
-
<textarea matInput rows="2" [
|
|
103
|
+
<mat-label>{{ c.can_reconcile ? 'What you verified (required)' : 'Note (optional)' }}</mat-label>
|
|
104
|
+
<textarea matInput rows="2" [maxLength]="noteMax" [required]="c.can_reconcile" [value]="reason()"
|
|
105
|
+
(input)="reason.set($any($event.target).value)"></textarea>
|
|
89
106
|
</mat-form-field>
|
|
90
107
|
}
|
|
91
|
-
<div class="approval-inbox__actions">
|
|
108
|
+
<div class="approval-inbox__actions" role="group" aria-label="Decide">
|
|
92
109
|
@if (c.can_decide) {
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
@if (confirming()) {
|
|
111
|
+
<span>Confirm this action? It runs exactly as shown.</span>
|
|
112
|
+
<button #yes matButton="filled" type="button" [disabled]="loading()" (click)="confirm(true)">Yes, confirm</button>
|
|
113
|
+
<button matButton type="button" [disabled]="loading()" (click)="cancelApprove()">Cancel</button>
|
|
114
|
+
} @else {
|
|
115
|
+
<button matButton="filled" type="button" [disabled]="loading()" (click)="askApprove()">Confirm</button>
|
|
116
|
+
<button matButton type="button" class="warn" [disabled]="loading()" (click)="confirm(false)">Decline</button>
|
|
117
|
+
}
|
|
95
118
|
}
|
|
96
119
|
@if (c.can_withdraw) {
|
|
97
120
|
<button matButton type="button" [disabled]="loading()" (click)="withdraw()">Withdraw</button>
|
|
98
121
|
}
|
|
99
122
|
@if (c.can_reconcile) {
|
|
100
|
-
<button matButton type="button" [disabled]="loading()" (click)="reconcile('D')">Mark executed</button>
|
|
101
|
-
<button matButton type="button" [disabled]="loading()" (click)="reconcile('F')">Mark failed</button>
|
|
123
|
+
<button matButton type="button" [disabled]="loading() || !reason().trim()" (click)="reconcile('D')">Mark executed</button>
|
|
124
|
+
<button matButton type="button" [disabled]="loading() || !reason().trim()" (click)="reconcile('F')">Mark failed</button>
|
|
102
125
|
}
|
|
103
126
|
</div>
|
|
104
127
|
</section>
|
|
@@ -107,4 +130,5 @@
|
|
|
107
130
|
@if (errorMessage()) {
|
|
108
131
|
<p class="approval-inbox__error" role="alert">{{ errorMessage() }}</p>
|
|
109
132
|
}
|
|
133
|
+
<p class="approval-inbox__status" role="status">{{ successMessage() }}</p>
|
|
110
134
|
</div>
|
|
@@ -1,37 +1,88 @@
|
|
|
1
|
-
import { DatePipe, JsonPipe } from '@angular/common';
|
|
2
|
-
import {
|
|
1
|
+
import { DatePipe, JsonPipe, NgTemplateOutlet } from '@angular/common';
|
|
2
|
+
import {
|
|
3
|
+
ChangeDetectionStrategy, Component, Directive, ElementRef, Injector, OnInit, Signal, TemplateRef, afterNextRender,
|
|
4
|
+
computed, contentChildren, inject, input, output, signal, viewChild,
|
|
5
|
+
} from '@angular/core';
|
|
3
6
|
import { MatButtonModule } from '@angular/material/button';
|
|
4
7
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
5
8
|
import { MatInputModule } from '@angular/material/input';
|
|
6
|
-
import { Observable, forkJoin } from 'rxjs';
|
|
9
|
+
import { Observable, catchError, forkJoin, of } from 'rxjs';
|
|
7
10
|
import { BaseAsync } from '../../component/abstract/base_async';
|
|
8
11
|
import { CaptionPipe } from '../../service/caption_pipe';
|
|
9
|
-
import {
|
|
12
|
+
import { errorDetail, httpStatus } from '../../util/errors';
|
|
13
|
+
import {
|
|
14
|
+
ApprovalRequest, ApprovalVerdict, CONFIRMATION_NOTE_MAX, Confirmation, ReconcileOutcome,
|
|
15
|
+
} from '../model/approval';
|
|
16
|
+
import { principalName } from '../model/decision';
|
|
10
17
|
import { ApprovalService } from '../service/approval.service';
|
|
11
18
|
|
|
19
|
+
export interface ApprovalPreviewContext {
|
|
20
|
+
$implicit: Confirmation;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How one tool's confirmation preview reads, or every other tool's with `"*"`:
|
|
25
|
+
* `<ng-template sailApprovalPreview="tool" let-c>…</ng-template>`. Without one the preview prints as JSON.
|
|
26
|
+
*/
|
|
27
|
+
@Directive({ selector: 'ng-template[sailApprovalPreview]' })
|
|
28
|
+
export class ApprovalPreviewDirective {
|
|
29
|
+
readonly tool = input.required<string>({ alias: 'sailApprovalPreview' });
|
|
30
|
+
readonly template = inject<TemplateRef<ApprovalPreviewContext>>(TemplateRef);
|
|
31
|
+
|
|
32
|
+
static ngTemplateContextGuard(_dir: ApprovalPreviewDirective, ctx: unknown): ctx is ApprovalPreviewContext {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ApprovalInboxSection {
|
|
38
|
+
title: string;
|
|
39
|
+
requests: ApprovalRequest[];
|
|
40
|
+
confirmations: Confirmation[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const actionErrors: Record<number, string> = {
|
|
44
|
+
403: 'You are not allowed to do this here.',
|
|
45
|
+
404: 'This item no longer exists, or you can no longer see it; the list was refreshed.',
|
|
46
|
+
409: 'This item changed or was decided elsewhere; the list was refreshed.',
|
|
47
|
+
};
|
|
48
|
+
|
|
12
49
|
/**
|
|
13
|
-
* The signed-in person's pending approvals and MCP confirmations
|
|
14
|
-
*
|
|
15
|
-
*
|
|
50
|
+
* The signed-in person's pending approvals and MCP confirmations, grouped as waiting, submitted,
|
|
51
|
+
* needing reconciliation and recent, with a decision view: approve, reject, or edit a tool call's
|
|
52
|
+
* arguments; confirm, decline, withdraw, or reconcile a confirmation. Approving asks once more, and
|
|
53
|
+
* reconciling requires a note. A list the backend does not serve (404) stays empty.
|
|
16
54
|
* Selector: <sail-approval-inbox>.
|
|
17
55
|
*/
|
|
18
56
|
@Component({
|
|
19
57
|
selector: 'sail-approval-inbox',
|
|
20
58
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
21
|
-
imports: [DatePipe, JsonPipe, MatButtonModule, MatFormFieldModule, MatInputModule, CaptionPipe],
|
|
59
|
+
imports: [DatePipe, JsonPipe, NgTemplateOutlet, MatButtonModule, MatFormFieldModule, MatInputModule, CaptionPipe],
|
|
22
60
|
templateUrl: './approval_inbox.html',
|
|
23
61
|
})
|
|
24
62
|
export class ApprovalInboxComponent extends BaseAsync implements OnInit {
|
|
25
63
|
private readonly approvals = inject(ApprovalService);
|
|
64
|
+
private readonly injector = inject(Injector);
|
|
65
|
+
private readonly previews = contentChildren(ApprovalPreviewDirective);
|
|
66
|
+
private readonly heading = viewChild<ElementRef<HTMLElement>>('heading');
|
|
67
|
+
private readonly yes = viewChild<ElementRef<HTMLElement>>('yes');
|
|
68
|
+
private opened = '';
|
|
69
|
+
|
|
70
|
+
/** The item to open first: an approval or confirmation key, or a confirmation id from a link. */
|
|
71
|
+
readonly open = input('');
|
|
26
72
|
|
|
27
73
|
readonly decided = output<ApprovalRequest>();
|
|
28
74
|
readonly confirmationChanged = output<Confirmation>();
|
|
29
75
|
|
|
76
|
+
readonly noteMax = CONFIRMATION_NOTE_MAX;
|
|
77
|
+
readonly name = principalName;
|
|
30
78
|
readonly requests = signal<ApprovalRequest[]>([]);
|
|
31
79
|
readonly confirmations = signal<Confirmation[]>([]);
|
|
80
|
+
readonly sections = computed(() => groupInbox(this.requests(), this.confirmations()));
|
|
32
81
|
readonly selectedKey = signal('');
|
|
33
82
|
readonly reason = signal('');
|
|
34
83
|
readonly revisedArguments = signal('');
|
|
84
|
+
/** The approve step awaits its second click. */
|
|
85
|
+
readonly confirming = signal(false);
|
|
35
86
|
readonly selected = computed(() => this.requests().find((r) => this.key(r) === this.selectedKey()));
|
|
36
87
|
readonly selectedConfirmation = computed(() => this.confirmations().find((c) => this.confirmationKey(c) === this.selectedKey()));
|
|
37
88
|
|
|
@@ -40,47 +91,52 @@ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
|
|
|
40
91
|
}
|
|
41
92
|
|
|
42
93
|
load(): void {
|
|
43
|
-
this.run(forkJoin([
|
|
94
|
+
this.run(forkJoin([
|
|
95
|
+
this.listed(this.approvals.pending(), this.requests(), 'Failed to load approvals.'),
|
|
96
|
+
this.listed(this.approvals.confirmations(), this.confirmations(), 'Failed to load confirmations.'),
|
|
97
|
+
]), ([requests, confirmations]) => {
|
|
44
98
|
this.requests.set(requests);
|
|
45
99
|
this.confirmations.set(confirmations);
|
|
46
|
-
|
|
47
|
-
|
|
100
|
+
const link = this.open();
|
|
101
|
+
if (link && link !== this.opened) {
|
|
102
|
+
const linked = requests.find((r) => this.key(r) === link)
|
|
103
|
+
?? confirmations.find((c) => this.confirmationKey(c) === link || String(c.id) === link);
|
|
104
|
+
if (linked) {
|
|
105
|
+
this.opened = link;
|
|
106
|
+
this.select('request_id' in linked ? this.key(linked) : this.confirmationKey(linked));
|
|
107
|
+
this.focus(this.heading);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!this.successMessage()) {
|
|
111
|
+
this.successMessage.set('The linked item is not listed: it may be decided, withdrawn or expired, or not yours to act on.');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!this.selected() && !this.selectedConfirmation()) {
|
|
115
|
+
this.selectFirst();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
48
118
|
}
|
|
49
119
|
|
|
50
120
|
select(key: string): void {
|
|
51
121
|
this.selectedKey.set(key);
|
|
52
122
|
this.reason.set('');
|
|
53
123
|
this.revisedArguments.set('');
|
|
124
|
+
this.confirming.set(false);
|
|
54
125
|
}
|
|
55
126
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
this.applyConfirmation(this.approvals.decideConfirmation(c, approve, this.reason().trim()));
|
|
127
|
+
askApprove(): void {
|
|
128
|
+
this.confirming.set(true);
|
|
129
|
+
this.focus(this.yes);
|
|
60
130
|
}
|
|
61
131
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.run(this.approvals.withdrawConfirmation(c), () => this.load(), 'Failed to withdraw the confirmation.');
|
|
132
|
+
cancelApprove(): void {
|
|
133
|
+
this.confirming.set(false);
|
|
134
|
+
this.focus(this.heading);
|
|
66
135
|
}
|
|
67
136
|
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
this.applyConfirmation(this.approvals.reconcileConfirmation(c, outcome, this.reason().trim()));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
private applyConfirmation(request: Observable<Confirmation>): void {
|
|
75
|
-
this.run(request, (result) => {
|
|
76
|
-
this.confirmations.update((rows) => rows.map((c) => c.id === result.id && c.tenant_id === result.tenant_id ? result : c));
|
|
77
|
-
this.reason.set('');
|
|
78
|
-
this.confirmationChanged.emit(result);
|
|
79
|
-
}, 'Failed to record the decision.', (err) => this.reloadOnConflict(err));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
confirmationKey(c: Confirmation): string {
|
|
83
|
-
return `${c.tenant_id}/${c.id}`;
|
|
137
|
+
previewTemplate(tool: string): TemplateRef<ApprovalPreviewContext> | undefined {
|
|
138
|
+
const previews = this.previews();
|
|
139
|
+
return (previews.find((p) => p.tool() === tool) ?? previews.find((p) => p.tool() === '*'))?.template;
|
|
84
140
|
}
|
|
85
141
|
|
|
86
142
|
decide(verdict: ApprovalVerdict): void {
|
|
@@ -94,7 +150,7 @@ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
|
|
|
94
150
|
return;
|
|
95
151
|
}
|
|
96
152
|
}
|
|
97
|
-
this.
|
|
153
|
+
this.act(this.approvals.resolve({
|
|
98
154
|
request_id: request.request_id,
|
|
99
155
|
execution_step_id: request.execution_step_id,
|
|
100
156
|
verdict,
|
|
@@ -103,20 +159,108 @@ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
|
|
|
103
159
|
revised_arguments: revised,
|
|
104
160
|
}), (result) => {
|
|
105
161
|
this.requests.update((rows) => rows.filter((r) => this.key(r) !== this.key(request)));
|
|
106
|
-
this.
|
|
162
|
+
this.selectFirst();
|
|
107
163
|
this.decided.emit(result);
|
|
108
|
-
|
|
164
|
+
return verdict === 'X' ? 'Rejected.' : 'Approved.';
|
|
165
|
+
});
|
|
109
166
|
}
|
|
110
167
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
this.
|
|
168
|
+
confirm(approve: boolean): void {
|
|
169
|
+
const c = this.selectedConfirmation();
|
|
170
|
+
if (!c) return;
|
|
171
|
+
this.applyConfirmation(this.approvals.decideConfirmation(c, approve, this.reason().trim()),
|
|
172
|
+
approve ? 'Confirmed; the action runs shortly.' : 'Declined; nothing was done.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
withdraw(): void {
|
|
176
|
+
const c = this.selectedConfirmation();
|
|
177
|
+
if (!c || !window.confirm('Withdraw this confirmation?')) return;
|
|
178
|
+
this.act(this.approvals.withdrawConfirmation(c), () => {
|
|
179
|
+
this.load();
|
|
180
|
+
return 'Withdrawn; nothing will be done.';
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
reconcile(outcome: ReconcileOutcome): void {
|
|
185
|
+
const c = this.selectedConfirmation();
|
|
186
|
+
const note = this.reason().trim();
|
|
187
|
+
if (!c || !note) return;
|
|
188
|
+
this.applyConfirmation(this.approvals.reconcileConfirmation(c, outcome, note), 'Outcome recorded; the action was not run again.');
|
|
115
189
|
}
|
|
116
190
|
|
|
117
191
|
key(request: ApprovalRequest): string {
|
|
118
192
|
return `${request.request_id}#${request.execution_step_id}`;
|
|
119
193
|
}
|
|
194
|
+
|
|
195
|
+
confirmationKey(c: Confirmation): string {
|
|
196
|
+
return `${c.tenant_id}/${c.id}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private applyConfirmation(request: Observable<Confirmation>, done: string): void {
|
|
200
|
+
this.act(request, (result) => {
|
|
201
|
+
this.confirmations.update((rows) => rows.map((c) => c.id === result.id && c.tenant_id === result.tenant_id ? result : c));
|
|
202
|
+
this.confirmationChanged.emit(result);
|
|
203
|
+
return done;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private act<T>(request: Observable<T>, done: (result: T) => string): void {
|
|
208
|
+
this.run(request, (result) => {
|
|
209
|
+
this.reason.set('');
|
|
210
|
+
this.confirming.set(false);
|
|
211
|
+
this.successMessage.set(done(result));
|
|
212
|
+
this.focus(this.heading);
|
|
213
|
+
}, 'Failed to record the decision.', (err) => {
|
|
214
|
+
const status = httpStatus(err);
|
|
215
|
+
const message = actionErrors[status ?? 0];
|
|
216
|
+
if (!message) return;
|
|
217
|
+
if (status !== 403) this.load();
|
|
218
|
+
this.errorMessage.set(message);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private listed<T>(list: Observable<T[]>, current: T[], failed: string): Observable<T[]> {
|
|
223
|
+
return list.pipe(catchError((err) => {
|
|
224
|
+
if (httpStatus(err) === 404) return of([]);
|
|
225
|
+
this.errorMessage.set(errorDetail(err, failed));
|
|
226
|
+
return of(current);
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private focus(target: Signal<ElementRef<HTMLElement> | undefined>): void {
|
|
231
|
+
afterNextRender(() => target()?.nativeElement.focus(), { injector: this.injector });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private selectFirst(): void {
|
|
235
|
+
const first = this.sections()[0];
|
|
236
|
+
this.select(first?.requests[0] ? this.key(first.requests[0])
|
|
237
|
+
: first?.confirmations[0] ? this.confirmationKey(first.confirmations[0]) : '');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Approvals wait first; confirmations land in the first section they qualify for, open ones soonest expiry first. */
|
|
242
|
+
export function groupInbox(requests: ApprovalRequest[], confirmations: Confirmation[]): ApprovalInboxSection[] {
|
|
243
|
+
const sections: ApprovalInboxSection[] = [
|
|
244
|
+
{ title: 'Waiting for your decision', requests, confirmations: [] },
|
|
245
|
+
{ title: 'Submitted by you', requests: [], confirmations: [] },
|
|
246
|
+
{ title: 'Needs reconciliation', requests: [], confirmations: [] },
|
|
247
|
+
{ title: 'Recent', requests: [], confirmations: [] },
|
|
248
|
+
];
|
|
249
|
+
for (const c of confirmations) {
|
|
250
|
+
sections[c.can_decide ? 0 : c.can_withdraw ? 1 : c.can_reconcile ? 2 : 3].confirmations.push(c);
|
|
251
|
+
}
|
|
252
|
+
sections.forEach((s, i) => s.confirmations.sort(i < 2
|
|
253
|
+
? (a, b) => time(a.expires_at) - time(b.expires_at)
|
|
254
|
+
: (a, b) => lastActivity(b) - lastActivity(a)));
|
|
255
|
+
return sections.filter((s) => s.requests.length || s.confirmations.length);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function time(value: string | undefined): number {
|
|
259
|
+
return (value && Date.parse(value)) || 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function lastActivity(c: Confirmation): number {
|
|
263
|
+
return Math.max(time(c.created_at), time(c.decided_at), time(c.completed_at));
|
|
120
264
|
}
|
|
121
265
|
|
|
122
266
|
function parseObject(text: string): Record<string, unknown> | undefined {
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
<td><code>{{ record.action }}</code></td>
|
|
12
12
|
<td>{{ record.resource }}</td>
|
|
13
13
|
<td>{{ record.outcome | sailCaption:'audit_decision_outcome' }}@if (record.reason) { <span class="assistant-activity__reason">({{ record.reason }})</span> }</td>
|
|
14
|
-
<td>{{
|
|
15
|
-
<td>{{ record.client_ref }}</td>
|
|
16
|
-
<td>@if (record.authority; as a) { {{ a.subject
|
|
14
|
+
<td>{{ name(record.principal) }}</td>
|
|
15
|
+
<td>{{ record.client_label || record.client_ref }}</td>
|
|
16
|
+
<td>@if (record.authority; as a) { {{ name(a.subject) }} }</td>
|
|
17
17
|
</tr>
|
|
18
18
|
}
|
|
19
19
|
</tbody>
|
|
@@ -3,12 +3,13 @@ import { ChangeDetectionStrategy, Component, OnInit, inject, input, signal } fro
|
|
|
3
3
|
import { MatButtonModule } from '@angular/material/button';
|
|
4
4
|
import { BaseAsync } from '../../component/abstract/base_async';
|
|
5
5
|
import { CaptionPipe } from '../../service/caption_pipe';
|
|
6
|
-
import { DecisionOutcome, DecisionRecord } from '../model/decision';
|
|
6
|
+
import { DecisionOutcome, DecisionRecord, principalName } from '../model/decision';
|
|
7
7
|
import { AgentStudioService } from '../service/agent_studio.service';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* What assistants did in the tenant: Scout's decision records (`decisions` feature, `AGENT_AUDIT`
|
|
11
|
-
* `VIEW`), newest first and paged by `next_before
|
|
11
|
+
* `VIEW`), newest first and paged by `next_before`, named by the product's labeler.
|
|
12
|
+
* Selector: <sail-assistant-activity>.
|
|
12
13
|
*/
|
|
13
14
|
@Component({
|
|
14
15
|
selector: 'sail-assistant-activity',
|
|
@@ -21,8 +22,7 @@ export class AssistantActivityComponent extends BaseAsync implements OnInit {
|
|
|
21
22
|
|
|
22
23
|
readonly category = input('');
|
|
23
24
|
readonly outcome = input<DecisionOutcome | undefined>(undefined);
|
|
24
|
-
|
|
25
|
-
readonly who = input<(record: DecisionRecord) => string>((r) => `${r.principal.kind} ${r.principal.id}`);
|
|
25
|
+
readonly name = principalName;
|
|
26
26
|
|
|
27
27
|
readonly records = signal<DecisionRecord[]>([]);
|
|
28
28
|
readonly nextBefore = signal(0);
|
|
@@ -13,7 +13,7 @@ import { BaseAuthService } from '../../service/auth.service';
|
|
|
13
13
|
import { CaptionPipe } from '../../service/caption_pipe';
|
|
14
14
|
import { LabelOption, LabelService } from '../../service/label.service';
|
|
15
15
|
import {
|
|
16
|
-
AGENT_READINESS_CAPTIONS, AgentAuditEvent, AgentDraft, AgentGuardrails, AgentLanguageDraft, AgentMergeMode,
|
|
16
|
+
AGENT_READINESS_CAPTIONS, AgentAuditEvent, AgentReadiness, AgentDraft, AgentGuardrails, AgentLanguageDraft, AgentMergeMode,
|
|
17
17
|
AgentModelSelection, AgentRelease, AgentSetting, AgentSettings, AgentSummary, AgentTestResult, GuardrailRule,
|
|
18
18
|
SafetyEvent, StudioFeature, StudioModel,
|
|
19
19
|
} from '../model/agent_studio';
|
|
@@ -127,7 +127,7 @@ export class StudioPageComponent implements OnInit {
|
|
|
127
127
|
return this.features().find((f) => f.id === id);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
readinessCaption(code:
|
|
130
|
+
readinessCaption(code: AgentReadiness): string {
|
|
131
131
|
return AGENT_READINESS_CAPTIONS[code] ?? code;
|
|
132
132
|
}
|
|
133
133
|
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
@if (turn.cost_minor !== undefined && turn.currency && turn.exponent !== undefined) { · {{ turn.cost_minor | sailMoney:turn.currency:turn.exponent }} }
|
|
11
11
|
@if (turn.release_version) { · {{ turn.agent_name }} v{{ turn.release_version }} }
|
|
12
12
|
</p>
|
|
13
|
-
@if (turn.status === 'F'
|
|
14
|
-
<p class="turn-thread__error" role="alert">{{ turn.error_text }}</p>
|
|
13
|
+
@if (turn.status === 'F') {
|
|
14
|
+
<p class="turn-thread__error" role="alert">{{ turn.error_text || failedText() }}</p>
|
|
15
15
|
}
|
|
16
16
|
@if (resultTemplate(turn.task_kind); as template) {
|
|
17
17
|
<ng-container [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{ $implicit: turn }" />
|
|
@@ -9,14 +9,22 @@ import { MoneyPipe } from '../../service/money_pipe';
|
|
|
9
9
|
import { ConversationTurn, TERMINAL_TURN_STATUSES } from '../model/conversation';
|
|
10
10
|
import { ConversationService } from '../service/conversation.service';
|
|
11
11
|
|
|
12
|
+
export interface TurnResultContext {
|
|
13
|
+
$implicit: ConversationTurn;
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
/**
|
|
13
|
-
* The result card of one task kind
|
|
14
|
-
*
|
|
17
|
+
* The result card of one task kind, or of every other kind with `"*"`:
|
|
18
|
+
* `<ng-template sailTurnResult="kind" let-turn>…</ng-template>`.
|
|
15
19
|
*/
|
|
16
20
|
@Directive({ selector: 'ng-template[sailTurnResult]' })
|
|
17
21
|
export class TurnResultDirective {
|
|
18
22
|
readonly kind = input.required<string>({ alias: 'sailTurnResult' });
|
|
19
|
-
readonly template = inject<TemplateRef<
|
|
23
|
+
readonly template = inject<TemplateRef<TurnResultContext>>(TemplateRef);
|
|
24
|
+
|
|
25
|
+
static ngTemplateContextGuard(_dir: TurnResultDirective, ctx: unknown): ctx is TurnResultContext {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/**
|
|
@@ -39,6 +47,8 @@ export class TurnThreadComponent extends BaseAsync implements OnInit {
|
|
|
39
47
|
readonly kindLabels = input<Record<string, string>>({});
|
|
40
48
|
readonly pollMs = input(10000);
|
|
41
49
|
readonly emptyMessage = input('Nothing yet.');
|
|
50
|
+
/** Shown for a failed turn that carries no error text. */
|
|
51
|
+
readonly failedText = input('The turn failed.');
|
|
42
52
|
|
|
43
53
|
readonly turns = signal<ConversationTurn[]>([]);
|
|
44
54
|
readonly nextBefore = signal('');
|
|
@@ -77,7 +87,8 @@ export class TurnThreadComponent extends BaseAsync implements OnInit {
|
|
|
77
87
|
return this.kindLabels()[kind] ?? kind;
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
resultTemplate(kind: string): TemplateRef<
|
|
81
|
-
|
|
90
|
+
resultTemplate(kind: string): TemplateRef<TurnResultContext> | undefined {
|
|
91
|
+
const results = this.results();
|
|
92
|
+
return (results.find((r) => r.kind() === kind) ?? results.find((r) => r.kind() === '*'))?.template;
|
|
82
93
|
}
|
|
83
94
|
}
|
|
@@ -12,7 +12,7 @@ export interface AgentSummary {
|
|
|
12
12
|
enabled: boolean;
|
|
13
13
|
is_default: boolean;
|
|
14
14
|
archived: boolean;
|
|
15
|
-
readiness:
|
|
15
|
+
readiness: AgentReadiness;
|
|
16
16
|
readiness_reason: string;
|
|
17
17
|
type_defaults_revision: number;
|
|
18
18
|
agent_revision: number;
|
|
@@ -310,8 +310,11 @@ export interface AgentSettingChange {
|
|
|
310
310
|
expected_type_defaults_revision: number;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
-
/**
|
|
314
|
-
export
|
|
313
|
+
/** Scout's fixed `AgentReadiness` values, which no constant domain carries. */
|
|
314
|
+
export type AgentReadiness =
|
|
315
|
+
'ready' | 'disabled' | 'missing_model' | 'unpublished' | 'error' | 'not_entitled' | 'needs_integration' | 'forbidden';
|
|
316
|
+
|
|
317
|
+
export const AGENT_READINESS_CAPTIONS: Record<AgentReadiness, string> = {
|
|
315
318
|
ready: 'Ready',
|
|
316
319
|
disabled: 'Disabled',
|
|
317
320
|
missing_model: 'No text model',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PrincipalRef } from './decision';
|
|
2
2
|
|
|
3
|
-
/** Scout approval desk and MCP confirmation inbox wire types (v1.2.
|
|
3
|
+
/** Scout approval desk and MCP confirmation inbox wire types (v1.2.14). Codes render through backend captions. */
|
|
4
4
|
|
|
5
5
|
/** A approved, X rejected, E edited (tool-call proposals only, with revised arguments). */
|
|
6
6
|
export type ApprovalVerdict = 'A' | 'X' | 'E';
|
|
@@ -46,6 +46,12 @@ export interface ApprovalResolveRequest {
|
|
|
46
46
|
/** `mcp_confirmation_status`: P pending, A approved, R running, D executed, F failed, U unknown, X declined, W withdrawn, T expired. */
|
|
47
47
|
export type ConfirmationStatus = 'P' | 'A' | 'R' | 'D' | 'F' | 'U' | 'X' | 'W' | 'T';
|
|
48
48
|
|
|
49
|
+
/** A verified reconciliation outcome. */
|
|
50
|
+
export type ReconcileOutcome = Extract<ConfirmationStatus, 'D' | 'F'>;
|
|
51
|
+
|
|
52
|
+
/** Scout's bound on decision and reconciliation notes, in characters. */
|
|
53
|
+
export const CONFIRMATION_NOTE_MAX = 500;
|
|
54
|
+
|
|
49
55
|
export interface ConfirmationKey {
|
|
50
56
|
tenant_id: number;
|
|
51
57
|
id: number;
|
|
@@ -53,8 +59,10 @@ export interface ConfirmationKey {
|
|
|
53
59
|
|
|
54
60
|
/** One MCP tool call awaiting or past a person's confirmation; never its payload. */
|
|
55
61
|
export interface Confirmation extends ConfirmationKey {
|
|
62
|
+
tenant_label?: string;
|
|
56
63
|
maker: PrincipalRef;
|
|
57
64
|
client_ref?: string;
|
|
65
|
+
client_label?: string;
|
|
58
66
|
tool: string;
|
|
59
67
|
payload_digest: string;
|
|
60
68
|
/** The product's preview of the complete action. */
|
|
@@ -67,7 +67,10 @@ export interface ConversationTurnPage {
|
|
|
67
67
|
next_before?: string;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Where the app mounts Scout's ConversationHandler, e.g. '/api/wingmate/'. `ConversationService` is a
|
|
72
|
+
* root singleton: provide this at bootstrap, or provide both together on a component.
|
|
73
|
+
*/
|
|
71
74
|
export const CONVERSATION_BASE_PATH = new InjectionToken<string>('SailConversationBasePath', {
|
|
72
75
|
providedIn: 'root',
|
|
73
76
|
factory: () => '/api/conversation/',
|
|
@@ -4,6 +4,8 @@ export type DecisionOutcome = 'A' | 'D' | 'P';
|
|
|
4
4
|
export interface PrincipalRef {
|
|
5
5
|
kind: string;
|
|
6
6
|
id: string;
|
|
7
|
+
/** The product's caption for the principal (Scout `contract.Labeler`). */
|
|
8
|
+
label?: string;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
export interface AuthorityRef {
|
|
@@ -24,6 +26,7 @@ export interface DecisionRecord {
|
|
|
24
26
|
authority?: AuthorityRef;
|
|
25
27
|
/** The client application the principal acted through. */
|
|
26
28
|
client_ref?: string;
|
|
29
|
+
client_label?: string;
|
|
27
30
|
work_ref?: string;
|
|
28
31
|
scope_id?: string;
|
|
29
32
|
release_version?: string;
|
|
@@ -46,3 +49,8 @@ export interface DecisionCategoryGroup {
|
|
|
46
49
|
category: string;
|
|
47
50
|
records: DecisionRecord[];
|
|
48
51
|
}
|
|
52
|
+
|
|
53
|
+
/** A principal's label, or its kind and id. */
|
|
54
|
+
export function principalName(ref: PrincipalRef): string {
|
|
55
|
+
return ref.label || `${ref.kind} ${ref.id}`;
|
|
56
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Injectable } from '@angular/core';
|
|
2
2
|
import { Observable } from 'rxjs';
|
|
3
3
|
import { BaseRestService } from '../../service/base_rest.service';
|
|
4
|
-
import { ApprovalRequest, ApprovalResolveRequest, Confirmation, ConfirmationKey } from '../model/approval';
|
|
4
|
+
import { ApprovalRequest, ApprovalResolveRequest, Confirmation, ConfirmationKey, ReconcileOutcome } from '../model/approval';
|
|
5
5
|
|
|
6
6
|
const base = '/api/approval/';
|
|
7
7
|
|
|
@@ -31,8 +31,8 @@ export class ApprovalService extends BaseRestService {
|
|
|
31
31
|
return this.http.post<ConfirmationKey>(this.url(base + 'confirmation/withdraw'), key);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** Records the verified outcome of an unknown confirmation
|
|
35
|
-
reconcileConfirmation(key: ConfirmationKey, outcome:
|
|
34
|
+
/** Records the verified outcome of an unknown confirmation; the note is required. */
|
|
35
|
+
reconcileConfirmation(key: ConfirmationKey, outcome: ReconcileOutcome, note: string): Observable<Confirmation> {
|
|
36
36
|
return this.http.post<Confirmation>(this.url(base + 'confirmation/reconcile'), { ...key, outcome, note });
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -3,7 +3,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|
|
3
3
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
4
4
|
import { AgencyInvite } from '../../model/agency';
|
|
5
5
|
import { AgencyService } from '../../service/agency.service';
|
|
6
|
-
import { errorCode, errorDetail } from '../../util/errors';
|
|
6
|
+
import { errorCode, errorDetail, httpStatus } from '../../util/errors';
|
|
7
7
|
|
|
8
8
|
const NO_PARTNER_CODE = 'agency_no_partner';
|
|
9
9
|
|
|
@@ -71,7 +71,7 @@ export class AgencyAcceptComponent implements OnInit {
|
|
|
71
71
|
},
|
|
72
72
|
error: (error: unknown) => {
|
|
73
73
|
this.accepting.set(false);
|
|
74
|
-
if (
|
|
74
|
+
if (httpStatus(error) === 409 && errorCode(error) === NO_PARTNER_CODE) {
|
|
75
75
|
localStorage.setItem(this.tokenStorageKey(), this.activeToken);
|
|
76
76
|
this.needsBusiness.set(true);
|
|
77
77
|
return;
|
|
@@ -85,10 +85,4 @@ export class AgencyAcceptComponent implements OnInit {
|
|
|
85
85
|
localStorage.setItem(this.tokenStorageKey(), this.activeToken);
|
|
86
86
|
this.businessSetupRequested.emit(this.activeToken);
|
|
87
87
|
}
|
|
88
|
-
|
|
89
|
-
private statusOf(error: unknown): number | undefined {
|
|
90
|
-
return typeof error === 'object' && error !== null && 'status' in error
|
|
91
|
-
? Number((error as { status?: unknown }).status)
|
|
92
|
-
: undefined;
|
|
93
|
-
}
|
|
94
88
|
}
|
|
@@ -7,6 +7,7 @@ import { MatIconModule } from '@angular/material/icon';
|
|
|
7
7
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
8
8
|
import { MatInputModule } from '@angular/material/input';
|
|
9
9
|
import { OAuthConnectionService } from '../../service/oauth_connection.service';
|
|
10
|
+
import { httpStatus } from '../../util/errors';
|
|
10
11
|
import { OAuthConnectionStatus, OAuthConnectionView, PartnerCredential, OAuthProviderDescriptor, OAuthProviderFieldRole } from '../../model/oauth_connection';
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -90,7 +91,7 @@ export class OAuthConnectionPanelComponent implements OnInit {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
private completeMsg(e: unknown): string {
|
|
93
|
-
const status = (e
|
|
94
|
+
const status = httpStatus(e);
|
|
94
95
|
if (status === 403) return 'This connection was started by another user — start again from this account.';
|
|
95
96
|
if (status === 400) return 'The connection link expired — start again.';
|
|
96
97
|
return this.msg(e, 'Could not complete the connection.');
|
|
@@ -18,13 +18,19 @@
|
|
|
18
18
|
<input matInput formControlName="allowedCidrs" maxlength="2000">
|
|
19
19
|
<mat-hint>Comma-separated addresses or CIDR ranges. Empty allows any address.</mat-hint>
|
|
20
20
|
</mat-form-field>
|
|
21
|
-
<
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
<mat-form-field>
|
|
22
|
+
<mat-label>Expires (optional)</mat-label>
|
|
23
|
+
<input matInput type="datetime-local" formControlName="expiresAt">
|
|
24
|
+
</mat-form-field>
|
|
25
|
+
@if (scopes().length) {
|
|
26
|
+
<fieldset class="api-keys__scopes">
|
|
27
|
+
<legend>Scopes</legend>
|
|
28
|
+
@for (scope of scopes(); track scope.code) {
|
|
29
|
+
<mat-checkbox [checked]="form.controls.scopes.value.includes(scope.code)"
|
|
30
|
+
(change)="toggleScope(scope.code, $event.checked)">{{ scope.caption }}</mat-checkbox>
|
|
31
|
+
}
|
|
32
|
+
</fieldset>
|
|
33
|
+
}
|
|
28
34
|
<button matButton="filled" type="submit" [disabled]="form.invalid || loading()">Create key</button>
|
|
29
35
|
</form>
|
|
30
36
|
}
|
|
@@ -32,7 +38,7 @@
|
|
|
32
38
|
@if (keys().length > 0) {
|
|
33
39
|
<table class="api-keys__table">
|
|
34
40
|
<thead>
|
|
35
|
-
<tr><th>Name</th><th>Prefix</th><th>Scopes</th><th>Networks</th><th>Created</th><th>Last used</th><th><span class="cdk-visually-hidden">Actions</span></th></tr>
|
|
41
|
+
<tr><th>Name</th><th>Prefix</th><th>Scopes</th><th>Networks</th><th>Created</th><th>Expires</th><th>Last used</th><th><span class="cdk-visually-hidden">Actions</span></th></tr>
|
|
36
42
|
</thead>
|
|
37
43
|
<tbody>
|
|
38
44
|
@for (key of keys(); track key.Id) {
|
|
@@ -51,6 +57,7 @@
|
|
|
51
57
|
}
|
|
52
58
|
</td>
|
|
53
59
|
<td>{{ key.CreatedAt | date: 'short' }}</td>
|
|
60
|
+
<td>{{ key.ExpiresAt ? (key.ExpiresAt | date: 'short') : 'never' }}</td>
|
|
54
61
|
<td>{{ key.LastUsedAt ? (key.LastUsedAt | date: 'short') : '' }}</td>
|
|
55
62
|
<td>
|
|
56
63
|
@if (restricting() === key.Id) {
|
|
@@ -11,9 +11,9 @@ import { LabelOption } from '../../service/label.service';
|
|
|
11
11
|
import { BaseAsync } from '../abstract/base_async';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* The partner's API keys: mint one with scopes
|
|
15
|
-
* restrict its networks later, or revoke it. The app passes the scopes it
|
|
16
|
-
* `ScopePolicy`
|
|
14
|
+
* The partner's API keys: mint one with scopes, allowed networks and an optional expiry (the
|
|
15
|
+
* plaintext shows once), restrict its networks later, or revoke it. The app passes the scopes it
|
|
16
|
+
* offers; keel's `ScopePolicy` decides and may supply them. Selector: <sail-api-keys>.
|
|
17
17
|
*/
|
|
18
18
|
@Component({
|
|
19
19
|
selector: 'sail-api-keys',
|
|
@@ -27,7 +27,7 @@ export class ApiKeysComponent extends BaseAsync implements OnInit {
|
|
|
27
27
|
private readonly fb = inject(FormBuilder).nonNullable;
|
|
28
28
|
|
|
29
29
|
/** Scopes offered for a new key, as code and caption. */
|
|
30
|
-
readonly scopes = input
|
|
30
|
+
readonly scopes = input<LabelOption[]>([]);
|
|
31
31
|
|
|
32
32
|
readonly canGenerate = this.access.canApiKeyAction('generate');
|
|
33
33
|
readonly canRestrict = this.access.canApiKeyAction('restrict');
|
|
@@ -39,7 +39,8 @@ export class ApiKeysComponent extends BaseAsync implements OnInit {
|
|
|
39
39
|
readonly form = this.fb.group({
|
|
40
40
|
keyName: ['', [Validators.required, Validators.maxLength(100)]],
|
|
41
41
|
allowedCidrs: ['', Validators.maxLength(2000)],
|
|
42
|
-
scopes: [[] as string[]
|
|
42
|
+
scopes: [[] as string[]],
|
|
43
|
+
expiresAt: [''],
|
|
43
44
|
});
|
|
44
45
|
readonly networks = this.fb.control('', Validators.maxLength(2000));
|
|
45
46
|
|
|
@@ -58,8 +59,13 @@ export class ApiKeysComponent extends BaseAsync implements OnInit {
|
|
|
58
59
|
|
|
59
60
|
mint(): void {
|
|
60
61
|
if (this.form.invalid) return;
|
|
61
|
-
const { keyName, scopes, allowedCidrs } = this.form.getRawValue();
|
|
62
|
-
this.run(this.access.mintApiKey(
|
|
62
|
+
const { keyName, scopes, allowedCidrs, expiresAt } = this.form.getRawValue();
|
|
63
|
+
this.run(this.access.mintApiKey({
|
|
64
|
+
key_name: keyName,
|
|
65
|
+
scopes: scopes.join(','),
|
|
66
|
+
allowed_cidrs: allowedCidrs,
|
|
67
|
+
expires_at: expiresAt ? new Date(expiresAt).toISOString() : undefined,
|
|
68
|
+
}), (key) => {
|
|
63
69
|
this.minted.set(key);
|
|
64
70
|
this.form.reset();
|
|
65
71
|
this.load();
|
package/src/model/access.ts
CHANGED
|
@@ -12,6 +12,15 @@ export interface ApiKey {
|
|
|
12
12
|
AllowedCidrs?: string; // CSV of networks; absent allows any address
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** keel `api_key/generate` request; blank scopes are the product `ScopePolicy`'s to supply. */
|
|
16
|
+
export type NewApiKey = {
|
|
17
|
+
key_name: string;
|
|
18
|
+
scopes: string; // CSV
|
|
19
|
+
allowed_cidrs: string;
|
|
20
|
+
/** RFC 3339; absent never expires. */
|
|
21
|
+
expires_at?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
15
24
|
/** keel `api_key/generate` reply: the plaintext is shown once and never stored. */
|
|
16
25
|
export interface MintedApiKey {
|
|
17
26
|
api_key: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Injectable } from '@angular/core';
|
|
2
2
|
import { Observable, catchError, throwError } from 'rxjs';
|
|
3
|
-
import { ApiKey, MintedApiKey, OAuthGrant } from '../model/access';
|
|
3
|
+
import { ApiKey, MintedApiKey, NewApiKey, OAuthGrant } from '../model/access';
|
|
4
4
|
import { TableAction } from '../model/appdata';
|
|
5
5
|
import { BackendService } from './rest_service';
|
|
6
6
|
import { RestURL } from './rest_url';
|
|
@@ -12,9 +12,8 @@ export class AccessService extends BackendService {
|
|
|
12
12
|
return this.list<ApiKey>('api_key', undefined, { orderBy: 'created_at desc' });
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
mintApiKey(
|
|
16
|
-
return this.apiKeyAction<MintedApiKey>('generate',
|
|
17
|
-
{ key_name: keyName, scopes: scopes.join(','), allowed_cidrs: allowedCidrs });
|
|
15
|
+
mintApiKey(key: NewApiKey): Observable<MintedApiKey> {
|
|
16
|
+
return this.apiKeyAction<MintedApiKey>('generate', key);
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
restrictApiKey(id: number, allowedCidrs: string): Observable<void> {
|
|
@@ -2,6 +2,7 @@ import { Injectable, WritableSignal, inject, signal } from '@angular/core';
|
|
|
2
2
|
import { HttpErrorResponse, HttpParams } from '@angular/common/http';
|
|
3
3
|
import { filter, finalize, map, mergeMap, shareReplay, take, tap } from 'rxjs/operators';
|
|
4
4
|
import { PasswordRules } from '../util/password_policy';
|
|
5
|
+
import { httpStatus } from '../util/errors';
|
|
5
6
|
import { ActiveSession, ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerCreated, PartnerRegistration, PartnerSetup, RestReport, TableDefinition, TokenPair, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
|
|
6
7
|
import {
|
|
7
8
|
LoginResponseSocial,
|
|
@@ -289,7 +290,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
289
290
|
},
|
|
290
291
|
error: (err: unknown) => {
|
|
291
292
|
console.error('Session handoff failed:', err);
|
|
292
|
-
this.sessionHandoffError.set(handoffErrorMessage((err
|
|
293
|
+
this.sessionHandoffError.set(handoffErrorMessage(httpStatus(err)));
|
|
293
294
|
},
|
|
294
295
|
});
|
|
295
296
|
}
|
package/src/util/errors.ts
CHANGED
|
@@ -21,7 +21,8 @@ function asString(value: unknown): string | undefined {
|
|
|
21
21
|
return typeof value === 'string' && value ? value : undefined;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** The HTTP status of any sail error shape; undefined when it carries none. */
|
|
25
|
+
export function httpStatus(err: unknown): number | undefined {
|
|
25
26
|
if (!err || typeof err !== 'object') return undefined;
|
|
26
27
|
const s = (err as { status?: unknown }).status;
|
|
27
28
|
return typeof s === 'number' ? s : undefined;
|
|
@@ -34,12 +35,12 @@ function statusOf(err: unknown): number | undefined {
|
|
|
34
35
|
* available. Use it to offer a retry rather than reporting a backend rejection.
|
|
35
36
|
*/
|
|
36
37
|
export function isTransportFailure(err: unknown): boolean {
|
|
37
|
-
return
|
|
38
|
+
return httpStatus(err) === 0;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** True for keel's 403: the session is valid but lacks the grant for this action. */
|
|
41
42
|
export function isPermissionDenied(err: unknown): boolean {
|
|
42
|
-
return
|
|
43
|
+
return httpStatus(err) === 403;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -55,7 +56,7 @@ export function isPermissionDenied(err: unknown): boolean {
|
|
|
55
56
|
* exposing their response text.
|
|
56
57
|
*/
|
|
57
58
|
export function errorDetail(err: unknown, fallback: string, statuses?: readonly number[]): string {
|
|
58
|
-
const status =
|
|
59
|
+
const status = httpStatus(err);
|
|
59
60
|
if (statuses && status !== undefined && !statuses.includes(status)) {
|
|
60
61
|
return fallback;
|
|
61
62
|
}
|