@zackbart/connecta 0.7.8 → 0.7.9

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/activity.d.ts +1 -1
  3. package/dist/activity.d.ts.map +1 -1
  4. package/dist/activity.js.map +1 -1
  5. package/dist/call-admission.d.ts +81 -0
  6. package/dist/call-admission.d.ts.map +1 -0
  7. package/dist/call-admission.js +339 -0
  8. package/dist/call-admission.js.map +1 -0
  9. package/dist/connectors/api.d.ts +3 -1
  10. package/dist/connectors/api.d.ts.map +1 -1
  11. package/dist/connectors/api.js +1 -0
  12. package/dist/connectors/api.js.map +1 -1
  13. package/dist/connectors/remote-mcp.d.ts +3 -1
  14. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  15. package/dist/connectors/remote-mcp.js +1 -0
  16. package/dist/connectors/remote-mcp.js.map +1 -1
  17. package/dist/execute.d.ts.map +1 -1
  18. package/dist/execute.js +45 -15
  19. package/dist/execute.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/meta-tools.d.ts +3 -0
  25. package/dist/meta-tools.d.ts.map +1 -1
  26. package/dist/meta-tools.js +127 -29
  27. package/dist/meta-tools.js.map +1 -1
  28. package/dist/registry.d.ts +25 -0
  29. package/dist/registry.d.ts.map +1 -1
  30. package/dist/registry.js +28 -0
  31. package/dist/registry.js.map +1 -1
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +5 -0
  34. package/dist/server.js.map +1 -1
  35. package/dist/types.d.ts +55 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/ui.d.ts.map +1 -1
  38. package/dist/ui.js +8 -4
  39. package/dist/ui.js.map +1 -1
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +1 -1
  43. package/src/activity.ts +5 -1
  44. package/src/call-admission.ts +519 -0
  45. package/src/connectors/api.ts +4 -0
  46. package/src/connectors/remote-mcp.ts +4 -0
  47. package/src/execute.ts +54 -22
  48. package/src/index.ts +5 -0
  49. package/src/meta-tools.ts +157 -45
  50. package/src/registry.ts +55 -0
  51. package/src/server.ts +5 -0
  52. package/src/types.ts +61 -0
  53. package/src/ui.ts +8 -4
  54. package/src/version.ts +1 -1
package/src/index.ts CHANGED
@@ -648,6 +648,7 @@ export function createConnecta(config: ConnectaConfig): Connecta {
648
648
  closePromise ??= Promise.resolve().then(async () => {
649
649
  requestAdmission.close();
650
650
  codeAdmission?.close();
651
+ registry.closeCallAdmission();
651
652
  await config.executor?.close?.();
652
653
  });
653
654
  await closePromise;
@@ -698,6 +699,10 @@ export type {
698
699
  export type { ApiOptions, ApiTool } from "./connectors/api.js";
699
700
  export type {
700
701
  Connector,
702
+ ConnectorCallAdmissionInput,
703
+ ConnectorCallAdmissionPolicy,
704
+ ConnectorCallAdmissionRule,
705
+ ConnectorRollingWindowBudget,
701
706
  ConnectaBranding,
702
707
  ConnectorCredentialAccess,
703
708
  ConnectorCredentialConfig,
package/src/meta-tools.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  messageLooksRetryable,
18
18
  type CallErrorDetails,
19
19
  } from "./errors.js";
20
+ import { isCallAdmissionError } from "./call-admission.js";
20
21
  import {
21
22
  isValidMaxResultBytes,
22
23
  MIN_MAX_RESULT_BYTES,
@@ -228,6 +229,24 @@ function errorDetails(code: string, message: string): ErrorDetails {
228
229
  return { code, message, retryable: messageLooksRetryable(message) };
229
230
  }
230
231
 
232
+ function callerCancelledDetails(): ErrorDetails {
233
+ return {
234
+ code: "cancelled",
235
+ message: "Tool call was cancelled by the caller.",
236
+ retryable: false,
237
+ };
238
+ }
239
+
240
+ function isCallerCancellation(
241
+ error: unknown,
242
+ signal: AbortSignal | undefined,
243
+ ): boolean {
244
+ return (
245
+ signal?.aborted === true ||
246
+ (isCallAdmissionError(error) && error.admissionKind === "cancelled")
247
+ );
248
+ }
249
+
231
250
  /** True if `b` is a UTF-8 continuation byte (0b10xxxxxx). */
232
251
  function isContinuationByte(b: number): boolean {
233
252
  return (b & 0xc0) === 0x80;
@@ -598,6 +617,8 @@ export function createMetaTools(
598
617
  /** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
599
618
  probeTimeoutMs?: number;
600
619
  activity?: ActivityRequestContext;
620
+ /** Inbound request cancellation shared by direct and batch child calls. */
621
+ requestSignal?: AbortSignal;
601
622
  /** Runtime continuation for the bounded tail of probe-owned teardown. */
602
623
  defer?: DeferredWork;
603
624
  } = {},
@@ -631,6 +652,7 @@ export function createMetaTools(
631
652
  attempts: number;
632
653
  timing: {
633
654
  catalogMs: number;
655
+ admissionMs: number;
634
656
  connectorMs: number;
635
657
  backoffMs: number;
636
658
  resultProcessingMs: number;
@@ -648,12 +670,14 @@ export function createMetaTools(
648
670
  ): Promise<RunCallOutcome> {
649
671
  const started = Date.now();
650
672
  let catalogMs = 0;
673
+ let admissionMs = 0;
651
674
  let connectorMs = 0;
652
675
  let backoffMs = 0;
653
676
  let resultProcessingMs = 0;
654
677
  let attempts = 0;
655
678
  const timing = () => ({
656
679
  catalogMs,
680
+ admissionMs,
657
681
  connectorMs,
658
682
  backoffMs,
659
683
  resultProcessingMs,
@@ -661,7 +685,7 @@ export function createMetaTools(
661
685
  });
662
686
  const resolved = registry.resolveAddress(call.address);
663
687
  const record = (
664
- outcome: "success" | "error" | "timeout",
688
+ outcome: "success" | "error" | "timeout" | "cancelled",
665
689
  errorCode?: string,
666
690
  ) => {
667
691
  if (!resolved) return;
@@ -679,7 +703,14 @@ export function createMetaTools(
679
703
  const failed = (error: ErrorDetails): RunCallOutcome => {
680
704
  const durationMs = Date.now() - started;
681
705
  const diagnostics = timing();
682
- record(error.code === "timeout" ? "timeout" : "error", error.code);
706
+ record(
707
+ error.code === "timeout"
708
+ ? "timeout"
709
+ : error.code === "cancelled"
710
+ ? "cancelled"
711
+ : "error",
712
+ error.code,
713
+ );
683
714
  return {
684
715
  toolResult:
685
716
  call.resultMode === "value"
@@ -729,6 +760,9 @@ export function createMetaTools(
729
760
  ).find((tool) => tool.name === resolved.toolName);
730
761
  } catch (err) {
731
762
  catalogMs += Date.now() - catalogStarted;
763
+ if (opts.requestSignal?.aborted) {
764
+ return failed(callerCancelledDetails());
765
+ }
732
766
  // A connector whose catalog cannot be fetched is as unusable as one whose
733
767
  // execution fails, so it feeds health accounting the same way the
734
768
  // execution catch below does — otherwise a connector every call_tool
@@ -774,38 +808,76 @@ export function createMetaTools(
774
808
  let result: unknown;
775
809
  while (true) {
776
810
  attempts++;
777
- const controller = timeoutMs ? new AbortController() : undefined;
811
+ let permit: Awaited<ReturnType<RegistryView["admitCall"]>> | undefined;
812
+ const controller =
813
+ timeoutMs || opts.requestSignal ? new AbortController() : undefined;
814
+ const forwardAbort = () =>
815
+ controller?.abort(opts.requestSignal?.reason);
816
+ if (opts.requestSignal?.aborted) forwardAbort();
817
+ else {
818
+ opts.requestSignal?.addEventListener("abort", forwardAbort, {
819
+ once: true,
820
+ });
821
+ }
778
822
  let timer: ReturnType<typeof setTimeout> | undefined;
779
- const ctx = registry.contextFor(
780
- resolved.connector.id,
781
- baseUrl,
782
- requestScope,
783
- { signal: controller?.signal, timeoutMs },
784
- );
785
- const connectorStarted = Date.now();
823
+ let onAbort: (() => void) | undefined;
824
+ let attemptFailed = false;
825
+ let attemptError: unknown;
786
826
  try {
787
- const pending = resolved.connector.callTool(
788
- resolved.toolName,
789
- call.args ?? {},
790
- ctx,
827
+ const admissionStarted = Date.now();
828
+ try {
829
+ permit = await registry.admitCall(resolved.connector.id, {
830
+ toolName: resolved.toolName,
831
+ args: call.args ?? {},
832
+ signal: opts.requestSignal,
833
+ });
834
+ } finally {
835
+ admissionMs += Date.now() - admissionStarted;
836
+ }
837
+ const ctx = registry.contextFor(
838
+ resolved.connector.id,
839
+ baseUrl,
840
+ requestScope,
841
+ { signal: controller?.signal, timeoutMs },
791
842
  );
792
- result = timeoutMs
793
- ? await Promise.race([
794
- pending,
795
- new Promise<never>((_, reject) => {
796
- timer = setTimeout(() => {
797
- reject(
798
- new ConnectorCallError(
799
- "timeout",
800
- `Tool call timed out after ${timeoutMs}ms`,
801
- ),
802
- );
803
- controller?.abort();
804
- }, timeoutMs);
805
- }),
806
- ])
807
- : await pending;
808
- connectorMs += Date.now() - connectorStarted;
843
+ let rejectCancelled!: (reason: unknown) => void;
844
+ const cancelled = controller
845
+ ? new Promise<never>((_, reject) => {
846
+ rejectCancelled = reject;
847
+ })
848
+ : undefined;
849
+ onAbort = () => {
850
+ rejectCancelled(
851
+ controller?.signal.reason ??
852
+ new ConnectorCallError("timeout", "Tool call was cancelled"),
853
+ );
854
+ };
855
+ controller?.signal.addEventListener("abort", onAbort, { once: true });
856
+ if (controller?.signal.aborted) onAbort();
857
+ if (controller?.signal.aborted) await cancelled;
858
+ if (timeoutMs) {
859
+ timer = setTimeout(() => {
860
+ controller?.abort(
861
+ new ConnectorCallError(
862
+ "timeout",
863
+ `Tool call timed out after ${timeoutMs}ms`,
864
+ ),
865
+ );
866
+ }, timeoutMs);
867
+ }
868
+ const connectorStarted = Date.now();
869
+ try {
870
+ const pending = resolved.connector.callTool(
871
+ resolved.toolName,
872
+ call.args ?? {},
873
+ ctx,
874
+ );
875
+ result = cancelled
876
+ ? await Promise.race([pending, cancelled])
877
+ : await pending;
878
+ } finally {
879
+ connectorMs += Date.now() - connectorStarted;
880
+ }
809
881
  const mcpResult = result as {
810
882
  content?: TextContent[];
811
883
  isError?: boolean;
@@ -816,34 +888,72 @@ export function createMetaTools(
816
888
  "Downstream tool call failed",
817
889
  );
818
890
  }
819
- break;
820
891
  } catch (err) {
821
- // Includes connector setup, downstream execution, and timeout wait.
822
- connectorMs += Date.now() - connectorStarted;
823
- const details = classifyCallError(err);
824
- if (attempts <= maxRetries && retrySafe && details.retryable) {
892
+ attemptFailed = true;
893
+ attemptError = err;
894
+ } finally {
895
+ if (timer) clearTimeout(timer);
896
+ if (onAbort) {
897
+ controller?.signal.removeEventListener("abort", onAbort);
898
+ }
899
+ opts.requestSignal?.removeEventListener("abort", forwardAbort);
900
+ permit?.release();
901
+ }
902
+ if (attemptFailed) {
903
+ const callerCancelled = isCallerCancellation(
904
+ attemptError,
905
+ opts.requestSignal,
906
+ );
907
+ const details = callerCancelled
908
+ ? callerCancelledDetails()
909
+ : classifyCallError(attemptError);
910
+ if (
911
+ !callerCancelled &&
912
+ attempts <= maxRetries &&
913
+ retrySafe &&
914
+ details.retryable
915
+ ) {
825
916
  const wait = retryBackoffMs(attempts, details.retryAfterMs);
826
917
  if (wait !== undefined) {
827
918
  const backoffStarted = Date.now();
828
919
  if (wait > 0) {
829
- await new Promise((resolve) => setTimeout(resolve, wait));
920
+ const completed = await new Promise<boolean>((resolve) => {
921
+ let settled = false;
922
+ const finish = (value: boolean) => {
923
+ if (settled) return;
924
+ settled = true;
925
+ clearTimeout(timer);
926
+ opts.requestSignal?.removeEventListener("abort", cancel);
927
+ resolve(value);
928
+ };
929
+ const timer = setTimeout(() => finish(true), wait);
930
+ const cancel = () => finish(false);
931
+ opts.requestSignal?.addEventListener("abort", cancel, {
932
+ once: true,
933
+ });
934
+ if (opts.requestSignal?.aborted) cancel();
935
+ });
936
+ backoffMs += Date.now() - backoffStarted;
937
+ if (!completed) return failed(callerCancelledDetails());
938
+ } else {
939
+ backoffMs += Date.now() - backoffStarted;
830
940
  }
831
- backoffMs += Date.now() - backoffStarted;
832
941
  continue;
833
942
  }
834
943
  // The reported window is longer than the engine will park a
835
944
  // synchronous request for. Fall through to failure with
836
945
  // retryAfterMs reported verbatim so the agent can re-issue.
837
946
  }
838
- registry.recordFailure(
839
- resolved.connector.id,
840
- Date.now() - started,
841
- err,
842
- );
947
+ if (!callerCancelled && !isCallAdmissionError(attemptError)) {
948
+ registry.recordFailure(
949
+ resolved.connector.id,
950
+ Date.now() - started,
951
+ attemptError,
952
+ );
953
+ }
843
954
  return failed(details);
844
- } finally {
845
- if (timer) clearTimeout(timer);
846
955
  }
956
+ break;
847
957
  }
848
958
 
849
959
  registry.recordSuccess(resolved.connector.id, Date.now() - started);
@@ -1657,6 +1767,7 @@ export function registerMetaTools(
1657
1767
  defaultToolTimeoutMs?: number;
1658
1768
  probeTimeoutMs?: number;
1659
1769
  activity?: ActivityRequestContext;
1770
+ requestSignal?: AbortSignal;
1660
1771
  defer?: DeferredWork;
1661
1772
  },
1662
1773
  ): void {
@@ -1664,6 +1775,7 @@ export function registerMetaTools(
1664
1775
  defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
1665
1776
  probeTimeoutMs: ctx.probeTimeoutMs,
1666
1777
  activity: ctx.activity,
1778
+ requestSignal: ctx.requestSignal,
1667
1779
  defer: ctx.defer,
1668
1780
  });
1669
1781
 
package/src/registry.ts CHANGED
@@ -18,6 +18,11 @@ import {
18
18
  type CredentialHealthConfig,
19
19
  type CredentialHealthRecord,
20
20
  } from "./credential-health.js";
21
+ import {
22
+ ConnectorCallAdmissionController,
23
+ type CallAdmissionPermit,
24
+ type ConnectorCallAdmissionSnapshot,
25
+ } from "./call-admission.js";
21
26
  import type { DeferredWork } from "./connector-scope.js";
22
27
  import { splitAddress, type Toolkit } from "./toolkits.js";
23
28
 
@@ -212,6 +217,14 @@ export interface RegistryView {
212
217
  requestScope?: object,
213
218
  callOptions?: ConnectorOperationOptions,
214
219
  ): ConnectorContext;
220
+ /**
221
+ * Acquire the connector's shared downstream-call permit. Scoped views
222
+ * delegate to the base registry so every toolkit contends on the same pool.
223
+ */
224
+ admitCall(
225
+ id: string,
226
+ input: { toolName: string; args: unknown; signal?: AbortSignal },
227
+ ): Promise<CallAdmissionPermit>;
215
228
  resultsStorage(): KVStorage;
216
229
  recordSuccess(id: string, latencyMs: number): void;
217
230
  recordFailure(id: string, latencyMs: number, error: unknown): void;
@@ -242,6 +255,10 @@ export interface RegistryView {
242
255
  */
243
256
  export class Registry implements RegistryView {
244
257
  private readonly connectors = new Map<string, Connector>();
258
+ private readonly callAdmission = new Map<
259
+ string,
260
+ ConnectorCallAdmissionController
261
+ >();
245
262
  private readonly cache = new Map<string, CacheEntry>();
246
263
  private readonly invalidated = new Set<string>();
247
264
  /** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
@@ -287,6 +304,12 @@ export class Registry implements RegistryView {
287
304
  throw new Error(`Duplicate connector id "${c.id}"`);
288
305
  }
289
306
  this.connectors.set(c.id, c);
307
+ if (c.callAdmission) {
308
+ this.callAdmission.set(
309
+ c.id,
310
+ new ConnectorCallAdmissionController(c.id, c.callAdmission),
311
+ );
312
+ }
290
313
  }
291
314
  this.checkConventions(opts.logger);
292
315
  this.checkResultCaps(
@@ -430,6 +453,30 @@ export class Registry implements RegistryView {
430
453
  };
431
454
  }
432
455
 
456
+ admitCall(
457
+ id: string,
458
+ input: { toolName: string; args: unknown; signal?: AbortSignal },
459
+ ): Promise<CallAdmissionPermit> {
460
+ const admission = this.callAdmission.get(id);
461
+ if (admission) return admission.acquire(input);
462
+ return Promise.resolve({ waitMs: 0, release() {} });
463
+ }
464
+
465
+ /** Payload-free aggregate state for the open health endpoint. */
466
+ callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot> {
467
+ return Object.fromEntries(
468
+ [...this.callAdmission].map(([id, admission]) => [
469
+ id,
470
+ admission.snapshot(),
471
+ ]),
472
+ );
473
+ }
474
+
475
+ /** Reject queued/future downstream admission; active permits release safely. */
476
+ closeCallAdmission(): void {
477
+ for (const admission of this.callAdmission.values()) admission.close();
478
+ }
479
+
433
480
  /**
434
481
  * Storage namespaced to the meta-tool result store (`results:` prefix), kept
435
482
  * separate from any connector's `conn:<id>:` namespace. Backs get_result.
@@ -946,6 +993,14 @@ export class ScopedRegistry implements RegistryView {
946
993
  return this.base.contextFor(id, baseUrl, requestScope, callOptions);
947
994
  }
948
995
 
996
+ admitCall(
997
+ id: string,
998
+ input: { toolName: string; args: unknown; signal?: AbortSignal },
999
+ ): Promise<CallAdmissionPermit> {
1000
+ if (!this.visible(id)) return Promise.reject(this.unknownConnector(id));
1001
+ return this.base.admitCall(id, input);
1002
+ }
1003
+
949
1004
  /**
950
1005
  * Stashed oversized results are bound to the scope that produced them: a
951
1006
  * scoped session cannot page a result it could not have produced, and an id
package/src/server.ts CHANGED
@@ -1273,6 +1273,7 @@ async function serveMcp(
1273
1273
  activity,
1274
1274
  defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
1275
1275
  probeTimeoutMs: opts.probeTimeoutMs,
1276
+ requestSignal: request.signal,
1276
1277
  ...(runtimeContext
1277
1278
  ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
1278
1279
  : {}),
@@ -1577,6 +1578,10 @@ export function createFetchHandler(
1577
1578
  code: opts.executor
1578
1579
  ? (codeAdmission ?? { managedByExecutor: true })
1579
1580
  : null,
1581
+ downstreamCalls: {
1582
+ policy: "connector-partitioned-per-runtime",
1583
+ connectors: registry.callAdmissionSnapshot(),
1584
+ },
1580
1585
  reservedRoutes: [
1581
1586
  "/health",
1582
1587
  "/",
package/src/types.ts CHANGED
@@ -44,6 +44,61 @@ export interface ToolAnnotations extends Record<string, unknown> {
44
44
  openWorldHint?: boolean;
45
45
  }
46
46
 
47
+ /** Inputs a connector may reduce to a non-secret admission partition key. */
48
+ export interface ConnectorCallAdmissionInput {
49
+ toolName: string;
50
+ args: unknown;
51
+ }
52
+
53
+ /** Exact sliding-window budget for one connector-call partition. */
54
+ export interface ConnectorRollingWindowBudget {
55
+ kind: "rolling-window";
56
+ /** Calls admitted during `windowMs` before another is proactively refused. */
57
+ maxCalls: number;
58
+ /** Width of the rolling window in milliseconds. */
59
+ windowMs: number;
60
+ }
61
+
62
+ /**
63
+ * One connector-level downstream call-admission rule.
64
+ *
65
+ * This release accepts the plural `rules` container below but enforces exactly
66
+ * one rule. That keeps the public shape ready for providers whose concurrency
67
+ * and budget limits eventually need different partition dimensions without
68
+ * pretending multi-rule admission is already atomic.
69
+ */
70
+ export interface ConnectorCallAdmissionRule {
71
+ /** Maximum simultaneous Connector.callTool attempts in one partition. */
72
+ maxConcurrency?: number;
73
+ /** Callers allowed to wait behind the concurrency bound. Default 32. */
74
+ maxQueueSize?: number;
75
+ /** Maximum concurrency-queue wait in milliseconds. Default 5,000. */
76
+ queueTimeoutMs?: number;
77
+ /** Retry hint for concurrency overloads. Default 1,000. */
78
+ retryAfterMs?: number;
79
+ /** Optional exact rolling-window call-start budget. */
80
+ budget?: ConnectorRollingWindowBudget;
81
+ /**
82
+ * Derive a bounded, non-secret partition key from the tool call. Omit for
83
+ * one connector-wide partition. Connecta retains the returned key only; it
84
+ * never copies arguments into limiter state.
85
+ */
86
+ partitionKey?(
87
+ input: Readonly<ConnectorCallAdmissionInput>,
88
+ ): string;
89
+ }
90
+
91
+ /** Optional downstream call-admission policy declared by one connector. */
92
+ export interface ConnectorCallAdmissionPolicy {
93
+ /**
94
+ * Plural-ready policy container. Exactly one rule is supported in this
95
+ * release; empty or multi-rule policies fail construction.
96
+ */
97
+ rules: readonly ConnectorCallAdmissionRule[];
98
+ /** Maximum simultaneously retained partition states. Default 1,024. */
99
+ maxPartitions?: number;
100
+ }
101
+
47
102
  export type ConnectorCredentialValues = Record<string, string>;
48
103
 
49
104
  /** Read-only access to the credentials assigned to one connector. */
@@ -145,6 +200,12 @@ export interface Connector {
145
200
  * the connector inherits the deployment-wide cap.
146
201
  */
147
202
  maxResultBytes?: number;
203
+ /**
204
+ * Optional per-runtime admission policy for downstream tool calls. It covers
205
+ * call_tool, every batch_call child, and execute_code host calls, but not
206
+ * catalog/status/auth operations.
207
+ */
208
+ callAdmission?: ConnectorCallAdmissionPolicy;
148
209
  /**
149
210
  * Optional agent-facing usage guide (markdown) for this connector — preferred
150
211
  * tools, address quirks, pagination conventions, rate-limit etiquette, good
package/src/ui.ts CHANGED
@@ -1064,7 +1064,8 @@ ${clerkScript}
1064
1064
  .activity-address { font-family: var(--mono); font-size: .78rem; overflow-wrap: anywhere; }
1065
1065
  .activity-outcome { font-size: .9em; }
1066
1066
  .activity-item.error .activity-outcome,
1067
- .activity-item.timeout .activity-outcome { text-decoration: underline; }
1067
+ .activity-item.timeout .activity-outcome,
1068
+ .activity-item.cancelled .activity-outcome { text-decoration: underline; }
1068
1069
  .activity-empty { border-top: 1px solid var(--rule); padding: .75rem 0; }
1069
1070
  .activity-more { margin-top: .75rem; }
1070
1071
  .unavailable {
@@ -1498,9 +1499,12 @@ function renderActivity() {
1498
1499
  }
1499
1500
  for (const event of visible) {
1500
1501
  const item = document.createElement("article");
1501
- const outcomeClass = ["success", "error", "timeout"].includes(event.outcome)
1502
- ? event.outcome
1503
- : "error";
1502
+ const outcomeClass = [
1503
+ "success",
1504
+ "error",
1505
+ "timeout",
1506
+ "cancelled",
1507
+ ].includes(event.outcome) ? event.outcome : "error";
1504
1508
  item.className = "activity-item " + outcomeClass;
1505
1509
  const retryCopy = event.attempts > 1
1506
1510
  ? " · " + esc(event.attempts) + " attempts"
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.7.8";
7
+ export const CONNECTA_VERSION = "0.7.9";