@agents-at-scale/ark 0.1.68 → 0.1.70

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.
@@ -108,6 +108,9 @@ const defaultArkServices = {
108
108
  category: 'core',
109
109
  chartPath: `${REGISTRY_BASE}/ark-tenant:${CHART_VERSION}`,
110
110
  installArgs: [],
111
+ // Without a broker, a tenant loses conversation history (issue #2642).
112
+ requires: ['ark-broker'],
113
+ dependencyOverrideArgs: ['--set', 'memory.requireBroker=false'],
111
114
  },
112
115
  'ark-api': {
113
116
  name: 'ark-api',
@@ -158,6 +161,7 @@ const defaultArkServices = {
158
161
  helmReleaseName: 'ark-broker',
159
162
  description: 'In-memory storage service with streaming support for Ark queries',
160
163
  enabled: true,
164
+ mandatory: true,
161
165
  category: 'service',
162
166
  chartPath: `${REGISTRY_BASE}/ark-broker:${CHART_VERSION}`,
163
167
  installArgs: [],
@@ -3,6 +3,14 @@ import * as fs from 'fs/promises';
3
3
  import yaml from 'yaml';
4
4
  import { listResources } from '../../lib/kubectl.js';
5
5
  import output from '../../lib/output.js';
6
+ // Argo workflow resources, in dependency order (cluster templates before the
7
+ // namespaced templates that may reference them). These live in Argo CRDs that
8
+ // may not be installed on the source cluster, so they are treated as optional.
9
+ const ARGO_RESOURCE_TYPES = [
10
+ 'clusterworkflowtemplates',
11
+ 'workflowtemplates',
12
+ 'cronworkflows',
13
+ ];
6
14
  // resource types in dependency order so that they can be loaded correctly
7
15
  // by default these will all be exported if not specified; can be overridden with defaultExportTypes in config
8
16
  const RESOURCE_ORDER = [
@@ -13,7 +21,15 @@ const RESOURCE_ORDER = [
13
21
  'teams',
14
22
  'mcpservers',
15
23
  'a2aservers',
24
+ ...ARGO_RESOURCE_TYPES,
16
25
  ];
26
+ // resource types whose CRDs may be absent; a missing CRD is warned and skipped
27
+ // rather than failing the whole export
28
+ const OPTIONAL_RESOURCE_TYPES = new Set(ARGO_RESOURCE_TYPES);
29
+ // cluster-scoped types have no namespace to filter on, so a namespace-scoped
30
+ // export skips them rather than sweeping in cluster-wide resources shared across
31
+ // tenants; they are captured by a cluster-wide export (no --namespace)
32
+ const CLUSTER_SCOPED_TYPES = new Set(['clusterworkflowtemplates']);
17
33
  const SERVER_MANAGED_METADATA_FIELDS = [
18
34
  'resourceVersion',
19
35
  'uid',
@@ -30,6 +46,20 @@ const LAST_APPLIED_ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuratio
30
46
  function isRecord(value) {
31
47
  return value !== null && typeof value === 'object' && !Array.isArray(value);
32
48
  }
49
+ function errorText(error) {
50
+ const stderr = isRecord(error) && typeof error.stderr === 'string' ? error.stderr : '';
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ return `${stderr}\n${message}`;
53
+ }
54
+ function isResourceTypeNotFoundError(error) {
55
+ const combined = errorText(error);
56
+ return (combined.includes("doesn't have a resource type") ||
57
+ combined.includes('could not find the requested resource'));
58
+ }
59
+ function isForbiddenError(error) {
60
+ const combined = errorText(error);
61
+ return (combined.includes('(Forbidden)') || combined.includes('is forbidden:'));
62
+ }
33
63
  function sanitizeResource(resource) {
34
64
  const sanitized = { ...resource };
35
65
  delete sanitized.status;
@@ -94,11 +124,31 @@ async function exportResources(options, config) {
94
124
  output.warning(`unknown resource type: ${resourceType}, skipping`);
95
125
  continue;
96
126
  }
127
+ if (options.namespace && CLUSTER_SCOPED_TYPES.has(resourceType)) {
128
+ output.info(`skipping cluster-scoped ${resourceType} for namespace-scoped export`);
129
+ continue;
130
+ }
97
131
  output.info(`fetching ${resourceType}...`);
98
- const resources = await listResources(resourceType, {
99
- namespace: options.namespace,
100
- labels: options.labels,
101
- });
132
+ let resources;
133
+ try {
134
+ resources = await listResources(resourceType, {
135
+ namespace: options.namespace,
136
+ labels: options.labels,
137
+ });
138
+ }
139
+ catch (error) {
140
+ if (OPTIONAL_RESOURCE_TYPES.has(resourceType)) {
141
+ if (isResourceTypeNotFoundError(error)) {
142
+ output.warning(`${resourceType} CRD not installed on this cluster, skipping`);
143
+ continue;
144
+ }
145
+ if (isForbiddenError(error)) {
146
+ output.warning(`not authorized to list ${resourceType}, skipping`);
147
+ continue;
148
+ }
149
+ }
150
+ throw error;
151
+ }
102
152
  const resourceCount = resources.length;
103
153
  if (resources.length > 0) {
104
154
  output.success(`found ${resourceCount} ${resourceType}`);
@@ -7,5 +7,6 @@ export declare function installArk(config: ArkConfig, serviceNames?: string[], o
7
7
  arkVersion?: string;
8
8
  marketplaceVersion?: string;
9
9
  backend?: string;
10
+ deps?: boolean;
10
11
  }): Promise<void>;
11
12
  export declare function createInstallCommand(config: ArkConfig): Command;
@@ -85,6 +85,43 @@ function exitIfServicesSkipped(skipped) {
85
85
  output.error(`installation incomplete: ${skipped.length} service(s) skipped because the requested version was not found: ${detail}`);
86
86
  process.exit(1);
87
87
  }
88
+ function findService(services, name) {
89
+ return Object.values(services).find((s) => s.name === name || s.helmReleaseName === name);
90
+ }
91
+ /**
92
+ * Expand requested service names to include their transitive `requires`,
93
+ * dependencies first. A visited set both de-duplicates and guards against
94
+ * a cycle in `requires` (a node already being visited is never re-entered).
95
+ * A dependency that does not resolve to an installable service (disabled,
96
+ * or excluded for the current backend) is skipped with a warning rather
97
+ * than pushed through to a confusing "service not found" error for a name
98
+ * the caller never typed.
99
+ */
100
+ function resolveServiceOrder(requestedNames, services, skipDeps) {
101
+ const resolved = [];
102
+ const visited = new Set();
103
+ function visit(name) {
104
+ if (visited.has(name))
105
+ return;
106
+ visited.add(name);
107
+ if (!skipDeps) {
108
+ const service = findService(services, name);
109
+ for (const dep of service?.requires || []) {
110
+ if (findService(services, dep)) {
111
+ visit(dep);
112
+ }
113
+ else {
114
+ output.warning(`${name} requires ${dep}, but it is not installable in this configuration — skipping it`);
115
+ }
116
+ }
117
+ }
118
+ resolved.push(name);
119
+ }
120
+ for (const name of requestedNames) {
121
+ visit(name);
122
+ }
123
+ return resolved;
124
+ }
88
125
  async function uninstallPrerequisites(service, verbose = false) {
89
126
  if (!service.prerequisiteUninstalls?.length)
90
127
  return;
@@ -204,7 +241,23 @@ export async function installArk(config, serviceNames = [], options = {}) {
204
241
  }
205
242
  // If specific services are requested, install only those services
206
243
  if (serviceNames.length > 0) {
207
- for (const serviceName of serviceNames) {
244
+ const installableServices = getInstallableServices(backend);
245
+ const skipDeps = options.deps === false;
246
+ const resolvedServiceNames = resolveServiceOrder(serviceNames, installableServices, skipDeps);
247
+ if (skipDeps) {
248
+ const skippedDeps = resolveServiceOrder(serviceNames, installableServices, false).filter((name) => !serviceNames.includes(name));
249
+ if (skippedDeps.length > 0) {
250
+ output.warning(`--no-deps: not installing ${skippedDeps.join(', ')} automatically; the requested service(s) may not behave as expected without them`);
251
+ }
252
+ }
253
+ else {
254
+ for (const name of resolvedServiceNames) {
255
+ if (!serviceNames.includes(name)) {
256
+ output.info(`${name} is required by a requested service, installing it first...`);
257
+ }
258
+ }
259
+ }
260
+ for (const serviceName of resolvedServiceNames) {
208
261
  // Check if it's a marketplace item
209
262
  if (isMarketplaceService(serviceName)) {
210
263
  const service = await getMarketplaceItem(serviceName);
@@ -249,19 +302,29 @@ export async function installArk(config, serviceNames = [], options = {}) {
249
302
  continue;
250
303
  }
251
304
  // Core ARK service
252
- const services = getInstallableServices(backend);
253
- const service = Object.values(services).find((s) => s.name === serviceName);
305
+ const service = findService(installableServices, serviceName);
254
306
  if (!service) {
255
307
  output.error(`service '${serviceName}' not found`);
256
308
  output.info('available services:');
257
- for (const s of Object.values(services)) {
309
+ for (const s of Object.values(installableServices)) {
258
310
  output.info(` ${s.name}`);
259
311
  }
260
312
  process.exit(1);
261
313
  }
314
+ // Apply the override only for a service actually missing one of its
315
+ // `requires` from this install — whether because --no-deps skipped it
316
+ // globally, or because a specific dependency wasn't installable (e.g.
317
+ // disabled in .arkrc.yaml) — not just because --no-deps was passed at
318
+ // all, which would wrongly override a service whose dependency IS
319
+ // being installed alongside it (e.g. `ark install ark-broker
320
+ // ark-tenant --no-deps`).
321
+ const missingRequires = (service.requires || []).some((dep) => !resolvedServiceNames.includes(dep));
262
322
  output.info(`installing ${service.name}...`);
263
323
  try {
264
- await installService(service, options.verbose, options.arkVersion, options.marketplaceVersion, backendInstallArgs(service, backend, postgresValues));
324
+ await installService(service, options.verbose, options.arkVersion, options.marketplaceVersion, [
325
+ ...backendInstallArgs(service, backend, postgresValues),
326
+ ...(missingRequires ? service.dependencyOverrideArgs || [] : []),
327
+ ]);
265
328
  output.success(`${service.name} installed successfully`);
266
329
  // Wait for ark-apiserver to be ready before continuing to other services
267
330
  if (service.helmReleaseName === 'ark-apiserver' && backend === 'postgresql') {
@@ -416,9 +479,13 @@ export async function installArk(config, serviceNames = [], options = {}) {
416
479
  }
417
480
  }
418
481
  }
419
- // Install selected services
420
- for (const serviceName of selectedComponents) {
421
- const service = Object.values(arkServices).find((s) => s.helmReleaseName === serviceName);
482
+ // Install selected services, dependencies first: `mandatory` only forces
483
+ // a service into `selectedComponents`, it says nothing about install
484
+ // order, so a `requires` dependency (e.g. ark-broker for ark-tenant) must
485
+ // still be resolved here even though every mandatory name is present.
486
+ const orderedComponents = resolveServiceOrder(selectedComponents, arkServices, false);
487
+ for (const serviceName of orderedComponents) {
488
+ const service = findService(arkServices, serviceName);
422
489
  if (!service || !service.chartPath) {
423
490
  continue;
424
491
  }
@@ -473,7 +540,9 @@ export async function installArk(config, serviceNames = [], options = {}) {
473
540
  process.exit(1);
474
541
  }
475
542
  }
476
- // Install all services
543
+ // Install all services. Every service is selected here, so alphabetical
544
+ // order happens to put ark-broker before ark-tenant today — resolve
545
+ // `requires` explicitly anyway rather than depend on that coincidence.
477
546
  const services = getInstallableServices(backend);
478
547
  const sortedServices = Object.values(services).sort((a, b) => {
479
548
  // Ensure ark-controller is always first
@@ -483,10 +552,22 @@ export async function installArk(config, serviceNames = [], options = {}) {
483
552
  return 1;
484
553
  return a.name.localeCompare(b.name);
485
554
  });
486
- for (const service of sortedServices) {
555
+ const orderedNames = resolveServiceOrder(sortedServices.map((s) => s.name), services, false);
556
+ const orderedServices = orderedNames
557
+ .map((name) => findService(services, name))
558
+ .filter((s) => Boolean(s));
559
+ for (const service of orderedServices) {
487
560
  output.info(`installing ${service.name}...`);
561
+ // A `requires` dependency disabled in .arkrc.yaml gets dropped (with a
562
+ // warning) by resolveServiceOrder above rather than installed — apply
563
+ // the same override as the named-service path so the dependent isn't
564
+ // then refused by its own chart-level guard.
565
+ const missingRequires = (service.requires || []).some((dep) => !orderedNames.includes(dep));
488
566
  try {
489
- await installService(service, options.verbose, options.arkVersion, options.marketplaceVersion, backendInstallArgs(service, backend, postgresValues));
567
+ await installService(service, options.verbose, options.arkVersion, options.marketplaceVersion, [
568
+ ...backendInstallArgs(service, backend, postgresValues),
569
+ ...(missingRequires ? service.dependencyOverrideArgs || [] : []),
570
+ ]);
490
571
  console.log(); // Add blank line after command output
491
572
  }
492
573
  catch (error) {
@@ -555,6 +636,7 @@ export function createInstallCommand(config) {
555
636
  .option('--marketplace-version <version>', 'Marketplace item version to install (e.g., 0.1.5)')
556
637
  .option('--wait-for-ready <timeout>', 'wait for Ark to be ready after installation (e.g., 30s, 2m)')
557
638
  .option('--backend <type>', "storage backend: 'etcd' (default) or 'postgresql' (overrides storage.backend in .arkrc.yaml)")
639
+ .option('--no-deps', "skip a requested service's dependencies (e.g. ark-broker for ark-tenant); the resulting install may lose the guarantees those dependencies provide")
558
640
  .option('-v, --verbose', 'show commands being executed')
559
641
  .action(async (services, options) => {
560
642
  await installArk(config, services, options);
@@ -5,7 +5,7 @@ import { StatusChecker } from '../../components/statusChecker.js';
5
5
  import { StatusFormatter, } from '../../ui/statusFormatter.js';
6
6
  import { fetchVersionInfo } from '../../lib/versions.js';
7
7
  import { waitForServicesReady, } from '../../lib/waitForReady.js';
8
- import { runReadinessChecks, describeStorageBackend, } from '../../lib/readinessChecks.js';
8
+ import { runReadinessChecks, describeStorageBackend, describeNamespaceMemory, } from '../../lib/readinessChecks.js';
9
9
  import { arkServices } from '../../arkServices.js';
10
10
  import output from '../../lib/output.js';
11
11
  import { parseTimeoutToSeconds } from '../../lib/timeout.js';
@@ -68,7 +68,26 @@ function backendStatusLine(detection) {
68
68
  details,
69
69
  };
70
70
  }
71
- function buildStatusSections(data, versionInfo, backend) {
71
+ function namespaceMemoryStatusLine(check) {
72
+ const display = {
73
+ ready: { icon: '●', color: 'green', details: check.message },
74
+ unresolved: { icon: '✗', color: 'red', details: 'not resolving' },
75
+ missing: { icon: '✗', color: 'red', details: 'missing' },
76
+ 'no-broker': { icon: '○', color: 'yellow', details: 'not configured' },
77
+ undetermined: { icon: '?', color: 'yellow', details: 'undetermined' },
78
+ };
79
+ const { icon, color, details } = display[check.status];
80
+ return {
81
+ icon,
82
+ iconColor: color,
83
+ status: 'default memory',
84
+ statusColor: color,
85
+ name: '',
86
+ details,
87
+ subtext: check.status === 'ready' ? undefined : check.message,
88
+ };
89
+ }
90
+ function buildStatusSections(data, versionInfo, backend, namespaceMemory) {
72
91
  const sections = [];
73
92
  // Dependencies section
74
93
  sections.push({
@@ -270,6 +289,9 @@ function buildStatusSections(data, versionInfo, backend) {
270
289
  }
271
290
  }
272
291
  }
292
+ if (namespaceMemory) {
293
+ arkStatusLines.push(namespaceMemoryStatusLine(namespaceMemory));
294
+ }
273
295
  if (backend) {
274
296
  arkStatusLines.push(backendStatusLine(backend));
275
297
  }
@@ -289,16 +311,24 @@ export async function checkStatus(serviceNames, options) {
289
311
  fetchVersionInfo(),
290
312
  ]);
291
313
  // Only probe for the storage backend if the cluster is reachable; probing an
292
- // unreachable cluster would just retry to its timeout.
293
- const detection = statusData.clusterAccess
294
- ? await describeStorageBackend()
295
- : {
296
- backend: 'unknown',
297
- status: 'unreachable',
298
- message: 'Cluster is not reachable — cannot determine the storage backend.',
299
- };
314
+ // unreachable cluster would just retry to its timeout. The memory check is
315
+ // scoped to the kubeconfig's current namespace: the invariant it reports on
316
+ // is per-namespace, and that is the namespace the user's queries land in.
317
+ // Both probes are independent, so they run together rather than in series.
318
+ const [detection, namespaceMemory] = await Promise.all([
319
+ statusData.clusterAccess
320
+ ? describeStorageBackend()
321
+ : Promise.resolve({
322
+ backend: 'unknown',
323
+ status: 'unreachable',
324
+ message: 'Cluster is not reachable — cannot determine the storage backend.',
325
+ }),
326
+ statusData.clusterAccess && statusData.arkReady
327
+ ? describeNamespaceMemory(statusData.clusterInfo?.namespace || 'default')
328
+ : Promise.resolve(undefined),
329
+ ]);
300
330
  spinner.stop();
301
- const sections = buildStatusSections(statusData, versionInfo, detection);
331
+ const sections = buildStatusSections(statusData, versionInfo, detection, namespaceMemory);
302
332
  StatusFormatter.printSections(sections);
303
333
  if (options?.waitForReady) {
304
334
  const timeoutSeconds = parseTimeoutToSeconds(options.waitForReady);
@@ -32,9 +32,6 @@ export class ChatClient {
32
32
  if (config.a2aContextId) {
33
33
  annotations[QUERY_ANNOTATIONS.A2A_CONTEXT_ID] = config.a2aContextId;
34
34
  }
35
- if (config.streamingEnabled && onChunk) {
36
- annotations['ark.mckinsey.com/streaming-enabled'] = 'true';
37
- }
38
35
  const queryResult = await this.arkApiClient.createQuery({
39
36
  input,
40
37
  target: { type: targetType, name: targetName },
@@ -12,7 +12,30 @@ export interface ReadinessCheckResult {
12
12
  durationMs: number;
13
13
  message?: string;
14
14
  }
15
+ export type NamespaceMemoryStatus = 'ready' | 'unresolved' | 'missing' | 'no-broker' | 'undetermined';
16
+ export interface NamespaceMemoryCheck {
17
+ status: NamespaceMemoryStatus;
18
+ namespace: string;
19
+ memoryName: string;
20
+ message: string;
21
+ }
15
22
  export type ReadinessProgress = (result: ReadinessCheckResult) => void;
16
23
  export declare function describeStorageBackend(): Promise<BackendDetection>;
24
+ /**
25
+ * Report whether the namespace ends up with a Memory the executor can use.
26
+ *
27
+ * Existence is not the question: NewHTTPMemory errors when
28
+ * status.lastResolvedAddress is empty, so a Memory left unresolved by a
29
+ * missing broker Service fails every query in the namespace rather than
30
+ * degrading. A namespace with no broker is expected to have no Memory, so that
31
+ * is reported as unconfigured rather than broken.
32
+ *
33
+ * ArkConfig.defaultMemory only changes the answer when the Memory it names
34
+ * both exists and resolves: resolveInjectableMemory declines to inject
35
+ * otherwise, and NewMemoryForQuery then falls back to the hardcoded name
36
+ * `default`. So the override is a candidate, and `default` decides the verdict
37
+ * whenever that candidate is not usable.
38
+ */
39
+ export declare function describeNamespaceMemory(namespace: string): Promise<NamespaceMemoryCheck>;
17
40
  export declare function detectStorageBackend(): Promise<DetectedBackend>;
18
41
  export declare function runReadinessChecks(timeoutSeconds: number, backend: DetectedBackend, onProgress?: ReadinessProgress): Promise<ReadinessCheckResult[]>;
@@ -14,8 +14,13 @@ async function runKubectl(args, timeoutMs) {
14
14
  stderr: result.stderr ?? '',
15
15
  };
16
16
  }
17
+ // A resource type the cluster does not serve at all. kubectl reports this
18
+ // differently from a missing object, and it is just as final an answer — the
19
+ // aggregated API server serves no arkconfigs, so this is the normal reply
20
+ // there rather than something to retry.
21
+ const UNSERVED_RESOURCE_PATTERN = /doesn't have a resource type|no matches for kind|the server could not find the requested resource/i;
17
22
  function classifyFailure(stderr) {
18
- if (/not\s*found/i.test(stderr)) {
23
+ if (/not\s*found/i.test(stderr) || UNSERVED_RESOURCE_PATTERN.test(stderr)) {
19
24
  return 'not-found';
20
25
  }
21
26
  if (/forbidden|unauthorized/i.test(stderr)) {
@@ -32,7 +37,8 @@ function isAuthoritativeResult(result) {
32
37
  if (result.exitCode === 0) {
33
38
  return true;
34
39
  }
35
- return /not\s*found|forbidden/i.test(result.stderr);
40
+ return (/not\s*found|forbidden/i.test(result.stderr) ||
41
+ UNSERVED_RESOURCE_PATTERN.test(result.stderr));
36
42
  }
37
43
  async function probeKubectl(args, timeoutMs, maxRetries = DETECT_MAX_RETRIES) {
38
44
  let result = await runKubectl(args, timeoutMs);
@@ -95,6 +101,236 @@ export async function describeStorageBackend() {
95
101
  }
96
102
  return unknownFrom(apiReason);
97
103
  }
104
+ // The Memory name the broker chart creates, and the one NewMemoryForQuery
105
+ // hardcodes when a Query carries no spec.memory. ArkConfig can name a
106
+ // different one, but only as a candidate — see describeNamespaceMemory.
107
+ const FALLBACK_MEMORY_NAME = 'default';
108
+ // Either ConfigMap is a broker presence signal, and only when its data carries
109
+ // enabled: "true" — the same test BrokerServiceRefFor applies before the
110
+ // controller's default-Memory backstop will act on it.
111
+ const BROKER_CONFIGMAP_NAMES = ['ark-config-broker', 'ark-config-streaming'];
112
+ // The ConfigMaps are not the whole story: they are rendered only when
113
+ // otelEndpoint.enabled / streaming.enabled are set, both of which default true
114
+ // but are independently disablable. A broker installed purely for message and
115
+ // session storage has neither, and reporting "no broker in this namespace"
116
+ // there is a confident claim that happens to be false. The label is the
117
+ // chart's rather than the release's, so a renamed release still matches. This
118
+ // mirrors DefaultMemoryReconciler's own two-signal lookup.
119
+ const BROKER_SERVICE_SELECTOR = 'app.kubernetes.io/name=ark-broker';
120
+ const BROKER_ENABLED_JSONPATH = '{range .items[*]}{.data.enabled}{"\\n"}{end}';
121
+ // kubectl reports a missing namespace and a missing object with the same
122
+ // NotFound status, so the two are told apart by the message. Reporting on a
123
+ // namespace that does not exist as "no broker here" would be a confident
124
+ // answer drawn from nothing.
125
+ function isMissingNamespace(stderr) {
126
+ return /namespaces?\s+"[^"]*"\s+not\s+found/i.test(stderr);
127
+ }
128
+ async function probeMemory(namespace, name) {
129
+ const result = await probeKubectl(['get', 'memory', name, '--namespace', namespace, '-o', 'json'], 10000);
130
+ if (result.exitCode !== 0) {
131
+ if (isMissingNamespace(result.stderr)) {
132
+ return {
133
+ outcome: 'undetermined',
134
+ detail: `namespace ${namespace} does not exist`,
135
+ };
136
+ }
137
+ const reason = classifyFailure(result.stderr);
138
+ if (reason === 'not-found') {
139
+ return { outcome: 'absent' };
140
+ }
141
+ return { outcome: 'undetermined', detail: reason };
142
+ }
143
+ let address = '';
144
+ let phase = '';
145
+ let statusMessage = '';
146
+ try {
147
+ const parsed = JSON.parse(result.stdout);
148
+ address = parsed?.status?.lastResolvedAddress || '';
149
+ phase = parsed?.status?.phase || '';
150
+ statusMessage = parsed?.status?.message || '';
151
+ }
152
+ catch {
153
+ return { outcome: 'undetermined', detail: 'unreadable kubectl output' };
154
+ }
155
+ if (address) {
156
+ return { outcome: 'resolved', address };
157
+ }
158
+ return {
159
+ outcome: 'unresolved',
160
+ detail: statusMessage || phase || 'no resolved address',
161
+ };
162
+ }
163
+ async function probeBrokerConfigMaps(namespace) {
164
+ const result = await probeKubectl([
165
+ 'get',
166
+ 'configmap',
167
+ ...BROKER_CONFIGMAP_NAMES,
168
+ '--namespace',
169
+ namespace,
170
+ '--ignore-not-found',
171
+ '-o',
172
+ `jsonpath=${BROKER_ENABLED_JSONPATH}`,
173
+ ], 10000);
174
+ if (result.exitCode !== 0) {
175
+ return 'undetermined';
176
+ }
177
+ const enabled = result.stdout
178
+ .split('\n')
179
+ .some((line) => line.trim() === 'true');
180
+ return enabled ? 'present' : 'absent';
181
+ }
182
+ async function probeBrokerService(namespace) {
183
+ const result = await probeKubectl([
184
+ 'get',
185
+ 'service',
186
+ '--namespace',
187
+ namespace,
188
+ '--selector',
189
+ BROKER_SERVICE_SELECTOR,
190
+ '-o',
191
+ 'name',
192
+ ], 10000);
193
+ if (result.exitCode !== 0) {
194
+ return 'undetermined';
195
+ }
196
+ return result.stdout.trim() ? 'present' : 'absent';
197
+ }
198
+ async function probeBroker(namespace) {
199
+ const fromConfigMaps = await probeBrokerConfigMaps(namespace);
200
+ if (fromConfigMaps === 'present') {
201
+ return fromConfigMaps;
202
+ }
203
+ const fromService = await probeBrokerService(namespace);
204
+ if (fromService === 'present') {
205
+ return fromService;
206
+ }
207
+ // Neither signal found it. Say so only if both probes actually answered.
208
+ if (fromConfigMaps === 'undetermined' || fromService === 'undetermined') {
209
+ return 'undetermined';
210
+ }
211
+ return 'absent';
212
+ }
213
+ // ArkConfig is cluster-scoped and optional, and the aggregated API server does
214
+ // not serve it at all — both of which classifyFailure reports as not-found, a
215
+ // legitimate "there is no override". "Could not tell" is different: a
216
+ // namespace-scoped user is forbidden from reading a cluster-scoped resource,
217
+ // and would otherwise be judged against the wrong name.
218
+ async function readDefaultMemoryOverride() {
219
+ const result = await probeKubectl([
220
+ 'get',
221
+ 'arkconfig',
222
+ 'default',
223
+ '-o',
224
+ 'jsonpath={.spec.defaultMemory.name}',
225
+ ], 10000);
226
+ if (result.exitCode === 0) {
227
+ const name = result.stdout.trim();
228
+ return name ? { kind: 'name', name } : { kind: 'none' };
229
+ }
230
+ if (classifyFailure(result.stderr) === 'not-found') {
231
+ return { kind: 'none' };
232
+ }
233
+ return { kind: 'undetermined' };
234
+ }
235
+ /**
236
+ * Report whether the namespace ends up with a Memory the executor can use.
237
+ *
238
+ * Existence is not the question: NewHTTPMemory errors when
239
+ * status.lastResolvedAddress is empty, so a Memory left unresolved by a
240
+ * missing broker Service fails every query in the namespace rather than
241
+ * degrading. A namespace with no broker is expected to have no Memory, so that
242
+ * is reported as unconfigured rather than broken.
243
+ *
244
+ * ArkConfig.defaultMemory only changes the answer when the Memory it names
245
+ * both exists and resolves: resolveInjectableMemory declines to inject
246
+ * otherwise, and NewMemoryForQuery then falls back to the hardcoded name
247
+ * `default`. So the override is a candidate, and `default` decides the verdict
248
+ * whenever that candidate is not usable.
249
+ */
250
+ export async function describeNamespaceMemory(namespace) {
251
+ const override = await readDefaultMemoryOverride();
252
+ if (override.kind === 'name' && override.name !== FALLBACK_MEMORY_NAME) {
253
+ const probe = await probeMemory(namespace, override.name);
254
+ if (probe.outcome === 'resolved') {
255
+ return {
256
+ namespace,
257
+ memoryName: override.name,
258
+ status: 'ready',
259
+ message: probe.address,
260
+ };
261
+ }
262
+ if (probe.outcome === 'undetermined') {
263
+ return {
264
+ namespace,
265
+ memoryName: override.name,
266
+ status: 'undetermined',
267
+ message: `Could not read Memory "${override.name}" in namespace ${namespace} (${probe.detail}).`,
268
+ };
269
+ }
270
+ }
271
+ const unusedOverride = override.kind === 'name' && override.name !== FALLBACK_MEMORY_NAME
272
+ ? override.name
273
+ : undefined;
274
+ const check = await describeFallbackMemory(namespace, unusedOverride);
275
+ // The override read failed, so this verdict may be judging the wrong name.
276
+ // Only downgrade one that accuses the namespace of being broken.
277
+ if (override.kind === 'undetermined' &&
278
+ (check.status === 'missing' || check.status === 'unresolved')) {
279
+ return {
280
+ ...check,
281
+ status: 'undetermined',
282
+ message: `${check.message} ArkConfig could not be read, so a cluster-wide default memory name may apply instead.`,
283
+ };
284
+ }
285
+ return check;
286
+ }
287
+ async function describeFallbackMemory(namespace, unusedOverride) {
288
+ const base = { namespace, memoryName: FALLBACK_MEMORY_NAME };
289
+ // The operator asked for a different name and is not getting it. Without
290
+ // saying so, the remediation below reads as "create Memory/default", which
291
+ // is right for the executor and confusing next to their own ArkConfig.
292
+ const overrideNote = unusedOverride
293
+ ? ` ArkConfig names "${unusedOverride}", which is not usable here, so resolution falls back to "${FALLBACK_MEMORY_NAME}".`
294
+ : '';
295
+ const probe = await probeMemory(namespace, FALLBACK_MEMORY_NAME);
296
+ if (probe.outcome === 'resolved') {
297
+ return { ...base, status: 'ready', message: probe.address };
298
+ }
299
+ if (probe.outcome === 'undetermined') {
300
+ return {
301
+ ...base,
302
+ status: 'undetermined',
303
+ message: `Could not read Memory "${FALLBACK_MEMORY_NAME}" in namespace ${namespace} (${probe.detail}).`,
304
+ };
305
+ }
306
+ if (probe.outcome === 'unresolved') {
307
+ return {
308
+ ...base,
309
+ status: 'unresolved',
310
+ message: `Memory "${FALLBACK_MEMORY_NAME}" in namespace ${namespace} has not resolved an address (${probe.detail}); queries using it will fail.${overrideNote}`,
311
+ };
312
+ }
313
+ const broker = await probeBroker(namespace);
314
+ if (broker === 'undetermined') {
315
+ return {
316
+ ...base,
317
+ status: 'undetermined',
318
+ message: `Could not determine whether namespace ${namespace} has a broker, so a missing Memory cannot be judged.`,
319
+ };
320
+ }
321
+ if (broker === 'present') {
322
+ return {
323
+ ...base,
324
+ status: 'missing',
325
+ message: `Namespace ${namespace} has a broker but no Memory "${FALLBACK_MEMORY_NAME}"; chats there will silently lose conversation history.${overrideNote}`,
326
+ };
327
+ }
328
+ return {
329
+ ...base,
330
+ status: 'no-broker',
331
+ message: `No broker in namespace ${namespace}, so no conversation history is kept there.`,
332
+ };
333
+ }
98
334
  export async function detectStorageBackend() {
99
335
  return (await describeStorageBackend()).backend;
100
336
  }
@@ -13,6 +13,8 @@ export interface ArkService {
13
13
  chartPath?: string;
14
14
  installArgs?: string[];
15
15
  prerequisiteUninstalls?: PrerequisiteUninstall[];
16
+ requires?: string[];
17
+ dependencyOverrideArgs?: string[];
16
18
  k8sServiceName?: string;
17
19
  k8sServicePort?: number;
18
20
  k8sPortForwardLocalPort?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-at-scale/ark",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
4
4
  "description": "Ark CLI - Interactive terminal interface for ARK agents",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -43,8 +43,8 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@kubernetes/client-node": "^1.3.0",
46
- "@modelcontextprotocol/sdk": "^1.27.1",
47
- "axios": "^1.18.0",
46
+ "@modelcontextprotocol/sdk": "^1.32.0",
47
+ "axios": "^1.20.0",
48
48
  "chalk": "^4.1.2",
49
49
  "commander": "^12.1.0",
50
50
  "debug": "^4.4.1",
@@ -85,7 +85,7 @@
85
85
  "node": ">=18.0.0"
86
86
  },
87
87
  "overrides": {
88
- "brace-expansion": ">=5.0.9",
88
+ "brace-expansion": "^5.0.12",
89
89
  "ip-address": "^10.3.1",
90
90
  "js-yaml": "^4.3.1",
91
91
  "minimatch": "^10.2.3",
@@ -95,10 +95,12 @@
95
95
  "flatted": "^3.4.2",
96
96
  "tar": "^7.5.11",
97
97
  "express-rate-limit": "^8.3.0",
98
- "fast-uri": "^3.1.5",
98
+ "fast-uri": "^4.1.4",
99
99
  "ws": "^8.21.0",
100
100
  "esbuild": "^0.28.1",
101
101
  "form-data": "^4.0.6",
102
- "postcss": "^8.5.18"
102
+ "postcss": "^8.5.18",
103
+ "proxy-addr": "^2.0.8",
104
+ "source-map-js": "^1.2.2"
103
105
  }
104
106
  }
@@ -31,16 +31,15 @@ wheels = [
31
31
 
32
32
  [[package]]
33
33
  name = "anyio"
34
- version = "4.9.0"
34
+ version = "4.14.2"
35
35
  source = { registry = "https://pypi.org/simple" }
36
36
  dependencies = [
37
37
  { name = "idna" },
38
- { name = "sniffio" },
39
38
  { name = "typing-extensions", marker = "python_full_version < '3.13'" },
40
39
  ]
41
- sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" }
40
+ sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
42
41
  wheels = [
43
- { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
42
+ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
44
43
  ]
45
44
 
46
45
  [[package]]
@@ -1104,15 +1103,6 @@ wheels = [
1104
1103
  { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
1105
1104
  ]
1106
1105
 
1107
- [[package]]
1108
- name = "sniffio"
1109
- version = "1.3.1"
1110
- source = { registry = "https://pypi.org/simple" }
1111
- sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
1112
- wheels = [
1113
- { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
1114
- ]
1115
-
1116
1106
  [[package]]
1117
1107
  name = "sse-starlette"
1118
1108
  version = "2.3.6"