@adhdev/mesh-shared 0.9.82-rc.267

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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Canonical git-status normalizers shared by the cloud (web-core) and standalone
3
+ * (daemon-core router) mesh paths. Previously each transport hand-maintained its
4
+ * own copy and they drifted (e.g. submodule drop rules, evidence checks); this is
5
+ * the one implementation both import.
6
+ */
7
+
8
+ import { joinRepoPath, readBoolean, readNumber, readRecord, readString, type JsonRecord } from './json'
9
+ import type { GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './types'
10
+
11
+ export function scoreGitUpstreamFreshness(status: GitUpstreamFreshness | undefined): number {
12
+ switch (status) {
13
+ case 'fresh':
14
+ return 30
15
+ case 'no_upstream':
16
+ return 4
17
+ case 'unchecked':
18
+ case undefined:
19
+ return 0
20
+ case 'stale':
21
+ return -10
22
+ case 'unavailable':
23
+ return -15
24
+ default:
25
+ return 0
26
+ }
27
+ }
28
+
29
+ export function readGitSubmodules(value: unknown, parentRepoRoot?: string): GitSubmoduleStatus[] | undefined {
30
+ if (!Array.isArray(value)) return undefined
31
+ const submodules = value
32
+ .map(entry => {
33
+ const submodule = readRecord(entry)
34
+ const path = readString(submodule.path)
35
+ const commit = readString(submodule.commit)
36
+ const repoPath = readString(submodule.repoPath, submodule.repo_root)
37
+ ?? joinRepoPath(parentRepoRoot, path)
38
+ // repoPath is only used for the submodule node's display workspace, which is
39
+ // allowed to be empty. The cloud P2P transit path can deliver submodule entries
40
+ // without repoPath (and a per-node git object without a derivable repoRoot), so
41
+ // dropping on missing repoPath would silently strip every submodule graph node.
42
+ // Keep any submodule that carries both path and commit.
43
+ if (!path || !commit) return null
44
+ const result: GitSubmoduleStatus = {
45
+ path,
46
+ commit,
47
+ dirty: readBoolean(submodule.dirty) ?? false,
48
+ outOfSync: readBoolean(submodule.outOfSync, submodule.out_of_sync) ?? false,
49
+ lastCheckedAt: readNumber(submodule.lastCheckedAt, submodule.last_checked_at) ?? Date.now(),
50
+ }
51
+ if (repoPath) result.repoPath = repoPath
52
+ const error = readString(submodule.error)
53
+ if (error) result.error = error
54
+ return result
55
+ })
56
+ .filter((entry): entry is GitSubmoduleStatus => entry !== null)
57
+ return submodules.length > 0 ? submodules : undefined
58
+ }
59
+
60
+ export function hasGitStatusEvidence(status: JsonRecord): boolean {
61
+ // BUG FIX: a transit-reshaped git status that carries only a repoRoot/workspace
62
+ // (e.g. cloud P2P stripped the branch/upstream/counters but kept the path) must
63
+ // NOT be dropped — otherwise the node loses its git object and any submodules
64
+ // hanging off it. Treat a present repoRoot/repo_root/workspace as evidence too.
65
+ return readBoolean(status.isGitRepo) !== undefined
66
+ || Boolean(readString(status.branch, status.upstream, status.upstreamStatus, status.upstream_status, status.headCommit))
67
+ || Boolean(readString(status.repoRoot, status.repo_root, status.workspace))
68
+ || readNumber(
69
+ status.ahead,
70
+ status.behind,
71
+ status.staged,
72
+ status.modified,
73
+ status.untracked,
74
+ status.deleted,
75
+ status.renamed,
76
+ status.lastCheckedAt,
77
+ status.last_checked_at,
78
+ ) !== undefined
79
+ || (Array.isArray(status.submodules) && status.submodules.length > 0)
80
+ }
81
+
82
+ export function normalizeGitStatus(
83
+ status: JsonRecord,
84
+ node: JsonRecord,
85
+ options?: { lastCheckedAt?: number },
86
+ ): GitRepoStatus | undefined {
87
+ const explicitIsGitRepo = readBoolean(status.isGitRepo)
88
+ if (!Object.keys(status).length || !hasGitStatusEvidence(status)) return undefined
89
+ const isGitRepo = explicitIsGitRepo ?? true
90
+ const conflictFiles = Array.isArray(status.conflictFiles)
91
+ ? status.conflictFiles.filter((entry): entry is string => typeof entry === 'string')
92
+ : []
93
+ const conflictCount = readNumber(status.conflicts) ?? conflictFiles.length
94
+ const hasConflicts = readBoolean(status.hasConflicts) ?? conflictCount > 0
95
+ // node.workspace is in the fallback chain so a transit node carrying its path only
96
+ // on the node (not the inner git object) still yields a parentRepoRoot for submodules.
97
+ const repoRoot = readString(status.repoRoot, status.repo_root, node.repoRoot, node.repo_root, status.workspace, node.workspace) || undefined
98
+ const submodules = readGitSubmodules(status.submodules, repoRoot)
99
+ const upstreamStatus = readString(status.upstreamStatus, status.upstream_status)
100
+ const upstreamFetchedAt = readNumber(status.upstreamFetchedAt, status.upstream_fetched_at)
101
+ const upstreamFetchError = readString(status.upstreamFetchError, status.upstream_fetch_error)
102
+ const error = readString(status.error)
103
+ const staged = readNumber(status.staged) ?? 0
104
+ const modified = readNumber(status.modified) ?? 0
105
+ const untracked = readNumber(status.untracked) ?? 0
106
+ const deleted = readNumber(status.deleted) ?? 0
107
+ const renamed = readNumber(status.renamed) ?? 0
108
+ return {
109
+ workspace: readString(status.workspace, node.workspace) || '',
110
+ repoRoot: repoRoot ?? null,
111
+ isGitRepo,
112
+ branch: readString(status.branch) ?? null,
113
+ headCommit: readString(status.headCommit) ?? null,
114
+ headMessage: readString(status.headMessage) ?? null,
115
+ upstream: readString(status.upstream) ?? null,
116
+ upstreamStatus: (upstreamStatus as GitUpstreamFreshness) ?? 'unchecked',
117
+ ...(upstreamFetchedAt !== undefined ? { upstreamFetchedAt } : {}),
118
+ ...(upstreamFetchError ? { upstreamFetchError } : {}),
119
+ ahead: readNumber(status.ahead) ?? 0,
120
+ behind: readNumber(status.behind) ?? 0,
121
+ staged,
122
+ modified,
123
+ untracked,
124
+ deleted,
125
+ renamed,
126
+ dirty: readBoolean(status.dirty, status.isDirty, status.is_dirty) ?? (staged + modified + untracked + deleted + renamed > 0 || hasConflicts),
127
+ hasConflicts,
128
+ conflictFiles,
129
+ stashCount: readNumber(status.stashCount, status.stash_count) ?? 0,
130
+ lastCheckedAt: options?.lastCheckedAt ?? readNumber(status.lastCheckedAt, status.last_checked_at) ?? Date.now(),
131
+ ...(submodules ? { submodules } : {}),
132
+ ...(error ? { error } : {}),
133
+ }
134
+ }
135
+
136
+ export function scoreGitStatusCandidate(git: GitRepoStatus | undefined): number {
137
+ if (!git) return Number.NEGATIVE_INFINITY
138
+ let score = 0
139
+ if (git.isGitRepo === true) score += 50
140
+ if (git.isGitRepo === false) score -= 10
141
+ if (git.branch) score += 20
142
+ if (git.headCommit) score += 20
143
+ if (git.upstream) score += 10
144
+ score += scoreGitUpstreamFreshness(git.upstreamStatus)
145
+ if (typeof git.ahead === 'number') score += 2
146
+ if (typeof git.behind === 'number') score += 2
147
+ if (Array.isArray(git.submodules) && git.submodules.length > 0) score += 4 + git.submodules.length
148
+ if (git.error) score -= 20
149
+ return score
150
+ }
151
+
152
+ /**
153
+ * Pick the best git status out of the four transit envelope slots a mesh node can
154
+ * carry: lastGit.status, lastGit.result.status, lastProbe.git.status,
155
+ * lastProbe.git.result.status. Returns undefined when none carry git evidence.
156
+ */
157
+ export function pickBestTransitGitStatus(node: JsonRecord, options?: { lastCheckedAt?: number }): GitRepoStatus | undefined {
158
+ const rawGit = readRecord(node.lastGit ?? node.last_git)
159
+ const gitResult = readRecord(rawGit.result)
160
+ const directStatus = readRecord(rawGit.status)
161
+ const nestedStatus = readRecord(gitResult.status)
162
+ const rawProbe = readRecord(node.lastProbe ?? node.last_probe)
163
+ const probeGit = readRecord(rawProbe.git)
164
+ const probeGitResult = readRecord(probeGit.result)
165
+ const probeDirectStatus = readRecord(probeGit.status)
166
+ const probeNestedStatus = readRecord(probeGitResult.status)
167
+ const lastCheckedAt = options?.lastCheckedAt
168
+ let best: { git: GitRepoStatus; score: number } | null = null
169
+ for (const status of [directStatus, nestedStatus, probeDirectStatus, probeNestedStatus]) {
170
+ const normalized = normalizeGitStatus(status, node, { lastCheckedAt: lastCheckedAt ?? Date.now() })
171
+ if (!normalized) continue
172
+ const score = scoreGitStatusCandidate(normalized)
173
+ if (!best || score > best.score) best = { git: normalized, score }
174
+ }
175
+ return best?.git
176
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Canonical compact git-shape summarizer used by debug/log surfaces on both the
3
+ * cloud (daemon-cloud mesh command summarizer) and standalone (daemon-core router
4
+ * RepoMeshStatusDebug) paths. Produces a small, log-safe projection of a git
5
+ * status record — commit SHAs are truncated to 12 chars.
6
+ *
7
+ * Transport-specific envelope unwrapping (result / result.status / top-level)
8
+ * stays in the caller; this takes an already-unwrapped status record.
9
+ */
10
+
11
+ import { readBoolean, readNumber, readRecord, readString } from './json'
12
+
13
+ export function summarizeGitShape(status: unknown): Record<string, unknown> | null {
14
+ const record = readRecord(status)
15
+ if (!Object.keys(record).length) return null
16
+ const submodules = Array.isArray(record.submodules)
17
+ ? record.submodules.map((entry: unknown) => {
18
+ const sub = readRecord(entry)
19
+ return {
20
+ path: readString(sub.path) ?? null,
21
+ commit: readString(sub.commit)?.slice(0, 12) ?? null,
22
+ dirty: readBoolean(sub.dirty) ?? false,
23
+ outOfSync: readBoolean(sub.outOfSync, sub.out_of_sync) ?? false,
24
+ }
25
+ })
26
+ : []
27
+ return {
28
+ isGitRepo: readBoolean(record.isGitRepo),
29
+ workspace: readString(record.workspace) ?? null,
30
+ repoRoot: readString(record.repoRoot, record.repo_root) ?? null,
31
+ branch: readString(record.branch) ?? null,
32
+ upstream: readString(record.upstream) ?? null,
33
+ upstreamStatus: readString(record.upstreamStatus, record.upstream_status) ?? null,
34
+ headCommit: readString(record.headCommit, record.head_commit)?.slice(0, 12) ?? null,
35
+ ahead: readNumber(record.ahead) ?? null,
36
+ behind: readNumber(record.behind) ?? null,
37
+ dirtyCounts: {
38
+ staged: readNumber(record.staged) ?? 0,
39
+ modified: readNumber(record.modified) ?? 0,
40
+ untracked: readNumber(record.untracked) ?? 0,
41
+ deleted: readNumber(record.deleted) ?? 0,
42
+ renamed: readNumber(record.renamed) ?? 0,
43
+ },
44
+ lastCheckedAt: readNumber(record.lastCheckedAt, record.last_checked_at) ?? null,
45
+ submoduleCount: submodules.length,
46
+ submodules,
47
+ }
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @adhdev/mesh-shared — pure, dependency-free mesh/git status normalizers shared
3
+ * by daemon-core (standalone / local IPC) and web-core (cloud / P2P transit).
4
+ *
5
+ * This package exists to kill a recurring bug class: the cloud and standalone
6
+ * transports each used to carry their own hand-synced copy of these normalizers,
7
+ * and they drifted (cloud would strip/reshape fields, the web filter would drop
8
+ * entries the standalone path kept). Both cores now import this single source of
9
+ * truth. It MUST stay a pure leaf — types + pure functions on plain JS objects,
10
+ * no Node/DOM APIs, no git exec, no transport, and an empty dependency set.
11
+ */
12
+
13
+ export * from './json'
14
+ export * from './types'
15
+ export * from './git-normalize'
16
+ export * from './session-normalize'
17
+ export * from './git-summarize'
package/src/json.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Pure JSON-record reading primitives shared by the cloud (web-core / P2P transit)
3
+ * and standalone (daemon-core / local IPC) mesh normalizers.
4
+ *
5
+ * These operate only on plain JS values — no Node/DOM APIs, no transport, no git
6
+ * exec — so both cores can import them without violating the core↔core dependency
7
+ * ban. They are the single source of truth for the field-coercion rules that the
8
+ * two transports previously hand-synced (and drifted on).
9
+ */
10
+
11
+ export type JsonRecord = Record<string, unknown>
12
+
13
+ export function readRecord(value: unknown): JsonRecord {
14
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}
15
+ }
16
+
17
+ export function readString(...values: unknown[]): string | undefined {
18
+ for (const value of values) {
19
+ if (typeof value !== 'string') continue
20
+ const trimmed = value.trim()
21
+ if (trimmed) return trimmed
22
+ }
23
+ return undefined
24
+ }
25
+
26
+ export function readNumber(...values: unknown[]): number | undefined {
27
+ for (const value of values) {
28
+ if (typeof value === 'number' && Number.isFinite(value)) return value
29
+ }
30
+ return undefined
31
+ }
32
+
33
+ export function readBoolean(...values: unknown[]): boolean | undefined {
34
+ for (const value of values) {
35
+ if (typeof value === 'boolean') return value
36
+ }
37
+ return undefined
38
+ }
39
+
40
+ export function readStringArray(value: unknown): string[] {
41
+ return Array.isArray(value)
42
+ ? value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
43
+ : []
44
+ }
45
+
46
+ /**
47
+ * Join a (possibly absent) repo root with a relative submodule path. Returns the
48
+ * path unchanged when it is already absolute, and undefined when nothing usable
49
+ * can be derived — callers must treat the result as optional.
50
+ */
51
+ export function joinRepoPath(root: string | undefined, relativePath: string | undefined): string | undefined {
52
+ const normalizedRoot = typeof root === 'string' ? root.trim().replace(/[\\/]+$/, '') : ''
53
+ const normalizedPath = typeof relativePath === 'string' ? relativePath.trim() : ''
54
+ if (!normalizedPath) return undefined
55
+ if (/^(?:[A-Za-z]:[\\/]|\/)/.test(normalizedPath)) return normalizedPath
56
+ if (!normalizedRoot) return undefined
57
+ return `${normalizedRoot}/${normalizedPath.replace(/^[\\/]+/, '')}`
58
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Canonical mesh-session record normalizer shared by the cloud (web-core) mesh
3
+ * paths. Parses an already-transit-shaped session record into a typed
4
+ * RepoMeshSessionStatus.
5
+ */
6
+
7
+ import { readBoolean, readRecord, readString } from './json'
8
+ import type { RepoMeshSessionStatus } from './types'
9
+
10
+ /**
11
+ * Build a deterministic synthetic session id from a record that has no explicit
12
+ * id. Two transit refreshes of the same logical session produce the same id so
13
+ * downstream dedupe stays stable across refreshes (a random id would create a new
14
+ * node every poll). Derived from the stable identifying fields available on the
15
+ * record; prefixed "synthetic:" so callers can tell it apart from a real id.
16
+ */
17
+ function deriveSyntheticSessionId(record: ReturnType<typeof readRecord>): string | undefined {
18
+ const parts = [
19
+ readString(record.workspace),
20
+ readString(record.providerType, record.provider),
21
+ readString(record.role),
22
+ readString(record.state, record.status),
23
+ readString(record.title),
24
+ readString(record.createdAt, record.created_at),
25
+ readString(record.startedAt, record.started_at),
26
+ ].filter((part): part is string => Boolean(part))
27
+ if (parts.length === 0) return undefined
28
+ return `synthetic:${parts.join('|')}`
29
+ }
30
+
31
+ export function normalizeMeshSessionRecord(entry: unknown): RepoMeshSessionStatus | null {
32
+ const record = readRecord(entry)
33
+ // BUG FIX: cloud transit can reshape/strip the explicit id field. Fall back
34
+ // through sessionId → session_id → id, then to a DETERMINISTIC synthetic id
35
+ // derived from record content so the session survives the round trip instead
36
+ // of being dropped (and so dedupe stays stable across refreshes). Return null
37
+ // ONLY when the record carries no identifying fields at all.
38
+ const sessionId = readString(record.sessionId, record.session_id, record.id)
39
+ ?? deriveSyntheticSessionId(record)
40
+ if (!sessionId) return null
41
+ return {
42
+ sessionId,
43
+ ...(readString(record.providerType, record.provider) ? { providerType: readString(record.providerType, record.provider) } : {}),
44
+ ...(readString(record.state, record.status) ? { state: readString(record.state, record.status) } : {}),
45
+ ...(readString(record.chatStatus, record.chat_status) ? { chatStatus: readString(record.chatStatus, record.chat_status) } : {}),
46
+ ...(readString(record.lifecycle) ? { lifecycle: readString(record.lifecycle) as RepoMeshSessionStatus['lifecycle'] } : {}),
47
+ ...(readString(record.surfaceKind, record.surface_kind) ? { surfaceKind: readString(record.surfaceKind, record.surface_kind) as RepoMeshSessionStatus['surfaceKind'] } : {}),
48
+ ...(readString(record.recoveryState, record.recovery_state) ? { recoveryState: readString(record.recoveryState, record.recovery_state) } : {}),
49
+ ...(readString(record.workspace) ? { workspace: readString(record.workspace) } : {}),
50
+ ...(readString(record.title) ? { title: readString(record.title) } : {}),
51
+ ...(readString(record.role) ? { role: readString(record.role) } : {}),
52
+ ...(readBoolean(record.isSelfCoordinator, record.is_self_coordinator) !== undefined ? { isSelfCoordinator: readBoolean(record.isSelfCoordinator, record.is_self_coordinator) } : {}),
53
+ ...(readString(record.statusNote, record.status_note) ? { statusNote: readString(record.statusNote, record.status_note) } : {}),
54
+ ...(readString(record.createdAt, record.created_at) ? { createdAt: readString(record.createdAt, record.created_at) } : {}),
55
+ ...(readString(record.startedAt, record.started_at) ? { startedAt: readString(record.startedAt, record.started_at) } : {}),
56
+ ...(readString(record.lastActivityAt, record.last_activity_at) ? { lastActivityAt: readString(record.lastActivityAt, record.last_activity_at) } : {}),
57
+ ...(readBoolean(record.isCached, record.is_cached) !== undefined ? { isCached: readBoolean(record.isCached, record.is_cached) } : {}),
58
+ }
59
+ }
package/src/types.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Canonical pure-shape mesh/git status types.
3
+ *
4
+ * These are the single source of truth for the data shapes that cross the
5
+ * cloud (P2P/transit) and standalone (local IPC) boundaries. daemon-core and
6
+ * web-core re-export these so neither side can drift its own copy. Pure type
7
+ * declarations only — no runtime, no dependency on either core.
8
+ */
9
+
10
+ export type GitUpstreamFreshness = 'fresh' | 'unchecked' | 'stale' | 'no_upstream' | 'unavailable'
11
+
12
+ export type GitFailureReason =
13
+ | 'not_git_repo'
14
+ | 'git_not_installed'
15
+ | 'timeout'
16
+ | 'path_outside_repo'
17
+ | 'dirty_index_required'
18
+ | 'conflict'
19
+ | 'invalid_args'
20
+ | 'nothing_to_commit'
21
+ | 'git_command_failed'
22
+
23
+ export interface GitRepoIdentity {
24
+ workspace: string
25
+ repoRoot: string | null
26
+ isGitRepo: boolean
27
+ }
28
+
29
+ export interface GitSubmoduleStatus {
30
+ /** Submodule path relative to repo root */
31
+ path: string
32
+ /** Current commit SHA the submodule is at */
33
+ commit: string
34
+ /**
35
+ * Path to the submodule repo (absolute). Optional: cloud P2P transit can
36
+ * deliver submodule entries without a derivable repo path, and graph
37
+ * rendering only uses this for a display field that is allowed to be empty.
38
+ */
39
+ repoPath?: string
40
+ /** Whether the submodule has uncommitted changes */
41
+ dirty: boolean
42
+ /** Whether the submodule commit differs from what the parent repo expects */
43
+ outOfSync: boolean
44
+ /** Last checked timestamp */
45
+ lastCheckedAt: number
46
+ /** Error message if submodule status could not be read */
47
+ error?: string
48
+ }
49
+
50
+ export interface DaemonBuildBehind {
51
+ /** Full build commit baked into the running daemon. */
52
+ buildCommit: string
53
+ /** Short build commit. */
54
+ buildCommitShort: string
55
+ /** HEAD commit the build commit is behind (repo or submodule). */
56
+ head: string
57
+ /** Where the comparison matched: 'root' or the submodule path. */
58
+ scope: string
59
+ /**
60
+ * Whether any package changed between buildCommit..HEAD affects the daemon
61
+ * runtime (daemon-core, standalone, session-host, terminal-mux, ghostty,
62
+ * mcp-server). When false, only web/render packages changed — the daemon does
63
+ * NOT need a rebuild/restart; only the web deploy is pending. Conservative:
64
+ * when the changed-package set can't be determined it defaults to true.
65
+ */
66
+ isDaemonAffecting: boolean
67
+ /** Distinct package names changed between buildCommit..HEAD (best-effort). */
68
+ affectedPackages?: string[]
69
+ warning: string
70
+ }
71
+
72
+ export interface GitRepoStatus extends GitRepoIdentity {
73
+ branch: string | null
74
+ headCommit: string | null
75
+ headMessage: string | null
76
+ upstream: string | null
77
+ /** Whether ahead/behind was verified against a freshly fetched upstream ref. */
78
+ upstreamStatus: GitUpstreamFreshness
79
+ /** Timestamp for the fetch that refreshed upstream refs when upstreamStatus === 'fresh'. */
80
+ upstreamFetchedAt?: number
81
+ /** Error from the last refresh attempt when upstreamStatus === 'stale'. */
82
+ upstreamFetchError?: string
83
+ ahead: number
84
+ behind: number
85
+ staged: number
86
+ modified: number
87
+ untracked: number
88
+ deleted: number
89
+ renamed: number
90
+ /** Aggregate dirty flag including root worktree changes, conflicts, stash, and submodule drift. */
91
+ dirty: boolean
92
+ hasConflicts: boolean
93
+ conflictFiles: string[]
94
+ stashCount: number
95
+ lastCheckedAt: number
96
+ /** Submodule statuses when auto-discover is enabled */
97
+ submodules?: GitSubmoduleStatus[]
98
+ /**
99
+ * Set when the running daemon's build commit is a STRICT ancestor of this
100
+ * repo's HEAD (or a submodule HEAD) — i.e. the live daemon predates committed
101
+ * code in this workspace and is awaiting a deploy/restart to catch up.
102
+ * Omitted entirely when no staleness is provable.
103
+ */
104
+ daemonBuildBehind?: DaemonBuildBehind
105
+ error?: string
106
+ reason?: GitFailureReason
107
+ }
108
+
109
+ export interface RepoMeshSessionStatus {
110
+ sessionId: string
111
+ providerType?: string
112
+ state?: string
113
+ chatStatus?: string
114
+ lifecycle?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'interrupted'
115
+ surfaceKind?: 'live_runtime' | 'recovery_snapshot' | 'inactive_record'
116
+ recoveryState?: string | null
117
+ workspace?: string | null
118
+ title?: string | null
119
+ role?: string | null
120
+ isSelfCoordinator?: boolean
121
+ statusNote?: string | null
122
+ createdAt?: string | null
123
+ startedAt?: string | null
124
+ lastActivityAt?: string | null
125
+ isCached?: boolean
126
+ }