@adhdev/daemon-core 0.9.82-rc.315 → 0.9.82-rc.317
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/dist/cli-adapter-types.d.ts +23 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +9 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +309 -48
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +308 -52
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-runtime-store.d.ts +6 -0
- package/dist/mesh/mesh-work-queue.d.ts +22 -0
- package/dist/repo-mesh-types.d.ts +68 -0
- package/package.json +2 -2
- package/src/cli-adapter-types.ts +25 -0
- package/src/cli-adapters/provider-cli-adapter.ts +36 -1
- package/src/cli-adapters/provider-cli-shared.ts +19 -2
- package/src/commands/router.ts +42 -1
- package/src/config/mesh-config.ts +72 -1
- package/src/git/git-status.ts +122 -41
- package/src/index.ts +7 -1
- package/src/mesh/mesh-events-coordinator.ts +5 -1
- package/src/mesh/mesh-reconcile-loop.ts +31 -1
- package/src/mesh/mesh-runtime-store.ts +13 -0
- package/src/mesh/mesh-work-queue.ts +89 -11
- package/src/repo-mesh-types.ts +112 -0
|
@@ -48,7 +48,7 @@ import { LOG } from '../logging/logger.js';
|
|
|
48
48
|
import { drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
|
|
49
49
|
import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
50
50
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
51
|
-
import { handleMeshForwardEvent, shouldForceInjectMeshEvent, MESH_FORCE_INJECT_EVENTS } from './mesh-events-coordinator.js';
|
|
51
|
+
import { handleMeshForwardEvent, shouldForceInjectMeshEvent, MESH_FORCE_INJECT_EVENTS, triggerMeshQueue } from './mesh-events-coordinator.js';
|
|
52
52
|
import {
|
|
53
53
|
peekUnresolvedDelegateForwards,
|
|
54
54
|
ackUnresolvedDelegateForward,
|
|
@@ -251,6 +251,36 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
// ── PHASE 3: recover pending queue claims for newly-idle sessions ──────────
|
|
255
|
+
// The event-driven claim paths (agent:ready / agent:generating_completed in
|
|
256
|
+
// mesh-events-coordinator) re-claim the queue the moment a session goes idle,
|
|
257
|
+
// but that depends on a single event being emitted AND (for a remote node)
|
|
258
|
+
// successfully forwarded to this coordinator. If that event is missed/dropped,
|
|
259
|
+
// a pending task targeting a now-idle session would sit unclaimed forever —
|
|
260
|
+
// there was no periodic safety net. This phase is that net: for every mesh this
|
|
261
|
+
// daemon hosts that has at least one pending task, run one triggerMeshQueue so a
|
|
262
|
+
// session that became idle without a delivered ready-event still gets its work.
|
|
263
|
+
//
|
|
264
|
+
// O(1) guard: skip the (relatively expensive) full idle-session + remote-idle
|
|
265
|
+
// scan entirely when the queue has no pending tasks — a COUNT(*) over the
|
|
266
|
+
// indexed status column, so an idle mesh costs one cheap query per tick.
|
|
267
|
+
// claimNextQueueTask is atomic, so racing the event-driven path can only have
|
|
268
|
+
// one winner; double-claiming is impossible.
|
|
269
|
+
for (const mesh of listMeshes()) {
|
|
270
|
+
const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
|
|
271
|
+
if (!daemonHostsMesh(mesh, selfIds)) continue;
|
|
272
|
+
if (store) {
|
|
273
|
+
try {
|
|
274
|
+
if (store.pendingQueueTaskCount(mesh.id) === 0) continue;
|
|
275
|
+
} catch { /* fall through and let triggerMeshQueue decide */ }
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
await triggerMeshQueue(components, mesh.id);
|
|
279
|
+
} catch (e: any) {
|
|
280
|
+
LOG.warn('MeshReconcile', `Pending-claim recovery trigger failed for mesh ${mesh.id}: ${e?.message || e}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
254
284
|
// ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
|
|
255
285
|
const coordinators = findLiveCoordinators(components);
|
|
256
286
|
if (coordinators.length === 0) {
|
|
@@ -504,6 +504,19 @@ export class MeshRuntimeStore {
|
|
|
504
504
|
return row?.count ?? 0;
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
+
/**
|
|
508
|
+
* O(1) count of queue tasks in 'pending' status for a mesh. A COUNT(*) over the
|
|
509
|
+
* indexed status column, so it avoids JSON.parse-ing every queue row — used as a
|
|
510
|
+
* cheap guard before the reconcile loop runs a full triggerMeshQueue scan.
|
|
511
|
+
*/
|
|
512
|
+
pendingQueueTaskCount(meshId: string): number {
|
|
513
|
+
const row = this.db.prepare(`
|
|
514
|
+
SELECT COUNT(*) as count FROM mesh_queue
|
|
515
|
+
WHERE mesh_id = ? AND status = 'pending'
|
|
516
|
+
`).get(meshId) as { count: number } | undefined;
|
|
517
|
+
return row?.count ?? 0;
|
|
518
|
+
}
|
|
519
|
+
|
|
507
520
|
/**
|
|
508
521
|
* Read the current per-mesh round-robin cursor (0 when unset). Used to rotate
|
|
509
522
|
* the tie-break winner among nodes tied at the least load.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
2
|
import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
|
|
3
|
-
import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
|
|
4
|
-
import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo-mesh-types.js';
|
|
3
|
+
import type { RepoMeshDaemonRole, LocalMeshNodeEntry } from '../repo-mesh-types.js';
|
|
4
|
+
import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange, resolveTaskAffinityRole } from '../repo-mesh-types.js';
|
|
5
5
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
6
6
|
import { getMesh } from '../config/mesh-config.js';
|
|
7
7
|
|
|
@@ -328,6 +328,67 @@ export function resolveConvergeRequiredTags(
|
|
|
328
328
|
return normalizeMeshCapabilityTags([...explicitRequiredTags, MESH_CONVERGE_REFINE_TAG]);
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Whether any node in the mesh advertises the given `role=<role>` capability tag.
|
|
333
|
+
* Reuses buildMeshNodeCapabilityTags (node-level scan: every declared providerRole is
|
|
334
|
+
* advertised) so the membership test matches the exact tag the claim/eligibility filter
|
|
335
|
+
* would see. Used to keep task-affinity SOFT — if no node can satisfy the role, the
|
|
336
|
+
* injection is skipped so the task falls back to ordinary least_loaded eligibility
|
|
337
|
+
* rather than being blocked.
|
|
338
|
+
*/
|
|
339
|
+
function meshHasNodeAdvertisingRole(nodes: LocalMeshNodeEntry[] | undefined, role: string): boolean {
|
|
340
|
+
if (!Array.isArray(nodes) || nodes.length === 0) return false;
|
|
341
|
+
const wanted = `role=${role}`;
|
|
342
|
+
return nodes.some(node => buildMeshNodeCapabilityTags(node as any).includes(wanted));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Declarative task_mode → role affinity resolution (precedent: resolveConvergeRequiredTags).
|
|
347
|
+
*
|
|
348
|
+
* At enqueue time, auto-inject a `role=<role>` required tag derived from the task's
|
|
349
|
+
* taskMode via the mesh's policy.taskAffinity (falling back to DEFAULT_TASKMODE_ROLE_MAP),
|
|
350
|
+
* so the task hard-filters onto nodes advertising that role through the ordinary
|
|
351
|
+
* required-tags path (nodeSatisfiesRequiredTags) — no claim/scheduler change needed.
|
|
352
|
+
*
|
|
353
|
+
* Strict precedence / backward compatibility — the injection is skipped (returns the
|
|
354
|
+
* explicit tags unchanged) when ANY of:
|
|
355
|
+
* - the caller pinned an explicit targetNodeId (operator chose the node), or
|
|
356
|
+
* - the caller supplied their own non-empty required_tags (caller's routing wins), or
|
|
357
|
+
* - affinity resolves to no role (policy disabled, blank override, or unknown mode), or
|
|
358
|
+
* - SOFT fallback: no node in the mesh advertises the resolved role=<role> — injecting
|
|
359
|
+
* would block the task with zero eligible nodes, so we skip it and let least_loaded
|
|
360
|
+
* eligibility take over (a warning is logged so the misconfiguration is visible).
|
|
361
|
+
* Idempotent: normalizeMeshCapabilityTags dedupes.
|
|
362
|
+
*/
|
|
363
|
+
export function resolveTaskAffinityRequiredTags(
|
|
364
|
+
meshId: string,
|
|
365
|
+
taskMode: MeshTaskMode | undefined,
|
|
366
|
+
explicitRequiredTags: string[],
|
|
367
|
+
opts?: { targetNodeId?: string; callerSpecifiedRequiredTags?: boolean },
|
|
368
|
+
): string[] {
|
|
369
|
+
// Caller pinned the node, or brought their own required_tags → respect it verbatim.
|
|
370
|
+
if (typeof opts?.targetNodeId === 'string' && opts.targetNodeId.trim()) return explicitRequiredTags;
|
|
371
|
+
if (opts?.callerSpecifiedRequiredTags === true) return explicitRequiredTags;
|
|
372
|
+
|
|
373
|
+
let mesh;
|
|
374
|
+
try {
|
|
375
|
+
mesh = getMesh(meshId);
|
|
376
|
+
} catch {
|
|
377
|
+
return explicitRequiredTags;
|
|
378
|
+
}
|
|
379
|
+
const role = resolveTaskAffinityRole(taskMode, mesh?.policy?.taskAffinity);
|
|
380
|
+
if (!role) return explicitRequiredTags;
|
|
381
|
+
|
|
382
|
+
// SOFT affinity: only hard-filter when the role actually exists in the mesh.
|
|
383
|
+
if (!meshHasNodeAdvertisingRole(mesh?.nodes, role)) {
|
|
384
|
+
try {
|
|
385
|
+
console.warn(`[mesh] task_affinity: no node advertises role=${role} for taskMode=${taskMode} in mesh ${meshId}; skipping injection (least_loaded fallback)`);
|
|
386
|
+
} catch { /* logging is best-effort */ }
|
|
387
|
+
return explicitRequiredTags;
|
|
388
|
+
}
|
|
389
|
+
return normalizeMeshCapabilityTags([...explicitRequiredTags, `role=${role}`]);
|
|
390
|
+
}
|
|
391
|
+
|
|
331
392
|
function withQueueLock<T>(_meshId: string, fn: () => T): T {
|
|
332
393
|
return MeshRuntimeStore.getInstance().transaction(fn);
|
|
333
394
|
}
|
|
@@ -413,6 +474,31 @@ export function enqueueTask(
|
|
|
413
474
|
throw new Error(`duplicate_task_id: task '${id}' already exists in mesh '${meshId}'`);
|
|
414
475
|
}
|
|
415
476
|
assertNoDependencyCycle(meshId, id, dependsOn);
|
|
477
|
+
// Did the caller bring their own routing tags? Affinity auto-injection is
|
|
478
|
+
// suppressed when so (caller's required_tags win), matching the spec — measured
|
|
479
|
+
// against the ORIGINAL caller tags, before convergence augmentation below.
|
|
480
|
+
const callerTags = normalizeMeshCapabilityTags(opts?.requiredTags);
|
|
481
|
+
const callerSpecifiedRequiredTags = callerTags.length > 0;
|
|
482
|
+
// Convergence routing (opt-in): auto-inject converge=refine for code_change
|
|
483
|
+
// tasks so they hard-filter onto refine-capable worktree nodes. No-op unless
|
|
484
|
+
// the mesh opts in; explicit target_node_id / required_tags are preserved.
|
|
485
|
+
let resolvedRequiredTags = resolveConvergeRequiredTags(
|
|
486
|
+
meshId,
|
|
487
|
+
modeValidation.taskMode,
|
|
488
|
+
callerTags,
|
|
489
|
+
{ targetNodeId: opts?.targetNodeId },
|
|
490
|
+
);
|
|
491
|
+
// Declarative task_mode → role affinity (precedent: convergence routing above):
|
|
492
|
+
// inject role=<role> for the task's taskMode unless the caller pinned a node or
|
|
493
|
+
// supplied their own required_tags. SOFT — skipped when no node advertises the
|
|
494
|
+
// role (least_loaded fallback). Built-in default mapping applies even without a
|
|
495
|
+
// taskAffinity config block; { enabled:false } disables it.
|
|
496
|
+
resolvedRequiredTags = resolveTaskAffinityRequiredTags(
|
|
497
|
+
meshId,
|
|
498
|
+
modeValidation.taskMode,
|
|
499
|
+
resolvedRequiredTags,
|
|
500
|
+
{ targetNodeId: opts?.targetNodeId, callerSpecifiedRequiredTags },
|
|
501
|
+
);
|
|
416
502
|
const entry: MeshWorkQueueEntry = {
|
|
417
503
|
id,
|
|
418
504
|
meshId,
|
|
@@ -421,15 +507,7 @@ export function enqueueTask(
|
|
|
421
507
|
taskMode: modeValidation.taskMode,
|
|
422
508
|
targetNodeId: opts?.targetNodeId,
|
|
423
509
|
targetSessionId: opts?.targetSessionId,
|
|
424
|
-
|
|
425
|
-
// tasks so they hard-filter onto refine-capable worktree nodes. No-op unless
|
|
426
|
-
// the mesh opts in; explicit target_node_id / required_tags are preserved.
|
|
427
|
-
requiredTags: resolveConvergeRequiredTags(
|
|
428
|
-
meshId,
|
|
429
|
-
modeValidation.taskMode,
|
|
430
|
-
normalizeMeshCapabilityTags(opts?.requiredTags),
|
|
431
|
-
{ targetNodeId: opts?.targetNodeId },
|
|
432
|
-
),
|
|
510
|
+
requiredTags: resolvedRequiredTags,
|
|
433
511
|
...(dependsOn.length > 0 ? { dependsOn } : {}),
|
|
434
512
|
...(typeof opts?.missionId === 'string' && opts.missionId.trim() ? { missionId: opts.missionId.trim() } : {}),
|
|
435
513
|
createdAt: new Date().toISOString(),
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -165,6 +165,106 @@ export function resolveNodeSchedulingPriority(
|
|
|
165
165
|
export const MESH_CONVERGE_REFINE_TAG = 'converge=refine';
|
|
166
166
|
export const MESH_CONVERGE_FAST_FORWARD_TAG = 'converge=fast_forward';
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Standard mesh resource-pool roles. These are the dashboard-provided defaults for
|
|
170
|
+
* the role=<x> routing tag (advertised by node policy.providerRoles and matched
|
|
171
|
+
* through nodeSatisfiesRequiredTags). A mesh is free to declare additional custom
|
|
172
|
+
* role strings in policy.taskAffinity; the dashboard role dropdown exposes the union
|
|
173
|
+
* of these four standards plus any config-declared roles. Roles are lowercased
|
|
174
|
+
* everywhere (the role=<x> tag is lowercased in roleCapabilityTags).
|
|
175
|
+
*/
|
|
176
|
+
export const STANDARD_MESH_ROLES = ['investigator', 'coder', 'validator', 'converger'] as const;
|
|
177
|
+
export type StandardMeshRole = typeof STANDARD_MESH_ROLES[number];
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Default taskMode → role mapping (all five task modes). A task enqueued with a
|
|
181
|
+
* given taskMode auto-routes to a node advertising the mapped role=<x> tag, unless
|
|
182
|
+
* the mesh overrides the mapping in policy.taskAffinity.byTaskMode. Keyed by the
|
|
183
|
+
* MeshTaskMode string literals (the canonical union lives in mesh-work-queue.ts;
|
|
184
|
+
* this map is keyed by string to avoid a types ↔ queue import cycle). Kept in sync
|
|
185
|
+
* with MeshTaskMode by the exhaustive lookup in resolveTaskAffinityRole.
|
|
186
|
+
*/
|
|
187
|
+
export const DEFAULT_TASKMODE_ROLE_MAP: Readonly<Record<string, StandardMeshRole>> = {
|
|
188
|
+
live_debug_readonly: 'investigator',
|
|
189
|
+
code_change: 'coder',
|
|
190
|
+
launch_app: 'coder',
|
|
191
|
+
validation: 'validator',
|
|
192
|
+
convergence: 'converger',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Declarative task_mode → role affinity policy. When set, enqueueTask auto-injects a
|
|
197
|
+
* `role=<role>` required tag (see resolveTaskAffinityRequiredTags) so the task
|
|
198
|
+
* hard-filters onto nodes advertising that role — but only when the caller did NOT
|
|
199
|
+
* already pin the task with an explicit target_node_id or its own required_tags.
|
|
200
|
+
*
|
|
201
|
+
* - `enabled`: master switch. Defaults to true so the built-in DEFAULT_TASKMODE_ROLE_MAP
|
|
202
|
+
* takes effect even when a mesh declares no taskAffinity block at all. Set false to
|
|
203
|
+
* fully disable affinity routing (today's pre-affinity behavior).
|
|
204
|
+
* - `byTaskMode`: per-taskMode override of the default mapping. A role string here may
|
|
205
|
+
* be any of STANDARD_MESH_ROLES or a custom role declared by the operator. An empty
|
|
206
|
+
* string disables affinity for that one task mode.
|
|
207
|
+
* - `customRoles`: extra (non-standard) role strings to surface in the dashboard role
|
|
208
|
+
* dropdown. Routing itself does not require a role to be pre-declared here — any role
|
|
209
|
+
* referenced in byTaskMode is injected regardless; customRoles only widens the UI list.
|
|
210
|
+
*/
|
|
211
|
+
export interface RepoMeshTaskAffinityPolicy {
|
|
212
|
+
enabled?: boolean;
|
|
213
|
+
byTaskMode?: Record<string, string>;
|
|
214
|
+
customRoles?: string[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolve the affinity role for a task mode against a mesh's taskAffinity policy.
|
|
219
|
+
* Returns the lowercased role string to inject as `role=<role>`, or null when no
|
|
220
|
+
* affinity applies (affinity disabled, an explicit empty override, or an unknown mode).
|
|
221
|
+
*
|
|
222
|
+
* Precedence: policy.byTaskMode[mode] (operator override) → DEFAULT_TASKMODE_ROLE_MAP[mode].
|
|
223
|
+
* A blank override string explicitly opts that mode out of affinity routing.
|
|
224
|
+
*/
|
|
225
|
+
export function resolveTaskAffinityRole(
|
|
226
|
+
taskMode: string | undefined,
|
|
227
|
+
policy: RepoMeshTaskAffinityPolicy | null | undefined,
|
|
228
|
+
): string | null {
|
|
229
|
+
if (!taskMode) return null;
|
|
230
|
+
if (policy?.enabled === false) return null;
|
|
231
|
+
const override = policy?.byTaskMode && Object.prototype.hasOwnProperty.call(policy.byTaskMode, taskMode)
|
|
232
|
+
? policy.byTaskMode[taskMode]
|
|
233
|
+
: undefined;
|
|
234
|
+
if (typeof override === 'string') {
|
|
235
|
+
const trimmed = override.trim().toLowerCase();
|
|
236
|
+
// An explicit blank override opts this task mode out of affinity routing.
|
|
237
|
+
return trimmed ? trimmed : null;
|
|
238
|
+
}
|
|
239
|
+
const fallback = DEFAULT_TASKMODE_ROLE_MAP[taskMode];
|
|
240
|
+
return fallback ?? null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Union of the standard roles plus any operator-declared roles (byTaskMode values +
|
|
245
|
+
* customRoles), lowercased and deduped, preserving standard-first ordering. Used by
|
|
246
|
+
* the dashboard role dropdown so operators can pick a standard or a config-declared
|
|
247
|
+
* custom role. Pure/UI-facing — does not gate routing.
|
|
248
|
+
*/
|
|
249
|
+
export function resolveMeshRoleOptions(policy: RepoMeshTaskAffinityPolicy | null | undefined): string[] {
|
|
250
|
+
const out: string[] = [...STANDARD_MESH_ROLES];
|
|
251
|
+
const seen = new Set<string>(out);
|
|
252
|
+
const add = (raw: unknown) => {
|
|
253
|
+
if (typeof raw !== 'string') return;
|
|
254
|
+
const role = raw.trim().toLowerCase();
|
|
255
|
+
if (!role || seen.has(role)) return;
|
|
256
|
+
seen.add(role);
|
|
257
|
+
out.push(role);
|
|
258
|
+
};
|
|
259
|
+
if (policy?.byTaskMode) {
|
|
260
|
+
for (const value of Object.values(policy.byTaskMode)) add(value);
|
|
261
|
+
}
|
|
262
|
+
if (Array.isArray(policy?.customRoles)) {
|
|
263
|
+
for (const value of policy.customRoles) add(value);
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
168
268
|
/**
|
|
169
269
|
* Resolve whether the load-balancing scheduler should auto-inject a
|
|
170
270
|
* `converge=refine` required tag onto code_change tasks so they hard-filter onto
|
|
@@ -220,6 +320,18 @@ export interface RepoMeshPolicy {
|
|
|
220
320
|
* Defaults to false: code_change routing is unchanged unless opted in.
|
|
221
321
|
*/
|
|
222
322
|
autoConvergeCodeChange?: boolean;
|
|
323
|
+
/**
|
|
324
|
+
* Declarative task_mode → role affinity routing. When present (and not disabled),
|
|
325
|
+
* enqueueTask auto-injects a `role=<role>` required tag for the task's taskMode so
|
|
326
|
+
* the work hard-filters onto nodes advertising that role. Explicit target_node_id
|
|
327
|
+
* routing and any caller-supplied required_tags are preserved (auto-injection only
|
|
328
|
+
* applies when the caller pinned neither). When the mapped role has no advertising
|
|
329
|
+
* node in the mesh, the task is NOT blocked — the role tag is skipped so the work
|
|
330
|
+
* falls back to ordinary least_loaded eligibility (soft affinity). Defaults to the
|
|
331
|
+
* built-in DEFAULT_TASKMODE_ROLE_MAP even when this block is omitted; set
|
|
332
|
+
* `{ enabled: false }` to fully restore pre-affinity routing.
|
|
333
|
+
*/
|
|
334
|
+
taskAffinity?: RepoMeshTaskAffinityPolicy;
|
|
223
335
|
/**
|
|
224
336
|
* Whether sessions spawned by mesh/coordinator policy should auto-open as visible
|
|
225
337
|
* dashboard tabs or start hidden. Defaults to 'visible' to preserve existing
|