@adhdev/daemon-core 0.9.82-rc.407 → 0.9.82-rc.409
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/git/git-status.d.ts +34 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +221 -95
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +220 -95
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-queue-assignment.d.ts +1 -1
- package/dist/mesh/mesh-work-queue.d.ts +35 -1
- package/dist/providers/chat-message-normalization.d.ts +18 -0
- package/package.json +2 -2
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +11 -1
- package/src/commands/chat-commands-write.ts +11 -0
- package/src/git/git-commands.ts +4 -1
- package/src/git/git-status.ts +219 -31
- package/src/index.ts +1 -1
- package/src/mesh/mesh-fast-forward.ts +5 -1
- package/src/mesh/mesh-queue-assignment.ts +8 -8
- package/src/mesh/mesh-reconcile-loop.ts +151 -94
- package/src/mesh/mesh-refine-gates.ts +6 -0
- package/src/mesh/mesh-runtime-store.ts +6 -5
- package/src/mesh/mesh-scheduling-runtime.ts +4 -7
- package/src/mesh/mesh-work-queue.ts +49 -8
- package/src/providers/chat-message-normalization.ts +53 -5
- package/src/providers/cli-provider-instance.ts +49 -8
|
@@ -6,7 +6,7 @@ export declare function tryAssignQueueTask(components: DaemonComponents, meshId:
|
|
|
6
6
|
/** Active assignments that hold the one-active-per-node / global-parallel invariant
|
|
7
7
|
* (everything except read-only diagnoses, which run unbounded by the write cap). */
|
|
8
8
|
export declare function activeWriteAssignedCount(meshId: string): number;
|
|
9
|
-
/** Active read-only
|
|
9
|
+
/** Active read-only assignments, for the read-only safety cap. */
|
|
10
10
|
export declare function activeReadonlyAssignedCount(meshId: string): number;
|
|
11
11
|
/**
|
|
12
12
|
* Order eligible nodes for assignment per the mesh scheduling pipeline:
|
|
@@ -7,6 +7,28 @@ export type MeshTaskMode = 'code_change' | 'validation' | 'live_debug_readonly'
|
|
|
7
7
|
export declare const ACTIVE_MESH_QUEUE_STATUSES: MeshActiveTaskStatus[];
|
|
8
8
|
export declare const HISTORICAL_MESH_QUEUE_STATUSES: MeshHistoricalTaskStatus[];
|
|
9
9
|
export declare const MESH_TASK_MODES: MeshTaskMode[];
|
|
10
|
+
/**
|
|
11
|
+
* QUEUE-NODE-SERIALIZATION: single source of truth for "is this task read-only?".
|
|
12
|
+
*
|
|
13
|
+
* Read-only classification used to be inlined as `task.taskMode === 'live_debug_readonly'`
|
|
14
|
+
* at every enforcement site (node-conflict claim gate, auto-launch isolation, the
|
|
15
|
+
* write/readonly cap counters, the write guardrail). That spread-out comparison is the
|
|
16
|
+
* exact recurring-defect class — one site drifting from the others silently makes the same
|
|
17
|
+
* task read-only at some gates and write at others, i.e. partial serialization. All sites
|
|
18
|
+
* MUST call this predicate so the classification is decided in exactly one place.
|
|
19
|
+
*
|
|
20
|
+
* Two orthogonal inputs feed the same boolean axis (kept backward-compatible):
|
|
21
|
+
* • `readonly === true` — the explicit boolean axis (new API surface).
|
|
22
|
+
* • `taskMode === 'live_debug_readonly'` — the original enum value, preserved as an
|
|
23
|
+
* OR-fallback so existing live_debug_readonly tasks keep behaving identically.
|
|
24
|
+
*
|
|
25
|
+
* Accepts any task-like shape (full {@link MeshWorkQueueEntry} or a bare
|
|
26
|
+
* `{ readonly?, taskMode? }`) so the daemon-core and mcp-server boundaries can share it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isTaskReadonly(task: {
|
|
29
|
+
readonly?: boolean;
|
|
30
|
+
taskMode?: MeshTaskMode | string;
|
|
31
|
+
} | null | undefined): boolean;
|
|
10
32
|
export interface MeshTaskModeValidationResult {
|
|
11
33
|
valid: boolean;
|
|
12
34
|
taskMode?: MeshTaskMode;
|
|
@@ -14,13 +36,21 @@ export interface MeshTaskModeValidationResult {
|
|
|
14
36
|
allowedOperations?: string[];
|
|
15
37
|
}
|
|
16
38
|
export declare function normalizeMeshTaskMode(value: unknown): MeshTaskMode | undefined;
|
|
17
|
-
export declare function validateMeshTaskModeRequest(mode: unknown, message: string): MeshTaskModeValidationResult;
|
|
39
|
+
export declare function validateMeshTaskModeRequest(mode: unknown, message: string, readonly?: boolean): MeshTaskModeValidationResult;
|
|
18
40
|
export interface MeshWorkQueueEntry {
|
|
19
41
|
id: string;
|
|
20
42
|
meshId: string;
|
|
21
43
|
message: string;
|
|
22
44
|
status: MeshTaskStatus;
|
|
23
45
|
taskMode?: MeshTaskMode;
|
|
46
|
+
/**
|
|
47
|
+
* QUEUE-NODE-SERIALIZATION: explicit read-only axis, orthogonal to taskMode. When
|
|
48
|
+
* true the task is treated as read-only by every scheduling gate (no node-busy
|
|
49
|
+
* isolation, counted under the read-only cap, write commands rejected) regardless of
|
|
50
|
+
* its taskMode. Decided exclusively through {@link isTaskReadonly}; `taskMode ===
|
|
51
|
+
* 'live_debug_readonly'` remains an OR-fallback so legacy rows behave unchanged.
|
|
52
|
+
*/
|
|
53
|
+
readonly?: boolean;
|
|
24
54
|
/** If specified, only this node can claim the task (used by legacy mesh_send_task) */
|
|
25
55
|
targetNodeId?: string;
|
|
26
56
|
/** If specified, only this runtime session can claim the task */
|
|
@@ -140,6 +170,8 @@ export declare function enqueueTask(meshId: string, message: string, opts?: {
|
|
|
140
170
|
targetNodeId?: string;
|
|
141
171
|
targetSessionId?: string;
|
|
142
172
|
taskMode?: MeshTaskMode | string;
|
|
173
|
+
/** QUEUE-NODE-SERIALIZATION: explicit read-only axis (orthogonal to taskMode). */
|
|
174
|
+
readonly?: boolean;
|
|
143
175
|
requiredTags?: string[];
|
|
144
176
|
/** M1: tasks that must complete before this one is claimable. */
|
|
145
177
|
dependsOn?: string[];
|
|
@@ -174,6 +206,8 @@ export declare function recordDirectDispatchTask(meshId: string, message: string
|
|
|
174
206
|
assignedNodeId?: string;
|
|
175
207
|
assignedSessionId?: string;
|
|
176
208
|
taskMode?: MeshTaskMode | string;
|
|
209
|
+
/** QUEUE-NODE-SERIALIZATION: explicit read-only axis (orthogonal to taskMode). */
|
|
210
|
+
readonly?: boolean;
|
|
177
211
|
dispatchedAt?: string;
|
|
178
212
|
}): MeshWorkQueueEntry | null;
|
|
179
213
|
/**
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import type { ChatMessage } from '../types.js';
|
|
2
2
|
export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 16000;
|
|
3
3
|
export declare function extractFinalSummaryFromMessages(messages: ChatMessage[] | null | undefined, maxChars?: number): string;
|
|
4
|
+
/**
|
|
5
|
+
* Turn-scoped variant of extractFinalSummaryFromMessages. Selects the last
|
|
6
|
+
* user-facing assistant/model bubble whose own timestamp is at/after the
|
|
7
|
+
* producing turn's start (`minTimestampMs`). This is the NOTIF Defect-B fix:
|
|
8
|
+
* a completion event's finalSummary must describe the turn THAT completed, not
|
|
9
|
+
* the prior task's last bubble. For native-source providers (claude-cli) the
|
|
10
|
+
* external transcript holds the ENTIRE session history filtered only by session
|
|
11
|
+
* start, so a completion debounce that fires before the producing turn's final
|
|
12
|
+
* assistant bubble has landed would otherwise echo the previous task's tail.
|
|
13
|
+
* A message whose timestamp predates the turn start is skipped; if no in-turn
|
|
14
|
+
* assistant bubble exists yet, returns '' (weak/empty) — never the stale tail.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors the reconcile path's transcriptAfterDispatch guard (mesh-events-stale):
|
|
17
|
+
* a bubble only counts if its timestamp proves it was produced after the turn began.
|
|
18
|
+
* When `minTimestampMs` is undefined the behaviour is identical to the unscoped
|
|
19
|
+
* extractor (no turn boundary known → no filtering).
|
|
20
|
+
*/
|
|
21
|
+
export declare function extractFinalSummaryFromMessagesAfter(messages: ChatMessage[] | null | undefined, minTimestampMs: number | undefined, maxChars?: number): string;
|
|
4
22
|
/**
|
|
5
23
|
* Like extractFinalSummaryFromMessages but also returns the ISO timestamp of the
|
|
6
24
|
* selected final assistant/model message. Completion reconciliation needs the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.409",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.409",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -29,6 +29,15 @@ function isModuleNotFoundError(error: unknown, ref: string): boolean {
|
|
|
29
29
|
return code === 'MODULE_NOT_FOUND' && message.includes(ref);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Identifies the host runtime so a binding-load failure names the exact ABI it
|
|
33
|
+
// looked for. The underlying binding is N-API (ABI-stable), so a failure here is
|
|
34
|
+
// almost always "no prebuilt directory addressed this triplet" rather than a
|
|
35
|
+
// true ABI incompatibility — making the triplet the single most useful
|
|
36
|
+
// diagnostic to surface in an env-blocker report.
|
|
37
|
+
function runtimeTriplet(): string {
|
|
38
|
+
return `${process.platform}-${process.arch}-node${process.versions.modules}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
function normalizeBinding(mod: any, ref: string): GhosttyVtBinding {
|
|
33
42
|
const binding = mod?.default?.createTerminal
|
|
34
43
|
? mod.default
|
|
@@ -75,7 +84,8 @@ function loadGhosttyVtBinding(): GhosttyVtBinding {
|
|
|
75
84
|
|
|
76
85
|
cachedBinding = null;
|
|
77
86
|
cachedBindingError = new Error(
|
|
78
|
-
`ghostty-vt binding unavailable
|
|
87
|
+
`ghostty-vt binding unavailable for runtime ${runtimeTriplet()} ` +
|
|
88
|
+
`(${errors.join('; ') || 'no candidates tried'})`,
|
|
79
89
|
);
|
|
80
90
|
throw cachedBindingError;
|
|
81
91
|
}
|
|
@@ -752,6 +752,17 @@ export async function handleResolveAction(h: CommandHelpers, args: any): Promise
|
|
|
752
752
|
: status?.status;
|
|
753
753
|
LOG.info('Command', `[resolveAction] CLI PTY gate target=${String(args?.targetSessionId || '')} rawStatus=${String(status?.status || '')} effectiveStatus=${String(effectiveStatus || '')} statusModal=${statusModal ? 'yes' : 'no'} surfacedModal=${surfacedModal ? 'yes' : 'no'} parsedModal=${parsedModal ? 'yes' : 'no'} instance=${targetInstance ? 'yes' : 'no'}`);
|
|
754
754
|
if (!effectiveModal) {
|
|
755
|
+
// APPROVAL Defect-B (live re-probe race): the modal is gone because the worker
|
|
756
|
+
// already resolved this very approval moments ago (delegated auto-approve fired,
|
|
757
|
+
// or a prior resolveAction landed) and the coordinator's approve raced in just
|
|
758
|
+
// after. That is NOT a caller error — return a SOFT already_resolved result so the
|
|
759
|
+
// coordinator does not hard-fail the task on a benign race. Mirrors the in-modal
|
|
760
|
+
// idempotency guard below (isApprovalRecentlyResolved → stalePrompt). Only a session
|
|
761
|
+
// that never had a recently-resolved approval reports the hard 'Not in approval state'.
|
|
762
|
+
if (typeof adapter.isApprovalRecentlyResolved === 'function' && adapter.isApprovalRecentlyResolved()) {
|
|
763
|
+
LOG.info('Command', `[resolveAction] CLI PTY → already_resolved (modal gone, resolved within cooldown)`);
|
|
764
|
+
return { success: true, alreadyResolved: true, status: 'already_resolved' };
|
|
765
|
+
}
|
|
755
766
|
return { success: false, error: 'Not in approval state' };
|
|
756
767
|
}
|
|
757
768
|
const buttons: string[] = Array.isArray(effectiveModal.buttons) ? effectiveModal.buttons : [];
|
package/src/git/git-commands.ts
CHANGED
|
@@ -462,7 +462,10 @@ async function gitCheckpoint(
|
|
|
462
462
|
const repo = await resolveGitRepository(workspace);
|
|
463
463
|
const repoRoot = repo.repoRoot!;
|
|
464
464
|
|
|
465
|
-
|
|
465
|
+
// Decision-then-mutate path: this conflict / dirty-submodule precheck gates a real
|
|
466
|
+
// `git add` + `git commit`. Bypass the C1 TTL cache so the checkpoint never proceeds
|
|
467
|
+
// on a stale "clean" verdict.
|
|
468
|
+
const statusResult = await getGitRepoStatus(workspace, { forceFresh: true });
|
|
466
469
|
if (statusResult.hasConflicts) {
|
|
467
470
|
throw new GitCommandError('conflict', 'Repository has conflicts — resolve before checkpointing');
|
|
468
471
|
}
|
package/src/git/git-status.ts
CHANGED
|
@@ -12,12 +12,45 @@ import {
|
|
|
12
12
|
type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* TTL for the happy-path result cache (C1). reconcile fires every 4s and mesh_status
|
|
16
|
+
* is polled; a single getGitRepoStatus on a 1-submodule repo spawns ~13-15 git
|
|
17
|
+
* processes (+ optional network fetch) which on Windows is ~10-15s. Within this short
|
|
18
|
+
* window, repeat callers that share the same option shape are served the already
|
|
19
|
+
* collected status instead of re-shelling. Kept well under the 4s reconcile cadence so
|
|
20
|
+
* a genuinely fresh probe still happens at least roughly once per reconcile tick.
|
|
19
21
|
*/
|
|
20
|
-
const
|
|
22
|
+
export const GIT_STATUS_CACHE_TTL_MS = 1500;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Last successfully-collected status per (workspace, option-shape), used for two things:
|
|
26
|
+
* 1. C1 TTL result cache — on the happy path, a cached entry younger than
|
|
27
|
+
* GIT_STATUS_CACHE_TTL_MS is returned directly (gated by `cachedAt`).
|
|
28
|
+
* 2. Transient-failure fallback — survive a transient git failure (timeout, slow
|
|
29
|
+
* Windows spawn under load, a momentary lock) WITHOUT dropping the node out of the
|
|
30
|
+
* mesh graph by re-serving the last good status, re-stamped as stale.
|
|
31
|
+
* A genuine "not a git repository" answer is NOT a transient failure — it never
|
|
32
|
+
* populates this cache and always reports isGitRepo:false. Only a successful, fully
|
|
33
|
+
* populated status is ever cached; error/empty results never are.
|
|
34
|
+
*
|
|
35
|
+
* The key folds in the option fields that change the SHAPE of the collected result
|
|
36
|
+
* (includeSubmodules, refreshUpstream) so different callers don't read each other's
|
|
37
|
+
* partial results. Fields that only affect timing/policy (timeoutMs, daemonBuildInfo,
|
|
38
|
+
* changeImpactConfig, submoduleIgnorePaths) are NOT part of the key — they don't change
|
|
39
|
+
* what a fresh same-shape collection would currently return for the happy path, and the
|
|
40
|
+
* sub-caches (changeImpactEvalCache) already key on their own invalidators.
|
|
41
|
+
*/
|
|
42
|
+
interface CachedStatusEntry {
|
|
43
|
+
status: GitRepoStatus;
|
|
44
|
+
cachedAt: number;
|
|
45
|
+
}
|
|
46
|
+
const lastKnownGoodStatus = new Map<string, CachedStatusEntry>();
|
|
47
|
+
|
|
48
|
+
/** Cache key folding in only the option fields that change the result shape. */
|
|
49
|
+
function statusCacheKey(workspace: string, options: GitStatusOptions): string {
|
|
50
|
+
const includeSubmodules = options.includeSubmodules !== false;
|
|
51
|
+
const refreshUpstream = options.refreshUpstream === true;
|
|
52
|
+
return `${workspace}\0sub=${includeSubmodules ? 1 : 0}\0up=${refreshUpstream ? 1 : 0}`;
|
|
53
|
+
}
|
|
21
54
|
|
|
22
55
|
/**
|
|
23
56
|
* Memoized Change Impact evaluation, keyed by the inputs that can change the
|
|
@@ -45,13 +78,31 @@ interface ChangeImpactConfigCacheEntry {
|
|
|
45
78
|
}
|
|
46
79
|
const changeImpactConfigCache = new Map<string, ChangeImpactConfigCacheEntry>();
|
|
47
80
|
|
|
81
|
+
/**
|
|
82
|
+
* C3: memoized ancestry verdict for the daemon-build-behind probe, keyed by
|
|
83
|
+
* `${repoPath}::${buildCommit}::${headOid}`. For a fixed (build commit, HEAD oid) pair
|
|
84
|
+
* the ancestry relationship (is build an ancestor of HEAD? equal? unrelated?) is
|
|
85
|
+
* immutable, so the verdict can be cached indefinitely — it auto-invalidates the moment
|
|
86
|
+
* HEAD moves (new oid → new key). This removes the steady-state `cat-file` +
|
|
87
|
+
* `rev-parse` + `merge-base` spawns (2-3 per scope) when HEAD has not moved between
|
|
88
|
+
* probes. A value of `false` means "build is NOT a strict ancestor of HEAD" (current,
|
|
89
|
+
* ahead, or unrelated — no warning); `true` means a strict-ancestor relationship was
|
|
90
|
+
* proven for that pair.
|
|
91
|
+
*/
|
|
92
|
+
const buildBehindAncestryCache = new Map<string, boolean>();
|
|
93
|
+
|
|
48
94
|
/** Test seam: clear the last-known-good status cache between cases. */
|
|
49
95
|
export function __resetGitStatusCacheForTests(): void {
|
|
50
96
|
lastKnownGoodStatus.clear();
|
|
51
97
|
changeImpactEvalCache.clear();
|
|
52
98
|
changeImpactConfigCache.clear();
|
|
99
|
+
upstreamFetchedAt.clear();
|
|
100
|
+
buildBehindAncestryCache.clear();
|
|
53
101
|
}
|
|
54
102
|
|
|
103
|
+
/** Alias matching the task's requested name. */
|
|
104
|
+
export const __resetGitStatusCache = __resetGitStatusCacheForTests;
|
|
105
|
+
|
|
55
106
|
/** Test-only introspection: number of memoized Change Impact evaluations. */
|
|
56
107
|
export function __changeImpactEvalCacheSizeForTests(): number {
|
|
57
108
|
return changeImpactEvalCache.size;
|
|
@@ -77,6 +128,18 @@ export interface GitStatusOptions {
|
|
|
77
128
|
* Callers should opt into this only for convergence-critical surfaces.
|
|
78
129
|
*/
|
|
79
130
|
refreshUpstream?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* When true, bypass the C1 TTL result cache: always re-collect a fresh status
|
|
133
|
+
* (ignoring any cached entry younger than GIT_STATUS_CACHE_TTL_MS). The freshly
|
|
134
|
+
* collected result still UPDATES the cache so subsequent normal callers benefit.
|
|
135
|
+
*
|
|
136
|
+
* Mutating / decision callers (mesh_fast_forward preflight + post-merge re-read,
|
|
137
|
+
* refine submodule alignment pre/post status) MUST set this — acting on a stale
|
|
138
|
+
* ahead/behind or submodule sync verdict is the primary correctness hazard of the
|
|
139
|
+
* cache. Read-only/observe callers (mesh_status, git-monitor, reconcile) leave it
|
|
140
|
+
* unset and enjoy the dedup.
|
|
141
|
+
*/
|
|
142
|
+
forceFresh?: boolean;
|
|
80
143
|
/**
|
|
81
144
|
* Test/override seam for the daemon build stamp used by the stale-build
|
|
82
145
|
* detector. Production callers omit this so the real baked-in build commit
|
|
@@ -96,10 +159,25 @@ export interface GitStatusOptions {
|
|
|
96
159
|
changeImpactConfig?: ChangeImpactConfig | null;
|
|
97
160
|
}
|
|
98
161
|
|
|
162
|
+
/**
|
|
163
|
+
* C2: minimum interval between actual `git fetch` network calls per workspace. A
|
|
164
|
+
* refreshUpstream caller within this window does NOT re-fetch; it serves ahead/behind
|
|
165
|
+
* from the locally-re-read porcelain (which is always fresh every call). So
|
|
166
|
+
* refreshUpstream becomes "fetch if the local remote-tracking ref is stale" rather than
|
|
167
|
+
* "always pay a network round trip". Only the upstream ahead/behind can age up to this
|
|
168
|
+
* throttle; local working-tree status never does.
|
|
169
|
+
*/
|
|
170
|
+
export const GIT_FETCH_THROTTLE_MS = 30_000;
|
|
171
|
+
|
|
172
|
+
/** Wall-clock of the last successful `git fetch` per workspace (C2 throttle gate). */
|
|
173
|
+
const upstreamFetchedAt = new Map<string, number>();
|
|
174
|
+
|
|
99
175
|
interface GitUpstreamProbe {
|
|
100
176
|
upstreamStatus: GitUpstreamFreshness;
|
|
101
177
|
upstreamFetchedAt?: number;
|
|
102
178
|
upstreamFetchError?: string;
|
|
179
|
+
/** True only when this probe performed an actual network fetch (porcelain re-read needed). */
|
|
180
|
+
didFetch?: boolean;
|
|
103
181
|
}
|
|
104
182
|
|
|
105
183
|
export async function getGitRepoStatus(
|
|
@@ -116,10 +194,26 @@ export async function getGitRepoStatus(
|
|
|
116
194
|
const effectiveOptions: GitStatusOptions =
|
|
117
195
|
options.timeoutMs === undefined ? { ...options, timeoutMs: GIT_STATUS_TIMEOUT_MS } : options;
|
|
118
196
|
|
|
197
|
+
const cacheKey = statusCacheKey(workspace, options);
|
|
198
|
+
|
|
199
|
+
// C1: happy-path TTL result cache. A non-forceFresh caller within the TTL window is
|
|
200
|
+
// served the last successfully-collected status for this exact option shape instead
|
|
201
|
+
// of re-shelling ~14 git processes. Only successful, fully-populated results are ever
|
|
202
|
+
// stored (see the .set below + the never-cache-error rule in the catch), so a cache
|
|
203
|
+
// hit can never serve an error/empty status.
|
|
204
|
+
if (!options.forceFresh) {
|
|
205
|
+
const cached = lastKnownGoodStatus.get(cacheKey);
|
|
206
|
+
if (cached && lastCheckedAt - cached.cachedAt < GIT_STATUS_CACHE_TTL_MS) {
|
|
207
|
+
return cached.status;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
119
211
|
try {
|
|
120
212
|
const repo = await resolveGitRepository(workspace, effectiveOptions);
|
|
121
213
|
const status = await collectGitRepoStatus(repo, includeSubmodules, lastCheckedAt, effectiveOptions);
|
|
122
|
-
|
|
214
|
+
// Cache the fresh, fully-populated success. forceFresh callers still refresh the
|
|
215
|
+
// cache so the next normal caller benefits from their freshly-collected status.
|
|
216
|
+
lastKnownGoodStatus.set(cacheKey, { status, cachedAt: lastCheckedAt });
|
|
123
217
|
return status;
|
|
124
218
|
} catch (error) {
|
|
125
219
|
const gitError = error instanceof GitCommandError
|
|
@@ -130,7 +224,7 @@ export async function getGitRepoStatus(
|
|
|
130
224
|
// a healthy node lose its repo identity and drop out of the mesh graph. Prefer the
|
|
131
225
|
// last status we successfully collected for this workspace, re-stamped as stale.
|
|
132
226
|
if (isTransientGitFailure(gitError)) {
|
|
133
|
-
const cached = lastKnownGoodStatus.get(
|
|
227
|
+
const cached = lastKnownGoodStatus.get(cacheKey)?.status;
|
|
134
228
|
if (cached) {
|
|
135
229
|
return {
|
|
136
230
|
...cached,
|
|
@@ -157,7 +251,11 @@ async function collectGitRepoStatus(
|
|
|
157
251
|
|
|
158
252
|
if (options.refreshUpstream) {
|
|
159
253
|
upstreamProbe = await refreshTrackedUpstream(repo, parsed, options);
|
|
160
|
-
|
|
254
|
+
// Re-read the porcelain (to pick up the updated ahead/behind) ONLY when this probe
|
|
255
|
+
// actually fetched. When the fetch was throttled (C2), the remote-tracking ref is
|
|
256
|
+
// unchanged, so the ahead/behind already parsed is still correct — re-reading would
|
|
257
|
+
// be a wasted spawn.
|
|
258
|
+
if (upstreamProbe.upstreamStatus === 'fresh' && upstreamProbe.didFetch) {
|
|
161
259
|
parsed = await readPorcelainStatus(repo, options);
|
|
162
260
|
}
|
|
163
261
|
}
|
|
@@ -166,8 +264,11 @@ async function collectGitRepoStatus(
|
|
|
166
264
|
const stashCount = await readStashCount(repo, options);
|
|
167
265
|
|
|
168
266
|
let submodules: GitSubmoduleStatus[] | undefined;
|
|
267
|
+
let submoduleHeadOids = new Map<string, string>();
|
|
169
268
|
if (includeSubmodules) {
|
|
170
|
-
|
|
269
|
+
const subResult = await getSubmoduleStatuses(repo, options);
|
|
270
|
+
submodules = subResult.submodules;
|
|
271
|
+
submoduleHeadOids = subResult.headOidByPath;
|
|
171
272
|
}
|
|
172
273
|
const submoduleDirty = (submodules || []).some(submodule => submodule.dirty || submodule.outOfSync || !!submodule.error);
|
|
173
274
|
const dirty = parsed.staged + parsed.modified + parsed.untracked + parsed.deleted + parsed.renamed > 0
|
|
@@ -175,7 +276,10 @@ async function collectGitRepoStatus(
|
|
|
175
276
|
|| stashCount > 0
|
|
176
277
|
|| submoduleDirty;
|
|
177
278
|
|
|
178
|
-
const daemonBuildBehind = await detectDaemonBuildBehind(repo, submodules, options
|
|
279
|
+
const daemonBuildBehind = await detectDaemonBuildBehind(repo, submodules, options, {
|
|
280
|
+
rootHeadOid: parsed.headOid,
|
|
281
|
+
submoduleHeadOids,
|
|
282
|
+
});
|
|
179
283
|
|
|
180
284
|
return {
|
|
181
285
|
workspace: repo.workspace,
|
|
@@ -426,10 +530,18 @@ function resolveChangeImpactConfigForRepo(
|
|
|
426
530
|
return { config, sourceKey: loaded.sourceKey };
|
|
427
531
|
}
|
|
428
532
|
|
|
533
|
+
interface DaemonBuildBehindHeadOids {
|
|
534
|
+
/** Root repo HEAD oid from porcelain `# branch.oid` (avoids a rev-parse spawn). */
|
|
535
|
+
rootHeadOid: string | null;
|
|
536
|
+
/** Per-submodule-path actual HEAD oid (already read during submodule collection). */
|
|
537
|
+
submoduleHeadOids: Map<string, string>;
|
|
538
|
+
}
|
|
539
|
+
|
|
429
540
|
async function detectDaemonBuildBehind(
|
|
430
541
|
repo: ResolvedGitRepo,
|
|
431
542
|
submodules: GitSubmoduleStatus[] | undefined,
|
|
432
543
|
options: GitStatusOptions,
|
|
544
|
+
headOids: DaemonBuildBehindHeadOids = { rootHeadOid: null, submoduleHeadOids: new Map() },
|
|
433
545
|
): Promise<DaemonBuildBehind | undefined> {
|
|
434
546
|
const build = options.daemonBuildInfo ?? getDaemonBuildInfo();
|
|
435
547
|
if (!build.commit || build.commit === 'unknown') return undefined;
|
|
@@ -440,23 +552,60 @@ async function detectDaemonBuildBehind(
|
|
|
440
552
|
// Check the root repo first, then each submodule. The daemon build commit is
|
|
441
553
|
// baked from the daemon-core (oss submodule) HEAD, so on an adhdev
|
|
442
554
|
// superproject worktree the match is expected on the `oss` submodule, not the
|
|
443
|
-
// root — checking both keeps the helper repo-agnostic.
|
|
444
|
-
|
|
445
|
-
|
|
555
|
+
// root — checking both keeps the helper repo-agnostic. The known HEAD oid (from
|
|
556
|
+
// porcelain for root, from the submodule rev-parse already paid during collection)
|
|
557
|
+
// is threaded in so the C3 ancestry cache can short-circuit before any spawn.
|
|
558
|
+
const scopes: Array<{ scope: string; repoPath: string; knownHeadOid: string | null }> = [
|
|
559
|
+
{ scope: 'root', repoPath: repo.repoRoot || repo.workspace, knownHeadOid: headOids.rootHeadOid },
|
|
446
560
|
];
|
|
447
561
|
for (const sub of submodules || []) {
|
|
448
|
-
if (sub.repoPath && !sub.error)
|
|
562
|
+
if (sub.repoPath && !sub.error) {
|
|
563
|
+
scopes.push({ scope: sub.path, repoPath: sub.repoPath, knownHeadOid: headOids.submoduleHeadOids.get(sub.path) ?? null });
|
|
564
|
+
}
|
|
449
565
|
}
|
|
450
566
|
|
|
451
|
-
for (const { scope, repoPath } of scopes) {
|
|
567
|
+
for (const { scope, repoPath, knownHeadOid } of scopes) {
|
|
452
568
|
try {
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
//
|
|
459
|
-
|
|
569
|
+
// C3 fast path: if we already know this scope's HEAD oid and have a cached
|
|
570
|
+
// ancestry verdict for (repoPath, buildCommit, headOid), reuse it. A `false`
|
|
571
|
+
// verdict (not a strict ancestor — current/ahead/unrelated) lets us skip the
|
|
572
|
+
// cat-file + merge-base spawns entirely; the verdict auto-invalidates when HEAD
|
|
573
|
+
// moves (new oid → new key). A `true` verdict still needs the diff
|
|
574
|
+
// classification below, which is itself memoized on the same head oid.
|
|
575
|
+
let head = knownHeadOid;
|
|
576
|
+
const ancestryKey = head ? `${repoPath}::${build.commit}::${head}` : null;
|
|
577
|
+
if (ancestryKey) {
|
|
578
|
+
const cachedVerdict = buildBehindAncestryCache.get(ancestryKey);
|
|
579
|
+
if (cachedVerdict === false) continue;
|
|
580
|
+
if (cachedVerdict === undefined) {
|
|
581
|
+
if (head === build.commit) {
|
|
582
|
+
buildBehindAncestryCache.set(ancestryKey, false);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
// Build commit must be a real object in THIS repo, else it's a different repo.
|
|
586
|
+
await runGit(repoPath, ['cat-file', '-e', `${build.commit}^{commit}`], options);
|
|
587
|
+
try {
|
|
588
|
+
await runGit(repoPath, ['merge-base', '--is-ancestor', build.commit, 'HEAD'], options);
|
|
589
|
+
} catch {
|
|
590
|
+
// Not a strict ancestor — cache the negative verdict so the next probe at
|
|
591
|
+
// this same HEAD short-circuits, then move on to the next scope.
|
|
592
|
+
buildBehindAncestryCache.set(ancestryKey, false);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
buildBehindAncestryCache.set(ancestryKey, true);
|
|
596
|
+
}
|
|
597
|
+
// cachedVerdict === true (or just proven true) → fall through to classification.
|
|
598
|
+
} else {
|
|
599
|
+
// No known HEAD oid for this scope — fall back to the original probe (resolve
|
|
600
|
+
// HEAD via rev-parse). This keeps correctness when porcelain/submodule oid is
|
|
601
|
+
// unavailable; the verdict is still cached once HEAD is known.
|
|
602
|
+
await runGit(repoPath, ['cat-file', '-e', `${build.commit}^{commit}`], options);
|
|
603
|
+
const headResult = await runGit(repoPath, ['rev-parse', 'HEAD'], options);
|
|
604
|
+
head = headResult.stdout.trim();
|
|
605
|
+
if (!head || head === build.commit) continue;
|
|
606
|
+
await runGit(repoPath, ['merge-base', '--is-ancestor', build.commit, 'HEAD'], options);
|
|
607
|
+
buildBehindAncestryCache.set(`${repoPath}::${build.commit}::${head}`, true);
|
|
608
|
+
}
|
|
460
609
|
// No throw → build commit IS an ancestor of HEAD → daemon is behind.
|
|
461
610
|
// Inspect WHICH packages changed in buildCommit..HEAD. A daemon rebuild/restart
|
|
462
611
|
// is only actually required when a daemon-runtime package changed; if only web /
|
|
@@ -465,6 +614,7 @@ async function detectDaemonBuildBehind(
|
|
|
465
614
|
// verdict is memoized on (repoPath, buildCommit, head, config) to suppress
|
|
466
615
|
// re-evaluation on the hot status path.
|
|
467
616
|
const evalKey = `${repoPath}${build.commit}${head}${configKey}`;
|
|
617
|
+
if (!head) continue; // proven non-empty oid here; every other branch above continued
|
|
468
618
|
let evaluated = changeImpactEvalCache.get(evalKey);
|
|
469
619
|
if (!evaluated) {
|
|
470
620
|
evaluated = await classifyDaemonBuildChange(repoPath, build.commit, options, policy);
|
|
@@ -509,6 +659,8 @@ async function detectDaemonBuildBehind(
|
|
|
509
659
|
|
|
510
660
|
interface ParsedPorcelainStatus {
|
|
511
661
|
branch: string | null;
|
|
662
|
+
/** Full HEAD object id from `# branch.oid`, or null when detached/unborn. */
|
|
663
|
+
headOid: string | null;
|
|
512
664
|
upstream: string | null;
|
|
513
665
|
ahead: number;
|
|
514
666
|
behind: number;
|
|
@@ -540,6 +692,20 @@ async function refreshTrackedUpstream(
|
|
|
540
692
|
return { upstreamStatus: 'no_upstream' };
|
|
541
693
|
}
|
|
542
694
|
|
|
695
|
+
// C2: throttle the actual network fetch. If we fetched this workspace within
|
|
696
|
+
// GIT_FETCH_THROTTLE_MS, skip the fetch and serve ahead/behind from the local
|
|
697
|
+
// porcelain (re-read fresh every call by the caller). forceFresh callers
|
|
698
|
+
// (convergence-critical: ff / refine) always re-fetch — they need true upstream.
|
|
699
|
+
const now = Date.now();
|
|
700
|
+
const lastFetch = upstreamFetchedAt.get(repo.workspace);
|
|
701
|
+
if (!options.forceFresh && lastFetch !== undefined && now - lastFetch < GIT_FETCH_THROTTLE_MS) {
|
|
702
|
+
return {
|
|
703
|
+
upstreamStatus: 'fresh',
|
|
704
|
+
upstreamFetchedAt: lastFetch,
|
|
705
|
+
didFetch: false,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
543
709
|
const remoteName = (await readBranchRemote(repo, parsed.branch, options)) ?? inferRemoteName(parsed.upstream);
|
|
544
710
|
if (!remoteName) {
|
|
545
711
|
return {
|
|
@@ -550,9 +716,12 @@ async function refreshTrackedUpstream(
|
|
|
550
716
|
|
|
551
717
|
try {
|
|
552
718
|
await runGit(repo, ['fetch', '--quiet', '--prune', '--no-tags', remoteName], options);
|
|
719
|
+
const fetchedAt = Date.now();
|
|
720
|
+
upstreamFetchedAt.set(repo.workspace, fetchedAt);
|
|
553
721
|
return {
|
|
554
722
|
upstreamStatus: 'fresh',
|
|
555
|
-
upstreamFetchedAt:
|
|
723
|
+
upstreamFetchedAt: fetchedAt,
|
|
724
|
+
didFetch: true,
|
|
556
725
|
};
|
|
557
726
|
} catch (error) {
|
|
558
727
|
return {
|
|
@@ -589,6 +758,7 @@ function formatGitError(error: unknown): string {
|
|
|
589
758
|
export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
|
|
590
759
|
const parsed: ParsedPorcelainStatus = {
|
|
591
760
|
branch: null,
|
|
761
|
+
headOid: null,
|
|
592
762
|
upstream: null,
|
|
593
763
|
ahead: 0,
|
|
594
764
|
behind: 0,
|
|
@@ -603,6 +773,13 @@ export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
|
|
|
603
773
|
for (const line of output.split('\n')) {
|
|
604
774
|
if (!line) continue;
|
|
605
775
|
|
|
776
|
+
if (line.startsWith('# branch.oid ')) {
|
|
777
|
+
const oid = line.slice('# branch.oid '.length).trim();
|
|
778
|
+
// `(initial)` is git's sentinel for an unborn HEAD — not a real object id.
|
|
779
|
+
parsed.headOid = oid && oid !== '(initial)' && /^[0-9a-f]{7,64}$/.test(oid) ? oid : null;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
|
|
606
783
|
if (line.startsWith('# branch.head ')) {
|
|
607
784
|
const branch = line.slice('# branch.head '.length).trim();
|
|
608
785
|
parsed.branch = branch && branch !== '(detached)' ? branch : null;
|
|
@@ -719,11 +896,17 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
|
|
|
719
896
|
|
|
720
897
|
// ─── Submodule Status ───────────────────────────
|
|
721
898
|
|
|
899
|
+
interface SubmoduleStatusResult {
|
|
900
|
+
submodules: GitSubmoduleStatus[];
|
|
901
|
+
/** Actual checked-out HEAD oid per submodule path (for the C3 ancestry cache key). */
|
|
902
|
+
headOidByPath: Map<string, string>;
|
|
903
|
+
}
|
|
904
|
+
|
|
722
905
|
async function getSubmoduleStatuses(
|
|
723
906
|
repo: ResolvedGitRepo,
|
|
724
907
|
options: GitStatusOptions,
|
|
725
|
-
): Promise<
|
|
726
|
-
if (!repo.repoRoot) return [];
|
|
908
|
+
): Promise<SubmoduleStatusResult> {
|
|
909
|
+
if (!repo.repoRoot) return { submodules: [], headOidByPath: new Map() };
|
|
727
910
|
|
|
728
911
|
try {
|
|
729
912
|
// Do NOT shell out to `git submodule status`. That porcelain wrapper is a
|
|
@@ -743,11 +926,11 @@ async function getSubmoduleStatuses(
|
|
|
743
926
|
// (conflict) prefix is surfaced separately via the superproject porcelain
|
|
744
927
|
// status that the caller already parses, and a conflicted submodule's own
|
|
745
928
|
// status read here also flags it dirty — so no row is lost.
|
|
746
|
-
const submodules = await deriveSubmoduleGitlinkStatuses(repo, options);
|
|
929
|
+
const { submodules, headOidByPath } = await deriveSubmoduleGitlinkStatuses(repo, options);
|
|
747
930
|
await Promise.all(submodules.map(submodule => enrichSubmoduleWorktreeStatus(repo, submodule, options)));
|
|
748
|
-
return submodules;
|
|
931
|
+
return { submodules, headOidByPath };
|
|
749
932
|
} catch {
|
|
750
|
-
return [];
|
|
933
|
+
return { submodules: [], headOidByPath: new Map() };
|
|
751
934
|
}
|
|
752
935
|
}
|
|
753
936
|
|
|
@@ -760,11 +943,12 @@ async function getSubmoduleStatuses(
|
|
|
760
943
|
async function deriveSubmoduleGitlinkStatuses(
|
|
761
944
|
repo: ResolvedGitRepo,
|
|
762
945
|
options: GitStatusOptions,
|
|
763
|
-
): Promise<
|
|
764
|
-
if (!repo.repoRoot) return [];
|
|
946
|
+
): Promise<SubmoduleStatusResult> {
|
|
947
|
+
if (!repo.repoRoot) return { submodules: [], headOidByPath: new Map() };
|
|
765
948
|
const paths = await readSubmodulePaths(repo, options);
|
|
766
949
|
const ignoreSet = new Set(options.submoduleIgnorePaths || []);
|
|
767
950
|
const lastCheckedAt = Date.now();
|
|
951
|
+
const headOidByPath = new Map<string, string>();
|
|
768
952
|
|
|
769
953
|
const entries = await Promise.all(
|
|
770
954
|
paths
|
|
@@ -773,6 +957,10 @@ async function deriveSubmoduleGitlinkStatuses(
|
|
|
773
957
|
const repoPath = repo.repoRoot + '/' + path;
|
|
774
958
|
const expected = await readGitlinkExpectedSha(repo, path, options);
|
|
775
959
|
const actual = await readSubmoduleHeadSha(repo, repoPath, options);
|
|
960
|
+
// Reuse the actual checked-out HEAD oid for the C3 build-behind ancestry cache
|
|
961
|
+
// key — it's the submodule HEAD the daemon build commit is tested against, and
|
|
962
|
+
// we already paid this rev-parse for the outOfSync check, so no extra spawn.
|
|
963
|
+
if (actual) headOidByPath.set(path, actual);
|
|
776
964
|
// Uninitialized / no checked-out HEAD reproduces `git submodule status`'s
|
|
777
965
|
// `-` prefix; a present-but-divergent HEAD reproduces the `+` prefix.
|
|
778
966
|
const outOfSync = actual === null
|
|
@@ -790,7 +978,7 @@ async function deriveSubmoduleGitlinkStatuses(
|
|
|
790
978
|
};
|
|
791
979
|
}),
|
|
792
980
|
);
|
|
793
|
-
return entries;
|
|
981
|
+
return { submodules: entries, headOidByPath };
|
|
794
982
|
}
|
|
795
983
|
|
|
796
984
|
/** Read submodule paths from `.gitmodules` via plumbing (no shell wrapper). */
|
package/src/index.ts
CHANGED
|
@@ -247,7 +247,7 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
|
|
|
247
247
|
export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
|
|
248
248
|
|
|
249
249
|
// ── Mesh Work Queue (GUPP) ──
|
|
250
|
-
export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
|
|
250
|
+
export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, isTaskReadonly, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
|
|
251
251
|
export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
|
|
252
252
|
export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, pruneStaleDirectDispatches, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
|
|
253
253
|
export type { StaleDirectPruneClassification, StaleDirectPruneResult, PruneStaleDirectDispatchesOptions } from './mesh/mesh-active-work.js';
|
|
@@ -87,7 +87,11 @@ type MeshFastForwardBase = Pick<
|
|
|
87
87
|
'workspace' | 'mode' | 'dryRun' | 'updateSubmodules' | 'plannedSteps' | 'trigger'
|
|
88
88
|
> & Pick<Partial<MeshFastForwardResult>, 'nodeId' | 'meshId'>;
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// forceFresh: fast-forward is a mutating/decision path — its preflight blockers and its
|
|
91
|
+
// post-merge re-reads MUST see live git state, never a TTL-cached status from a
|
|
92
|
+
// concurrent reconcile/mesh_status probe. It also bypasses the fetch throttle so
|
|
93
|
+
// ahead/behind reflects a true upstream at decision time.
|
|
94
|
+
const STATUS_OPTIONS = { refreshUpstream: true, includeSubmodules: true, timeoutMs: 15_000, forceFresh: true } as const;
|
|
91
95
|
|
|
92
96
|
export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promise<MeshFastForwardResult> {
|
|
93
97
|
const workspace = typeof args.workspace === 'string' ? args.workspace.trim() : '';
|