@adhdev/daemon-core 0.9.82-rc.328 → 0.9.82-rc.329
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-adapters/provider-cli-shared.d.ts +1 -1
- package/dist/git/change-impact-config.d.ts +159 -0
- package/dist/git/git-status.d.ts +14 -0
- package/dist/git/index.d.ts +2 -0
- package/dist/index.js +647 -281
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +598 -238
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-stale.d.ts +1 -1
- package/dist/providers/status-monitor.d.ts +7 -7
- package/dist/shared-types.d.ts +1 -1
- package/package.json +2 -2
- package/src/agent-stream/provider-adapter.ts +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +2 -2
- package/src/cli-adapters/provider-cli-shared.ts +1 -1
- package/src/commands/chat-commands.ts +1 -1
- package/src/commands/cli-manager.ts +1 -1
- package/src/commands/router.ts +46 -0
- package/src/git/change-impact-config.ts +354 -0
- package/src/git/git-status.ts +182 -16
- package/src/git/index.ts +16 -0
- package/src/mesh/mesh-events-coordinator.ts +13 -12
- package/src/mesh/mesh-events-stale.ts +4 -4
- package/src/mesh/mesh-events-utils.ts +3 -3
- package/src/providers/acp-provider-instance.ts +6 -5
- package/src/providers/cli-provider-instance.ts +14 -10
- package/src/providers/extension-provider-instance.ts +7 -6
- package/src/providers/ide-provider-instance.ts +10 -6
- package/src/providers/read-chat-contract.ts +1 -1
- package/src/providers/status-monitor.d.ts +7 -7
- package/src/providers/status-monitor.ts +37 -22
- package/src/shared-types.ts +3 -0
- package/src/status/reporter.ts +1 -1
|
@@ -31,7 +31,7 @@ export declare function reconcileDirectDispatchCompletionFromTranscript(args: {
|
|
|
31
31
|
ledgerEntryId?: string;
|
|
32
32
|
reason?: string;
|
|
33
33
|
};
|
|
34
|
-
export declare function
|
|
34
|
+
export declare function buildNoProgressCompletionReconciliation(args: {
|
|
35
35
|
meshId: string;
|
|
36
36
|
nodeId?: string;
|
|
37
37
|
nodeLabel: string;
|
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Common across all Provider categories (IDE/Extension/CLI/ACP).
|
|
5
5
|
* - Approval waiting (waiting_approval) notification
|
|
6
|
-
* -
|
|
6
|
+
* - No-progress watchdog: alert when an active turn makes no progress for a while
|
|
7
7
|
* - All config toggleable via Provider Settings
|
|
8
8
|
*/
|
|
9
9
|
export interface MonitorConfig {
|
|
10
10
|
/** Enable awaiting-approval notification */
|
|
11
11
|
approvalAlert: boolean;
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
|
|
12
|
+
/** No-progress watchdog notification enabled */
|
|
13
|
+
noProgressAlert: boolean;
|
|
14
|
+
/** No-progress threshold (seconds) */
|
|
15
|
+
noProgressThresholdSec: number;
|
|
16
16
|
/** Repeat notification cooldown (seconds) */
|
|
17
17
|
alertCooldownSec: number;
|
|
18
18
|
}
|
|
@@ -28,7 +28,7 @@ export declare class StatusMonitor {
|
|
|
28
28
|
private config;
|
|
29
29
|
private lastAlertTime;
|
|
30
30
|
private generatingStartTimes;
|
|
31
|
-
private
|
|
31
|
+
private noProgressAlerted;
|
|
32
32
|
private lastProgressFingerprint;
|
|
33
33
|
private lastProgressChangeAt;
|
|
34
34
|
constructor(config?: Partial<MonitorConfig>);
|
|
@@ -40,7 +40,7 @@ export declare class StatusMonitor {
|
|
|
40
40
|
* Check status transition → return notification event array.
|
|
41
41
|
* Called from each onTick() or detectStatusTransition().
|
|
42
42
|
*/
|
|
43
|
-
check(agentKey: string, status: string, now: number, progressFingerprint?: string): MonitorEvent[];
|
|
43
|
+
check(agentKey: string, status: string, now: number, progressFingerprint?: string, approvalPending?: boolean): MonitorEvent[];
|
|
44
44
|
/** Cooldown check — prevent sending the same notification too frequently */
|
|
45
45
|
private shouldAlert;
|
|
46
46
|
/** Reset (on agent terminate/restart) */
|
package/dist/shared-types.d.ts
CHANGED
|
@@ -619,7 +619,7 @@ export interface DashboardBootstrapDaemonEntry extends Partial<CloudDaemonSummar
|
|
|
619
619
|
p2p?: StatusReportPayload['p2p'];
|
|
620
620
|
timestamp?: number;
|
|
621
621
|
}
|
|
622
|
-
export type DaemonStatusEventName = 'agent:generating_started' | 'agent:waiting_approval' | 'agent:generating_completed' | 'agent:stopped' | 'monitor:long_generating';
|
|
622
|
+
export type DaemonStatusEventName = 'agent:generating_started' | 'agent:waiting_approval' | 'agent:generating_completed' | 'agent:stopped' | 'monitor:no_progress' | 'monitor:long_generating';
|
|
623
623
|
/** Minimal daemon-originated event payload relayed through the server. */
|
|
624
624
|
export interface DaemonStatusEventPayload {
|
|
625
625
|
event: DaemonStatusEventName;
|
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.329",
|
|
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.329",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -256,7 +256,7 @@ export class ProviderStreamAdapter implements IAgentStreamAdapter {
|
|
|
256
256
|
}
|
|
257
257
|
const validated = validateReadChatResultPayload(data, `${this.agentType} readChat`);
|
|
258
258
|
const validatedStatus = (validated as any).status as string;
|
|
259
|
-
const streamStatus = validatedStatus === 'generating' || validatedStatus === 'long_generating'
|
|
259
|
+
const streamStatus = validatedStatus === 'generating' || validatedStatus === 'no_progress' || validatedStatus === 'long_generating'
|
|
260
260
|
? 'streaming'
|
|
261
261
|
: validatedStatus;
|
|
262
262
|
const state: AgentStreamState = {
|
|
@@ -1321,7 +1321,7 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1321
1321
|
};
|
|
1322
1322
|
if (parsedSessionStatus === 'idle' && hasFinalAssistant(parsedStatusBeforeSend)) return null;
|
|
1323
1323
|
if (this.engine.currentStatus === 'generating') return 'current_status_generating';
|
|
1324
|
-
if (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating') {
|
|
1324
|
+
if (parsedSessionStatus === 'generating' || parsedSessionStatus === 'no_progress' || parsedSessionStatus === 'long_generating') {
|
|
1325
1325
|
const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
|
|
1326
1326
|
const parsedHasActionableModal = Boolean(
|
|
1327
1327
|
parsedModal
|
|
@@ -1414,7 +1414,7 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1414
1414
|
const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
|
|
1415
1415
|
? String(parsedStatusBeforeSend.status)
|
|
1416
1416
|
: '';
|
|
1417
|
-
if (!allowInputDuringGeneration && (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating')) {
|
|
1417
|
+
if (!allowInputDuringGeneration && (parsedSessionStatus === 'generating' || parsedSessionStatus === 'no_progress' || parsedSessionStatus === 'long_generating')) {
|
|
1418
1418
|
const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
|
|
1419
1419
|
const parsedHasActionableModal = Boolean(
|
|
1420
1420
|
parsedModal
|
|
@@ -62,7 +62,7 @@ export interface CliSessionStatus {
|
|
|
62
62
|
* Timestamp (ms) of the most recent *visible* terminal screen change.
|
|
63
63
|
* Stricter than lastOutputAt — only advances when the rendered screen
|
|
64
64
|
* content actually differs, so repeated keepalive bytes do not register as
|
|
65
|
-
* progress. Preferred liveness signal for the
|
|
65
|
+
* progress. Preferred liveness signal for the no-progress watchdog.
|
|
66
66
|
*/
|
|
67
67
|
lastScreenChangeAt?: number;
|
|
68
68
|
bufferState?: {
|
|
@@ -1372,7 +1372,7 @@ function normalizeReadChatCommandStatus(status: unknown, activeModal: unknown):
|
|
|
1372
1372
|
}
|
|
1373
1373
|
|
|
1374
1374
|
function isGeneratingLikeStatus(status: unknown): boolean {
|
|
1375
|
-
return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
|
|
1375
|
+
return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
|
|
1376
1376
|
}
|
|
1377
1377
|
|
|
1378
1378
|
function hasVisibleAssistantMessage(messages: unknown[] | undefined): boolean {
|
|
@@ -81,7 +81,7 @@ export interface CliManagerDeps {
|
|
|
81
81
|
|
|
82
82
|
type CommandResult = { success: boolean;[key: string]: unknown };
|
|
83
83
|
|
|
84
|
-
const BUSY_AGENT_STATUSES = new Set(['generating', 'running', 'streaming', 'starting', 'busy', 'waiting', 'waiting_approval', 'long_generating']);
|
|
84
|
+
const BUSY_AGENT_STATUSES = new Set(['generating', 'running', 'streaming', 'starting', 'busy', 'waiting', 'waiting_approval', 'no_progress', 'long_generating']);
|
|
85
85
|
const ZERO_MESSAGE_STARTING_SEND_WAIT_MS = 2_000;
|
|
86
86
|
|
|
87
87
|
function normalizeAgentStatus(value: unknown): string {
|
package/src/commands/router.ts
CHANGED
|
@@ -27,6 +27,13 @@ import { listProviderHistorySessions } from '../config/chat-history.js';
|
|
|
27
27
|
import { detectIDEs } from '../detection/ide-detector.js';
|
|
28
28
|
import { detectCLI, detectCLIs } from '../detection/cli-detector.js';
|
|
29
29
|
import { getGitRepoStatus } from '../git/git-status.js';
|
|
30
|
+
import {
|
|
31
|
+
CHANGE_IMPACT_CONFIG_LOCATIONS,
|
|
32
|
+
CHANGE_IMPACT_CONFIG_SCHEMA,
|
|
33
|
+
loadChangeImpactConfig,
|
|
34
|
+
suggestChangeImpactConfig,
|
|
35
|
+
validateChangeImpactConfig,
|
|
36
|
+
} from '../git/change-impact-config.js';
|
|
30
37
|
import {
|
|
31
38
|
normalizeGitStatus as sharedNormalizeGitStatus,
|
|
32
39
|
pickBestTransitGitStatus as sharedPickBestTransitGitStatus,
|
|
@@ -7693,6 +7700,45 @@ export class DaemonCommandRouter {
|
|
|
7693
7700
|
};
|
|
7694
7701
|
}
|
|
7695
7702
|
|
|
7703
|
+
case 'get_mesh_change_impact_config_schema': {
|
|
7704
|
+
return {
|
|
7705
|
+
success: true,
|
|
7706
|
+
schema: CHANGE_IMPACT_CONFIG_SCHEMA,
|
|
7707
|
+
locations: CHANGE_IMPACT_CONFIG_LOCATIONS,
|
|
7708
|
+
sourceOfTruth: 'repo change-impact config',
|
|
7709
|
+
heuristicRole: 'suggestions_only_not_execution_path',
|
|
7710
|
+
note: 'Declarative config only — JSON/YAML are parsed but never executed. Defines which package/file changes require a daemon rebuild/restart vs. a web-only redeploy vs. nothing.',
|
|
7711
|
+
};
|
|
7712
|
+
}
|
|
7713
|
+
|
|
7714
|
+
case 'validate_mesh_change_impact_config': {
|
|
7715
|
+
const workspace = typeof args?.workspace === 'string' ? args.workspace : process.cwd();
|
|
7716
|
+
if (args?.config !== undefined) {
|
|
7717
|
+
const validation = validateChangeImpactConfig(args.config, 'inline');
|
|
7718
|
+
return { success: validation.valid, source: 'inline', sourceType: 'mesh_policy', ...validation };
|
|
7719
|
+
}
|
|
7720
|
+
const loaded = loadChangeImpactConfig(workspace);
|
|
7721
|
+
if (loaded.sourceType === 'repo_file') {
|
|
7722
|
+
const validation = validateChangeImpactConfig(loaded.config, loaded.source);
|
|
7723
|
+
return { success: validation.valid, ...loaded, ...validation };
|
|
7724
|
+
}
|
|
7725
|
+
return {
|
|
7726
|
+
success: false,
|
|
7727
|
+
...loaded,
|
|
7728
|
+
valid: false,
|
|
7729
|
+
errors: [loaded.error || 'repo change-impact config unavailable'],
|
|
7730
|
+
};
|
|
7731
|
+
}
|
|
7732
|
+
|
|
7733
|
+
case 'suggest_mesh_change_impact_config': {
|
|
7734
|
+
const workspace = typeof args?.workspace === 'string' ? args.workspace : process.cwd();
|
|
7735
|
+
return {
|
|
7736
|
+
success: true,
|
|
7737
|
+
...suggestChangeImpactConfig(workspace),
|
|
7738
|
+
note: 'Suggestions are heuristic scaffold only; the draft must be reviewed and saved into repo change-impact config before it takes effect. Nothing is executed.',
|
|
7739
|
+
};
|
|
7740
|
+
}
|
|
7741
|
+
|
|
7696
7742
|
case 'mesh_init': {
|
|
7697
7743
|
const workspace = typeof args?.workspace === 'string' && args.workspace.trim() ? args.workspace.trim() : process.cwd();
|
|
7698
7744
|
const mesh = args?.inlineMesh || {};
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import * as yaml from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "Change Impact" — config-driven classification of which git changes between the
|
|
7
|
+
* live daemon's build commit and the workspace HEAD actually require a daemon
|
|
8
|
+
* rebuild/restart (vs. a web-only redeploy, vs. nothing).
|
|
9
|
+
*
|
|
10
|
+
* The daemon-core git surface only knows the FACTS (which files / packages changed
|
|
11
|
+
* between buildCommit..HEAD). The POLICY — which packages are daemon-runtime, which
|
|
12
|
+
* are web-only, which root files are non-runtime, and what command to recommend —
|
|
13
|
+
* is declarative config injected from outside. This keeps git-status.ts repo-agnostic
|
|
14
|
+
* while preserving ADHDev's exact built-in behavior when no config is present.
|
|
15
|
+
*
|
|
16
|
+
* SECURITY: config is declarative only. JSON/YAML are parsed; arbitrary JS config
|
|
17
|
+
* (.js) is intentionally NOT supported, so loading a config can never execute code.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A change-impact classification bucket. */
|
|
21
|
+
export type ChangeImpactKind = 'daemon' | 'web' | 'none';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Recommended action for a given impact classification. `recommendedAction` is the
|
|
25
|
+
* stable classification key (always one of daemon|web|none, NOT config-overridable);
|
|
26
|
+
* config customizes only the human-facing `recommendedCommand` recipe.
|
|
27
|
+
*/
|
|
28
|
+
export interface ChangeImpactTarget {
|
|
29
|
+
/** Human-facing command/recipe to act on the impact (e.g. deploy + restart). */
|
|
30
|
+
recommendedCommand: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ChangeImpactConfig {
|
|
34
|
+
/**
|
|
35
|
+
* Package names (the `<name>` in `packages/<name>/`) whose change means the
|
|
36
|
+
* daemon runtime is stale and must be rebuilt/redeployed + restarted. When
|
|
37
|
+
* provided, this REPLACES the built-in daemon-runtime set.
|
|
38
|
+
*/
|
|
39
|
+
daemonRuntimePackages?: string[];
|
|
40
|
+
/**
|
|
41
|
+
* Package names that are web-only — a change to one does not require a daemon
|
|
42
|
+
* restart, only a web redeploy. When provided, this REPLACES the built-in
|
|
43
|
+
* web-only set.
|
|
44
|
+
*/
|
|
45
|
+
webOnlyPackages?: string[];
|
|
46
|
+
/**
|
|
47
|
+
* Glob patterns for root-level (non-package) files that demonstrably cannot
|
|
48
|
+
* change what the daemon runtime executes (markers, docs, license text). These
|
|
49
|
+
* are added on top of the built-in non-runtime root-file detection.
|
|
50
|
+
*/
|
|
51
|
+
nonRuntimeRootFilePatterns?: string[];
|
|
52
|
+
/** Recommended action/command per impact classification. Missing keys fall back to built-in defaults. */
|
|
53
|
+
impactTargets?: Partial<Record<ChangeImpactKind, ChangeImpactTarget>>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ChangeImpactConfigLoadResult {
|
|
57
|
+
config?: ChangeImpactConfig;
|
|
58
|
+
source: string;
|
|
59
|
+
sourceType: 'repo_file' | 'unavailable' | 'invalid';
|
|
60
|
+
path?: string;
|
|
61
|
+
error?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Stable identity of the config SOURCE, used as part of a cache key so a config
|
|
64
|
+
* edit invalidates any cached impact evaluation. Combines the resolved path and
|
|
65
|
+
* its mtime (or the path for the unavailable/invalid case).
|
|
66
|
+
*/
|
|
67
|
+
sourceKey: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const CHANGE_IMPACT_CONFIG_LOCATIONS = [
|
|
71
|
+
'.adhdev/change-impact.json',
|
|
72
|
+
'.adhdev/change-impact.yaml',
|
|
73
|
+
'.adhdev/change-impact.yml',
|
|
74
|
+
'.adhdev/repo-mesh-change-impact.json',
|
|
75
|
+
'.adhdev/repo-mesh-change-impact.yaml',
|
|
76
|
+
'.adhdev/repo-mesh-change-impact.yml',
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
export const CHANGE_IMPACT_CONFIG_SCHEMA = {
|
|
80
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
81
|
+
title: 'ADHDev Change Impact Config',
|
|
82
|
+
type: 'object',
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
properties: {
|
|
85
|
+
daemonRuntimePackages: { type: 'array', items: { type: 'string', minLength: 1 } },
|
|
86
|
+
webOnlyPackages: { type: 'array', items: { type: 'string', minLength: 1 } },
|
|
87
|
+
nonRuntimeRootFilePatterns: { type: 'array', items: { type: 'string', minLength: 1 } },
|
|
88
|
+
impactTargets: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
properties: {
|
|
92
|
+
daemon: { $ref: '#/$defs/target' },
|
|
93
|
+
web: { $ref: '#/$defs/target' },
|
|
94
|
+
none: { $ref: '#/$defs/target' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
$defs: {
|
|
99
|
+
target: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
required: ['recommendedCommand'],
|
|
103
|
+
properties: {
|
|
104
|
+
recommendedCommand: { type: 'string', minLength: 1 },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
} as const;
|
|
109
|
+
|
|
110
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
111
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isStringArray(value: unknown): value is string[] {
|
|
115
|
+
return Array.isArray(value) && value.every(v => typeof v === 'string' && v.length > 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validateTarget(value: unknown, key: string, errors: string[]): ChangeImpactTarget | undefined {
|
|
119
|
+
if (!isRecord(value)) {
|
|
120
|
+
errors.push(`impactTargets.${key} must be an object`);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const { recommendedCommand } = value;
|
|
124
|
+
if (typeof recommendedCommand !== 'string' || !recommendedCommand.length) {
|
|
125
|
+
errors.push(`impactTargets.${key}.recommendedCommand must be a non-empty string`);
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
// recommendedAction is the fixed classification key; config supplies only the command.
|
|
129
|
+
for (const k of Object.keys(value)) {
|
|
130
|
+
if (k !== 'recommendedCommand') errors.push(`impactTargets.${key}.${k} is not a recognized field (only recommendedCommand)`);
|
|
131
|
+
}
|
|
132
|
+
return { recommendedCommand };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function validateChangeImpactConfig(raw: unknown, source = 'inline'): { valid: boolean; errors: string[]; config?: ChangeImpactConfig } {
|
|
136
|
+
const errors: string[] = [];
|
|
137
|
+
if (!isRecord(raw)) {
|
|
138
|
+
return { valid: false, errors: [`${source}: config must be an object`] };
|
|
139
|
+
}
|
|
140
|
+
const config: ChangeImpactConfig = {};
|
|
141
|
+
|
|
142
|
+
if (raw.daemonRuntimePackages !== undefined) {
|
|
143
|
+
if (isStringArray(raw.daemonRuntimePackages)) config.daemonRuntimePackages = [...raw.daemonRuntimePackages];
|
|
144
|
+
else errors.push('daemonRuntimePackages must be an array of non-empty strings');
|
|
145
|
+
}
|
|
146
|
+
if (raw.webOnlyPackages !== undefined) {
|
|
147
|
+
if (isStringArray(raw.webOnlyPackages)) config.webOnlyPackages = [...raw.webOnlyPackages];
|
|
148
|
+
else errors.push('webOnlyPackages must be an array of non-empty strings');
|
|
149
|
+
}
|
|
150
|
+
if (raw.nonRuntimeRootFilePatterns !== undefined) {
|
|
151
|
+
if (isStringArray(raw.nonRuntimeRootFilePatterns)) config.nonRuntimeRootFilePatterns = [...raw.nonRuntimeRootFilePatterns];
|
|
152
|
+
else errors.push('nonRuntimeRootFilePatterns must be an array of non-empty strings');
|
|
153
|
+
}
|
|
154
|
+
if (raw.impactTargets !== undefined) {
|
|
155
|
+
if (!isRecord(raw.impactTargets)) {
|
|
156
|
+
errors.push('impactTargets must be an object');
|
|
157
|
+
} else {
|
|
158
|
+
const targets: Partial<Record<ChangeImpactKind, ChangeImpactTarget>> = {};
|
|
159
|
+
for (const key of Object.keys(raw.impactTargets)) {
|
|
160
|
+
if (key !== 'daemon' && key !== 'web' && key !== 'none') {
|
|
161
|
+
errors.push(`impactTargets.${key} is not a recognized impact kind (daemon|web|none)`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const target = validateTarget(raw.impactTargets[key], key, errors);
|
|
165
|
+
if (target) targets[key as ChangeImpactKind] = target;
|
|
166
|
+
}
|
|
167
|
+
if (Object.keys(targets).length) config.impactTargets = targets;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Reject unknown top-level keys to catch typos early (mirrors additionalProperties:false).
|
|
171
|
+
for (const key of Object.keys(raw)) {
|
|
172
|
+
if (!['daemonRuntimePackages', 'webOnlyPackages', 'nonRuntimeRootFilePatterns', 'impactTargets'].includes(key)) {
|
|
173
|
+
errors.push(`unknown config key '${key}'`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { valid: errors.length === 0, errors, config: errors.length === 0 ? config : undefined };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseConfigText(path: string, text: string): unknown {
|
|
181
|
+
if (/\.json$/i.test(path)) return JSON.parse(text);
|
|
182
|
+
return yaml.load(text);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Locate and load the Change Impact config for a repo root. Declarative only —
|
|
187
|
+
* never executes config. Returns a sourceKey suitable for cache invalidation:
|
|
188
|
+
* config path + mtime, or the sentinel for the missing/invalid case.
|
|
189
|
+
*/
|
|
190
|
+
export function loadChangeImpactConfig(repoRoot: string): ChangeImpactConfigLoadResult {
|
|
191
|
+
for (const relative of CHANGE_IMPACT_CONFIG_LOCATIONS) {
|
|
192
|
+
const configPath = join(repoRoot, relative);
|
|
193
|
+
if (!existsSync(configPath)) continue;
|
|
194
|
+
try {
|
|
195
|
+
const text = readFileSync(configPath, 'utf-8');
|
|
196
|
+
let mtimeMs = 0;
|
|
197
|
+
try {
|
|
198
|
+
mtimeMs = statSync(configPath).mtimeMs;
|
|
199
|
+
} catch {
|
|
200
|
+
// Best-effort; fall back to content length so edits still perturb the key.
|
|
201
|
+
mtimeMs = text.length;
|
|
202
|
+
}
|
|
203
|
+
const parsed = parseConfigText(configPath, text);
|
|
204
|
+
const validation = validateChangeImpactConfig(parsed, relative);
|
|
205
|
+
if (!validation.valid) {
|
|
206
|
+
return {
|
|
207
|
+
source: relative,
|
|
208
|
+
sourceType: 'invalid',
|
|
209
|
+
path: configPath,
|
|
210
|
+
error: validation.errors.join('; '),
|
|
211
|
+
sourceKey: `invalid:${configPath}:${mtimeMs}`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
config: validation.config,
|
|
216
|
+
source: relative,
|
|
217
|
+
sourceType: 'repo_file',
|
|
218
|
+
path: configPath,
|
|
219
|
+
sourceKey: `file:${configPath}:${mtimeMs}`,
|
|
220
|
+
};
|
|
221
|
+
} catch (error: any) {
|
|
222
|
+
return {
|
|
223
|
+
source: relative,
|
|
224
|
+
sourceType: 'invalid',
|
|
225
|
+
path: configPath,
|
|
226
|
+
error: error?.message || String(error),
|
|
227
|
+
sourceKey: `error:${configPath}`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
source: 'unavailable',
|
|
233
|
+
sourceType: 'unavailable',
|
|
234
|
+
error: `No change-impact config found. Checked: ${CHANGE_IMPACT_CONFIG_LOCATIONS.join(', ')}`,
|
|
235
|
+
sourceKey: 'unavailable',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Translate a config glob pattern into a RegExp matched against a forward-slash
|
|
241
|
+
* file path. Supports `**` (any path segments), `*` (any chars except `/`), and
|
|
242
|
+
* `?` (single non-slash char). No brace/character-class expansion — declarative,
|
|
243
|
+
* minimal, predictable. All other characters are matched literally.
|
|
244
|
+
*/
|
|
245
|
+
export function globToRegExp(pattern: string): RegExp {
|
|
246
|
+
let out = '';
|
|
247
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
248
|
+
const ch = pattern[i];
|
|
249
|
+
if (ch === '*') {
|
|
250
|
+
if (pattern[i + 1] === '*') {
|
|
251
|
+
// `**` → any sequence of characters including path separators.
|
|
252
|
+
out += '.*';
|
|
253
|
+
i++;
|
|
254
|
+
// Swallow a trailing slash after `**/` so `**/x` also matches `x`.
|
|
255
|
+
if (pattern[i + 1] === '/') i++;
|
|
256
|
+
} else {
|
|
257
|
+
out += '[^/]*';
|
|
258
|
+
}
|
|
259
|
+
} else if (ch === '?') {
|
|
260
|
+
out += '[^/]';
|
|
261
|
+
} else if ('.+^${}()|[]\\'.includes(ch)) {
|
|
262
|
+
out += '\\' + ch;
|
|
263
|
+
} else {
|
|
264
|
+
out += ch;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return new RegExp(`^${out}$`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface ChangeImpactConfigSuggestion {
|
|
271
|
+
/** A non-executable, declarative config draft a human can save verbatim. */
|
|
272
|
+
suggestedConfig: ChangeImpactConfig;
|
|
273
|
+
/** Per-field human-facing rationale for the proposed classification. */
|
|
274
|
+
notes: string[];
|
|
275
|
+
/** Package directories discovered under `packages/` (and `oss/packages/`). */
|
|
276
|
+
discoveredPackages: { daemon: string[]; web: string[]; unclassified: string[] };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function listPackageDirs(packagesRoot: string): string[] {
|
|
280
|
+
try {
|
|
281
|
+
return readdirSync(packagesRoot, { withFileTypes: true })
|
|
282
|
+
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
283
|
+
.map(entry => entry.name);
|
|
284
|
+
} catch {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Inspect a repo's package layout and propose a reasonable, declarative Change
|
|
291
|
+
* Impact config draft. This is heuristic scaffold ONLY — it never executes
|
|
292
|
+
* anything and the draft must be reviewed/saved by a human before it takes
|
|
293
|
+
* effect. The heuristic mirrors ADHDev's own conventions:
|
|
294
|
+
* - `web-*` / `*-web` packages → web-only (web redeploy, no daemon restart)
|
|
295
|
+
* - everything else under packages/ → daemon-runtime (rebuild + restart)
|
|
296
|
+
* - docs/license/marker root files → non-runtime
|
|
297
|
+
* Callers are expected to prune the draft to their actual runtime topology.
|
|
298
|
+
*/
|
|
299
|
+
export function suggestChangeImpactConfig(repoRoot: string): ChangeImpactConfigSuggestion {
|
|
300
|
+
const notes: string[] = [];
|
|
301
|
+
const daemon = new Set<string>();
|
|
302
|
+
const web = new Set<string>();
|
|
303
|
+
const unclassified: string[] = [];
|
|
304
|
+
|
|
305
|
+
const roots = ['packages', join('oss', 'packages')];
|
|
306
|
+
for (const rel of roots) {
|
|
307
|
+
const packagesRoot = join(repoRoot, rel);
|
|
308
|
+
if (!existsSync(packagesRoot)) continue;
|
|
309
|
+
for (const name of listPackageDirs(packagesRoot)) {
|
|
310
|
+
if (/(^web[-.]|[-.]web$)/i.test(name) || /dashboard|frontend|ui$/i.test(name)) {
|
|
311
|
+
web.add(name);
|
|
312
|
+
} else {
|
|
313
|
+
daemon.add(name);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const daemonRuntimePackages = [...daemon].sort();
|
|
319
|
+
const webOnlyPackages = [...web].sort();
|
|
320
|
+
|
|
321
|
+
if (!daemonRuntimePackages.length && !webOnlyPackages.length) {
|
|
322
|
+
notes.push('No packages/ or oss/packages/ directories found — defaulting to an empty draft you should fill in by hand.');
|
|
323
|
+
} else {
|
|
324
|
+
if (daemonRuntimePackages.length) notes.push(`Classified ${daemonRuntimePackages.length} package(s) as daemon-runtime (change → rebuild/redeploy + restart).`);
|
|
325
|
+
if (webOnlyPackages.length) notes.push(`Classified ${webOnlyPackages.length} web-* package(s) as web-only (change → web redeploy, no daemon restart).`);
|
|
326
|
+
notes.push('Heuristic only: confirm each package actually matches its bucket before saving.');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const nonRuntimeRootFilePatterns = [
|
|
330
|
+
'*.md',
|
|
331
|
+
'docs/**',
|
|
332
|
+
'LICENSE',
|
|
333
|
+
'LICENSE.*',
|
|
334
|
+
'.gitignore',
|
|
335
|
+
];
|
|
336
|
+
notes.push('nonRuntimeRootFilePatterns lists root files that demonstrably cannot change daemon runtime behavior; extend with your repo markers.');
|
|
337
|
+
|
|
338
|
+
const suggestedConfig: ChangeImpactConfig = {
|
|
339
|
+
...(daemonRuntimePackages.length ? { daemonRuntimePackages } : {}),
|
|
340
|
+
...(webOnlyPackages.length ? { webOnlyPackages } : {}),
|
|
341
|
+
nonRuntimeRootFilePatterns,
|
|
342
|
+
impactTargets: {
|
|
343
|
+
daemon: { recommendedCommand: 'rebuild + redeploy the daemon, then restart it' },
|
|
344
|
+
web: { recommendedCommand: 'redeploy the web app (no daemon restart required)' },
|
|
345
|
+
none: { recommendedCommand: 'no action required' },
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
suggestedConfig,
|
|
351
|
+
notes,
|
|
352
|
+
discoveredPackages: { daemon: daemonRuntimePackages, web: webOnlyPackages, unclassified },
|
|
353
|
+
};
|
|
354
|
+
}
|