@telora/daemon 0.19.27 → 0.19.31
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/build-info.json +2 -2
- package/dist/config-hot-reload.d.ts +91 -0
- package/dist/config-hot-reload.d.ts.map +1 -0
- package/dist/config-hot-reload.js +121 -0
- package/dist/config-hot-reload.js.map +1 -0
- package/dist/config-reconcile.d.ts +114 -0
- package/dist/config-reconcile.d.ts.map +1 -0
- package/dist/config-reconcile.js +187 -0
- package/dist/config-reconcile.js.map +1 -0
- package/dist/drain-teardown.d.ts +66 -0
- package/dist/drain-teardown.d.ts.map +1 -0
- package/dist/drain-teardown.js +56 -0
- package/dist/drain-teardown.js.map +1 -0
- package/dist/focus-engine.d.ts.map +1 -1
- package/dist/focus-engine.js +28 -0
- package/dist/focus-engine.js.map +1 -1
- package/dist/focus-executor.d.ts.map +1 -1
- package/dist/focus-executor.js +20 -0
- package/dist/focus-executor.js.map +1 -1
- package/dist/index.js +66 -4
- package/dist/index.js.map +1 -1
- package/dist/stop-request.d.ts +54 -0
- package/dist/stop-request.d.ts.map +1 -0
- package/dist/stop-request.js +89 -0
- package/dist/stop-request.js.map +1 -0
- package/dist/unified-engine-lifecycle.d.ts +68 -10
- package/dist/unified-engine-lifecycle.d.ts.map +1 -1
- package/dist/unified-engine-lifecycle.js +177 -71
- package/dist/unified-engine-lifecycle.js.map +1 -1
- package/dist/unified-shell.d.ts.map +1 -1
- package/dist/unified-shell.js +73 -8
- package/dist/unified-shell.js.map +1 -1
- package/dist/worker-pidfile.d.ts +77 -0
- package/dist/worker-pidfile.d.ts.map +1 -0
- package/dist/worker-pidfile.js +161 -0
- package/dist/worker-pidfile.js.map +1 -0
- package/package.json +2 -2
package/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commitSha": "
|
|
3
|
-
"builtAt": "2026-07-
|
|
2
|
+
"commitSha": "e14d22a15",
|
|
3
|
+
"builtAt": "2026-07-01T20:27:07.749Z",
|
|
4
4
|
"expectedMigrations": [
|
|
5
5
|
"20250829113330_create_org_nodes_table.sql",
|
|
6
6
|
"20250829113402_fix_function_security_search_path.sql",
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure classification + planning for SIGHUP config hot-reload.
|
|
3
|
+
*
|
|
4
|
+
* The SIGHUP hot-reload seam historically reconciled ONLY the products array
|
|
5
|
+
* (unified-engine-lifecycle.ts). This module generalizes it: given the live
|
|
6
|
+
* resolved engine config and a freshly-resolved config from the edited
|
|
7
|
+
* daemon.json, it classifies each changed field into one of three buckets --
|
|
8
|
+
*
|
|
9
|
+
* - LIVE-APPLICABLE scalar: a pure-policy value (loop/verification-adjacent
|
|
10
|
+
* thresholds, token/cost limits, log retention, policyFailureMode,
|
|
11
|
+
* maxTotalSessions) that is safe to apply to a running fleet with no
|
|
12
|
+
* team killed. Applied by mutating the shared engine config object in
|
|
13
|
+
* place so the next tick/spawn reads the new value, plus the
|
|
14
|
+
* ResourceGovernor session-cap setters for maxTotalSessions.
|
|
15
|
+
* - RESTART-REQUIRED: an identity field (teloraUrl/trackerId/organizationId/
|
|
16
|
+
* productId) or a bound-resource field (a path, a bound port, a boot-time
|
|
17
|
+
* resource) that cannot change under a live process -- surfaced as a
|
|
18
|
+
* "restart required" warning, exactly as products-only hot-reload already
|
|
19
|
+
* warns for identity changes.
|
|
20
|
+
* - Lacuna sidecar: a transition (start / stop / restart / unchanged) derived
|
|
21
|
+
* from the enabled flag and the bound host/port/source, applied by the
|
|
22
|
+
* lifecycle start/stop helpers (which never touch a running team).
|
|
23
|
+
*
|
|
24
|
+
* Everything here is PURE and side-effect-free: it computes a plan. The apply
|
|
25
|
+
* (process/IO) lives in unified-engine-lifecycle.ts so this layer stays
|
|
26
|
+
* unit-testable without a live daemon or SIGHUP.
|
|
27
|
+
*/
|
|
28
|
+
import type { LacunaConfig } from './types.js';
|
|
29
|
+
/**
|
|
30
|
+
* The live-applicable scalar fields of the resolved engine config. These are
|
|
31
|
+
* pure-policy values with no bound OS resource behind them; applying a new
|
|
32
|
+
* value to the running config object is non-disruptive to in-flight teams.
|
|
33
|
+
*
|
|
34
|
+
* `maxTotalSessions` is special-cased downstream: besides the in-place config
|
|
35
|
+
* mutation it drives the ResourceGovernor per-engine limit (setEngineLimit),
|
|
36
|
+
* which is the authoritative concurrency cap the listener gates spawns on.
|
|
37
|
+
*/
|
|
38
|
+
export declare const LIVE_APPLICABLE_SCALARS: readonly ["maxTotalSessions", "tokenLimit", "costLimit", "mergeLockTimeoutMs", "mergeLockContentionWarningMs", "policyFailureMode", "logMaxAgeDays", "logMaxTotalBytes", "logMaxFiles"];
|
|
39
|
+
export type LiveApplicableScalar = (typeof LIVE_APPLICABLE_SCALARS)[number];
|
|
40
|
+
/**
|
|
41
|
+
* Fields that CANNOT be applied to a running process. Identity fields re-bind
|
|
42
|
+
* the daemon's connection; bound-resource fields (paths resolved at boot, the
|
|
43
|
+
* OTLP telemetry port, the git worktree root, the integration branch) are
|
|
44
|
+
* captured by long-lived handles. A change here is surfaced as a warning so the
|
|
45
|
+
* operator knows a restart is needed -- never silently ignored, never applied.
|
|
46
|
+
*/
|
|
47
|
+
export declare const RESTART_REQUIRED_FIELDS: readonly ["teloraUrl", "trackerId", "organizationId", "productId", "repoPath", "worktreeDir", "integrationBranch", "logDir", "claudeCodePath", "sessionTimeoutMs"];
|
|
48
|
+
export type RestartRequiredField = (typeof RESTART_REQUIRED_FIELDS)[number];
|
|
49
|
+
/** A single changed field, old -> new, for logging. */
|
|
50
|
+
export interface FieldChange {
|
|
51
|
+
field: string;
|
|
52
|
+
oldValue: unknown;
|
|
53
|
+
newValue: unknown;
|
|
54
|
+
}
|
|
55
|
+
/** The computed apply plan for a scalar/identity diff. */
|
|
56
|
+
export interface ConfigChangePlan {
|
|
57
|
+
/** Live-applicable scalar changes, keyed by field, carrying the new value. */
|
|
58
|
+
liveScalars: FieldChange[];
|
|
59
|
+
/** Changes that require a restart (identity / bound-resource). */
|
|
60
|
+
restartRequired: FieldChange[];
|
|
61
|
+
/** True when maxTotalSessions changed -- the caller drives the governor cap. */
|
|
62
|
+
sessionCapChanged: boolean;
|
|
63
|
+
/** The new maxTotalSessions when it changed (else null). */
|
|
64
|
+
newMaxTotalSessions: number | null;
|
|
65
|
+
}
|
|
66
|
+
/** Shape this module reads from a resolved engine config. Loose by design. */
|
|
67
|
+
export type ResolvedConfigLike = Record<string, unknown>;
|
|
68
|
+
/**
|
|
69
|
+
* Classify the scalar/identity diff between the live engine config and a
|
|
70
|
+
* freshly-resolved config from the edited daemon.json. Pure: reads only the
|
|
71
|
+
* two objects, returns a plan. Products and lacuna are handled by their own
|
|
72
|
+
* dedicated paths and are intentionally NOT classified here.
|
|
73
|
+
*/
|
|
74
|
+
export declare function classifyConfigChanges(live: ResolvedConfigLike, next: ResolvedConfigLike): ConfigChangePlan;
|
|
75
|
+
/**
|
|
76
|
+
* The Lacuna sidecar lifecycle transition implied by an old -> new config.
|
|
77
|
+
*
|
|
78
|
+
* - `start` -- was disabled, now enabled: launch the sidecar.
|
|
79
|
+
* - `stop` -- was enabled, now disabled: tear the sidecar down.
|
|
80
|
+
* - `restart` -- stays enabled but a BOUND field (host/port/source/upstream/
|
|
81
|
+
* readyTimeout/compress) changed: stop then start so the new
|
|
82
|
+
* binding takes effect. Fail-open by construction downstream.
|
|
83
|
+
* - `unchanged` -- no lifecycle action needed (still disabled, or enabled with
|
|
84
|
+
* no bound-field change).
|
|
85
|
+
*/
|
|
86
|
+
export type LacunaTransition = 'start' | 'stop' | 'restart' | 'unchanged';
|
|
87
|
+
/**
|
|
88
|
+
* Compute the sidecar transition from old vs new lacuna config. Pure.
|
|
89
|
+
*/
|
|
90
|
+
export declare function computeLacunaTransition(oldLacuna: LacunaConfig, newLacuna: LacunaConfig): LacunaTransition;
|
|
91
|
+
//# sourceMappingURL=config-hot-reload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config-hot-reload.d.ts","sourceRoot":"","sources":["../src/config-hot-reload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,yLAU1B,CAAC;AAEX,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,uBAAuB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5E;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,oKAa1B,CAAC;AAEX,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,uBAAuB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5E,uDAAuD;AACvD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,0DAA0D;AAC1D,MAAM,WAAW,gBAAgB;IAC/B,8EAA8E;IAC9E,WAAW,EAAE,WAAW,EAAE,CAAC;IAC3B,kEAAkE;IAClE,eAAe,EAAE,WAAW,EAAE,CAAC;IAC/B,gFAAgF;IAChF,iBAAiB,EAAE,OAAO,CAAC;IAC3B,4DAA4D;IAC5D,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,8EAA8E;AAC9E,MAAM,MAAM,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEzD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,kBAAkB,EACxB,IAAI,EAAE,kBAAkB,GACvB,gBAAgB,CA2BlB;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,WAAW,CAAC;AAc1E;;GAEG;AACH,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,YAAY,EACvB,SAAS,EAAE,YAAY,GACtB,gBAAgB,CAOlB"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure classification + planning for SIGHUP config hot-reload.
|
|
3
|
+
*
|
|
4
|
+
* The SIGHUP hot-reload seam historically reconciled ONLY the products array
|
|
5
|
+
* (unified-engine-lifecycle.ts). This module generalizes it: given the live
|
|
6
|
+
* resolved engine config and a freshly-resolved config from the edited
|
|
7
|
+
* daemon.json, it classifies each changed field into one of three buckets --
|
|
8
|
+
*
|
|
9
|
+
* - LIVE-APPLICABLE scalar: a pure-policy value (loop/verification-adjacent
|
|
10
|
+
* thresholds, token/cost limits, log retention, policyFailureMode,
|
|
11
|
+
* maxTotalSessions) that is safe to apply to a running fleet with no
|
|
12
|
+
* team killed. Applied by mutating the shared engine config object in
|
|
13
|
+
* place so the next tick/spawn reads the new value, plus the
|
|
14
|
+
* ResourceGovernor session-cap setters for maxTotalSessions.
|
|
15
|
+
* - RESTART-REQUIRED: an identity field (teloraUrl/trackerId/organizationId/
|
|
16
|
+
* productId) or a bound-resource field (a path, a bound port, a boot-time
|
|
17
|
+
* resource) that cannot change under a live process -- surfaced as a
|
|
18
|
+
* "restart required" warning, exactly as products-only hot-reload already
|
|
19
|
+
* warns for identity changes.
|
|
20
|
+
* - Lacuna sidecar: a transition (start / stop / restart / unchanged) derived
|
|
21
|
+
* from the enabled flag and the bound host/port/source, applied by the
|
|
22
|
+
* lifecycle start/stop helpers (which never touch a running team).
|
|
23
|
+
*
|
|
24
|
+
* Everything here is PURE and side-effect-free: it computes a plan. The apply
|
|
25
|
+
* (process/IO) lives in unified-engine-lifecycle.ts so this layer stays
|
|
26
|
+
* unit-testable without a live daemon or SIGHUP.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* The live-applicable scalar fields of the resolved engine config. These are
|
|
30
|
+
* pure-policy values with no bound OS resource behind them; applying a new
|
|
31
|
+
* value to the running config object is non-disruptive to in-flight teams.
|
|
32
|
+
*
|
|
33
|
+
* `maxTotalSessions` is special-cased downstream: besides the in-place config
|
|
34
|
+
* mutation it drives the ResourceGovernor per-engine limit (setEngineLimit),
|
|
35
|
+
* which is the authoritative concurrency cap the listener gates spawns on.
|
|
36
|
+
*/
|
|
37
|
+
export const LIVE_APPLICABLE_SCALARS = [
|
|
38
|
+
'maxTotalSessions',
|
|
39
|
+
'tokenLimit',
|
|
40
|
+
'costLimit',
|
|
41
|
+
'mergeLockTimeoutMs',
|
|
42
|
+
'mergeLockContentionWarningMs',
|
|
43
|
+
'policyFailureMode',
|
|
44
|
+
'logMaxAgeDays',
|
|
45
|
+
'logMaxTotalBytes',
|
|
46
|
+
'logMaxFiles',
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* Fields that CANNOT be applied to a running process. Identity fields re-bind
|
|
50
|
+
* the daemon's connection; bound-resource fields (paths resolved at boot, the
|
|
51
|
+
* OTLP telemetry port, the git worktree root, the integration branch) are
|
|
52
|
+
* captured by long-lived handles. A change here is surfaced as a warning so the
|
|
53
|
+
* operator knows a restart is needed -- never silently ignored, never applied.
|
|
54
|
+
*/
|
|
55
|
+
export const RESTART_REQUIRED_FIELDS = [
|
|
56
|
+
// identity
|
|
57
|
+
'teloraUrl',
|
|
58
|
+
'trackerId',
|
|
59
|
+
'organizationId',
|
|
60
|
+
'productId',
|
|
61
|
+
// bound-resource
|
|
62
|
+
'repoPath',
|
|
63
|
+
'worktreeDir',
|
|
64
|
+
'integrationBranch',
|
|
65
|
+
'logDir',
|
|
66
|
+
'claudeCodePath',
|
|
67
|
+
'sessionTimeoutMs',
|
|
68
|
+
];
|
|
69
|
+
/**
|
|
70
|
+
* Classify the scalar/identity diff between the live engine config and a
|
|
71
|
+
* freshly-resolved config from the edited daemon.json. Pure: reads only the
|
|
72
|
+
* two objects, returns a plan. Products and lacuna are handled by their own
|
|
73
|
+
* dedicated paths and are intentionally NOT classified here.
|
|
74
|
+
*/
|
|
75
|
+
export function classifyConfigChanges(live, next) {
|
|
76
|
+
const liveScalars = [];
|
|
77
|
+
for (const field of LIVE_APPLICABLE_SCALARS) {
|
|
78
|
+
if (!Object.is(live[field], next[field])) {
|
|
79
|
+
liveScalars.push({ field, oldValue: live[field], newValue: next[field] });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const restartRequired = [];
|
|
83
|
+
for (const field of RESTART_REQUIRED_FIELDS) {
|
|
84
|
+
if (!Object.is(live[field], next[field])) {
|
|
85
|
+
restartRequired.push({ field, oldValue: live[field], newValue: next[field] });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const capChange = liveScalars.find((c) => c.field === 'maxTotalSessions');
|
|
89
|
+
const newMax = capChange && typeof capChange.newValue === 'number' && Number.isFinite(capChange.newValue)
|
|
90
|
+
? capChange.newValue
|
|
91
|
+
: null;
|
|
92
|
+
return {
|
|
93
|
+
liveScalars,
|
|
94
|
+
restartRequired,
|
|
95
|
+
sessionCapChanged: capChange !== undefined,
|
|
96
|
+
newMaxTotalSessions: newMax,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Bound fields whose change requires a sidecar restart while it stays enabled. */
|
|
100
|
+
function lacunaBoundFieldsDiffer(a, b) {
|
|
101
|
+
return (a.host !== b.host ||
|
|
102
|
+
a.port !== b.port ||
|
|
103
|
+
a.source !== b.source ||
|
|
104
|
+
a.upstreamBaseUrl !== b.upstreamBaseUrl ||
|
|
105
|
+
a.compressEnabled !== b.compressEnabled ||
|
|
106
|
+
a.readyTimeoutMs !== b.readyTimeoutMs);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Compute the sidecar transition from old vs new lacuna config. Pure.
|
|
110
|
+
*/
|
|
111
|
+
export function computeLacunaTransition(oldLacuna, newLacuna) {
|
|
112
|
+
if (!oldLacuna.enabled && newLacuna.enabled)
|
|
113
|
+
return 'start';
|
|
114
|
+
if (oldLacuna.enabled && !newLacuna.enabled)
|
|
115
|
+
return 'stop';
|
|
116
|
+
if (oldLacuna.enabled && newLacuna.enabled) {
|
|
117
|
+
return lacunaBoundFieldsDiffer(oldLacuna, newLacuna) ? 'restart' : 'unchanged';
|
|
118
|
+
}
|
|
119
|
+
return 'unchanged';
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=config-hot-reload.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config-hot-reload.js","sourceRoot":"","sources":["../src/config-hot-reload.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon config self-heal: prune products deleted from the backend.
|
|
3
|
+
*
|
|
4
|
+
* Registration into ~/.telora/daemon.json is asymmetric -- adding a product has
|
|
5
|
+
* two hot-reloading paths (telora_connector_start and telora_product create ->
|
|
6
|
+
* addProductToDaemonConfig() + SIGHUP), but removal has none. A product deleted
|
|
7
|
+
* out-of-band (SQL/UI) leaves a permanent stale entry, so every poll tick the
|
|
8
|
+
* focus engine iterates config.products, calls a backend that 404s, and scans a
|
|
9
|
+
* repoPath that may no longer exist.
|
|
10
|
+
*
|
|
11
|
+
* This module closes that gap by RECONCILIATION, not an explicit command:
|
|
12
|
+
* config (reality) reconciles toward the backend (intent). On each tick the
|
|
13
|
+
* daemon looks up every configured product; when a lookup returns a DEFINITIVE
|
|
14
|
+
* backend not-found (HTTP 404), that entry is removed from both the in-memory
|
|
15
|
+
* config.products array (poll loop stops iterating it immediately) and the
|
|
16
|
+
* on-disk daemon.json (rewritten 0600, all other fields preserved).
|
|
17
|
+
*
|
|
18
|
+
* SAFETY CONSTRAINT (non-negotiable): prune ONLY on a definitive not-found.
|
|
19
|
+
* Transient errors (network blip, 5xx, timeout, read-only mode, circuit open)
|
|
20
|
+
* and a merely-missing repo directory NEVER trigger removal -- a transient
|
|
21
|
+
* outage must not silently unregister live products. The backend not-found is
|
|
22
|
+
* the sole authoritative deletion signal.
|
|
23
|
+
*
|
|
24
|
+
* The disk read-merge-write mirrors the shape of the MCP-side
|
|
25
|
+
* addProductToDaemonConfig(); the daemon cannot import that helper (it lives in
|
|
26
|
+
* a separate package), so the shape is re-implemented here.
|
|
27
|
+
*/
|
|
28
|
+
import type { DaemonConfig } from './types.js';
|
|
29
|
+
/**
|
|
30
|
+
* Classification of a single product lookup against the backend.
|
|
31
|
+
*
|
|
32
|
+
* - 'found' -> the product exists; keep it.
|
|
33
|
+
* - 'not_found' -> DEFINITIVE backend not-found (404); prune it.
|
|
34
|
+
* - 'transient' -> network / 5xx / timeout / read-only / circuit / anything
|
|
35
|
+
* that is not a definitive 404; KEEP it (safety constraint).
|
|
36
|
+
*/
|
|
37
|
+
export type ProductLookupOutcome = 'found' | 'not_found' | 'transient';
|
|
38
|
+
export interface PruneDiskResult {
|
|
39
|
+
/** True when daemon.json was rewritten without the pruned entry. */
|
|
40
|
+
written: boolean;
|
|
41
|
+
/** The config file targeted (global if present, else repo-local). */
|
|
42
|
+
configPath: string;
|
|
43
|
+
/** Why the disk write was skipped (config missing / corrupt), when not written. */
|
|
44
|
+
reason?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface ReconcileDeps {
|
|
47
|
+
/** Classify a configured product against the backend. */
|
|
48
|
+
lookupProduct(productId: string): Promise<ProductLookupOutcome>;
|
|
49
|
+
/** Remove the product from daemon.json on disk (read-merge-write, 0600). */
|
|
50
|
+
pruneFromDisk(productId: string): PruneDiskResult;
|
|
51
|
+
/** Emit an operator-visible log line (-> ~/.telora/daemon.log). */
|
|
52
|
+
log(message: string): void;
|
|
53
|
+
}
|
|
54
|
+
export interface ReconcileResult {
|
|
55
|
+
/** Products checked this tick. */
|
|
56
|
+
checked: number;
|
|
57
|
+
/** Ids pruned this tick (definitive not-found). */
|
|
58
|
+
pruned: string[];
|
|
59
|
+
/** Surviving product count after the tick. */
|
|
60
|
+
kept: number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Return a copy of an existing daemon.json object with the given product ids
|
|
64
|
+
* removed from products[], preserving every other field (identity, engines,
|
|
65
|
+
* surviving products, and any operator customizations).
|
|
66
|
+
*
|
|
67
|
+
* Mirrors the normalization mergeConfig() applies on the add path: a legacy
|
|
68
|
+
* flat `productId` + `repoPath` pair is migrated into products[] first, then
|
|
69
|
+
* the flat fields are dropped so products[] is the single canonical surface.
|
|
70
|
+
*/
|
|
71
|
+
export declare function removeProductsFromConfig(existing: Record<string, unknown>, removedIds: string[]): Record<string, unknown>;
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the daemon.json the running daemon reads: the global
|
|
74
|
+
* ~/.telora/daemon.json when it exists (the daemon is launched with
|
|
75
|
+
* `--config <global>` in that case), else the repo-local .telora/daemon.json.
|
|
76
|
+
*
|
|
77
|
+
* Mirrors resolveDaemonPaths() in the MCP daemonConfig helper so add and remove
|
|
78
|
+
* target the identical file.
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolveDaemonConfigPath(repoPath: string): string;
|
|
81
|
+
/**
|
|
82
|
+
* Read-merge-write daemon.json removing one product entry, at mode 0600.
|
|
83
|
+
* Skips (never partially writes) when the config is missing or corrupt.
|
|
84
|
+
*/
|
|
85
|
+
export declare function writeConfigRemovingProduct(configPath: string, productId: string): PruneDiskResult;
|
|
86
|
+
/**
|
|
87
|
+
* Look up a product by id and classify the outcome for reconciliation.
|
|
88
|
+
*
|
|
89
|
+
* Uses callApiOnce (a single attempt that BYPASSES the retry + circuit breaker)
|
|
90
|
+
* so a 404 resolves immediately and repeated not-found lookups never open the
|
|
91
|
+
* shared 'delivery' breaker. Only HTTP 404 is a definitive not-found; every
|
|
92
|
+
* other failure -- network error (no statusCode), 5xx, timeout, read-only-mode
|
|
93
|
+
* refusal (503) -- classifies as transient and keeps the product.
|
|
94
|
+
*/
|
|
95
|
+
export declare function classifyProductLookup(productId: string): Promise<ProductLookupOutcome>;
|
|
96
|
+
/**
|
|
97
|
+
* Build the production dependency set for reconcileDaemonProducts. The config
|
|
98
|
+
* path is resolved once from the daemon's repoPath (stable for the run).
|
|
99
|
+
*/
|
|
100
|
+
export declare function buildReconcileDeps(config: DaemonConfig): ReconcileDeps;
|
|
101
|
+
/**
|
|
102
|
+
* Reconcile config.products against backend reality once.
|
|
103
|
+
*
|
|
104
|
+
* For each configured product: look it up; on a DEFINITIVE not-found, remove it
|
|
105
|
+
* from the on-disk daemon.json and splice it out of the in-memory config.products
|
|
106
|
+
* array IN PLACE (so the array reference shared with the poll loop / listener /
|
|
107
|
+
* heartbeat sees the removal on its next cycle, and in-flight teams on surviving
|
|
108
|
+
* products are untouched). Any non-definitive outcome keeps the product.
|
|
109
|
+
*
|
|
110
|
+
* All fallible work is per-product isolated: a lookup or disk error for one
|
|
111
|
+
* product never aborts reconciliation of the others.
|
|
112
|
+
*/
|
|
113
|
+
export declare function reconcileDaemonProducts(config: Pick<DaemonConfig, 'products'>, deps: ReconcileDeps): Promise<ReconcileResult>;
|
|
114
|
+
//# sourceMappingURL=config-reconcile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config-reconcile.d.ts","sourceRoot":"","sources":["../src/config-reconcile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAMH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAO/C;;;;;;;GAOG;AACH,MAAM,MAAM,oBAAoB,GAAG,OAAO,GAAG,WAAW,GAAG,WAAW,CAAC;AAEvE,MAAM,WAAW,eAAe;IAC9B,oEAAoE;IACpE,OAAO,EAAE,OAAO,CAAC;IACjB,qEAAqE;IACrE,UAAU,EAAE,MAAM,CAAC;IACnB,mFAAmF;IACnF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,yDAAyD;IACzD,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAChE,4EAA4E;IAC5E,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,eAAe,CAAC;IAClD,mEAAmE;IACnE,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,eAAe;IAC9B,kCAAkC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;CACd;AAMD;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,UAAU,EAAE,MAAM,EAAE,GACnB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAoBzB;AAMD;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAIhE;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CACxC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GAChB,eAAe,CAgBjB;AAMD;;;;;;;;GAQG;AACH,wBAAsB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAQ5F;AAMD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,YAAY,GAAG,aAAa,CAOtE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,EACtC,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,eAAe,CAAC,CA2C1B"}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon config self-heal: prune products deleted from the backend.
|
|
3
|
+
*
|
|
4
|
+
* Registration into ~/.telora/daemon.json is asymmetric -- adding a product has
|
|
5
|
+
* two hot-reloading paths (telora_connector_start and telora_product create ->
|
|
6
|
+
* addProductToDaemonConfig() + SIGHUP), but removal has none. A product deleted
|
|
7
|
+
* out-of-band (SQL/UI) leaves a permanent stale entry, so every poll tick the
|
|
8
|
+
* focus engine iterates config.products, calls a backend that 404s, and scans a
|
|
9
|
+
* repoPath that may no longer exist.
|
|
10
|
+
*
|
|
11
|
+
* This module closes that gap by RECONCILIATION, not an explicit command:
|
|
12
|
+
* config (reality) reconciles toward the backend (intent). On each tick the
|
|
13
|
+
* daemon looks up every configured product; when a lookup returns a DEFINITIVE
|
|
14
|
+
* backend not-found (HTTP 404), that entry is removed from both the in-memory
|
|
15
|
+
* config.products array (poll loop stops iterating it immediately) and the
|
|
16
|
+
* on-disk daemon.json (rewritten 0600, all other fields preserved).
|
|
17
|
+
*
|
|
18
|
+
* SAFETY CONSTRAINT (non-negotiable): prune ONLY on a definitive not-found.
|
|
19
|
+
* Transient errors (network blip, 5xx, timeout, read-only mode, circuit open)
|
|
20
|
+
* and a merely-missing repo directory NEVER trigger removal -- a transient
|
|
21
|
+
* outage must not silently unregister live products. The backend not-found is
|
|
22
|
+
* the sole authoritative deletion signal.
|
|
23
|
+
*
|
|
24
|
+
* The disk read-merge-write mirrors the shape of the MCP-side
|
|
25
|
+
* addProductToDaemonConfig(); the daemon cannot import that helper (it lives in
|
|
26
|
+
* a separate package), so the shape is re-implemented here.
|
|
27
|
+
*/
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { homedir } from 'node:os';
|
|
30
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { callApiOnce } from './queries/shared.js';
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Pure config transform (no I/O -- unit-testable)
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
/**
|
|
36
|
+
* Return a copy of an existing daemon.json object with the given product ids
|
|
37
|
+
* removed from products[], preserving every other field (identity, engines,
|
|
38
|
+
* surviving products, and any operator customizations).
|
|
39
|
+
*
|
|
40
|
+
* Mirrors the normalization mergeConfig() applies on the add path: a legacy
|
|
41
|
+
* flat `productId` + `repoPath` pair is migrated into products[] first, then
|
|
42
|
+
* the flat fields are dropped so products[] is the single canonical surface.
|
|
43
|
+
*/
|
|
44
|
+
export function removeProductsFromConfig(existing, removedIds) {
|
|
45
|
+
const merged = { ...existing };
|
|
46
|
+
const removeSet = new Set(removedIds);
|
|
47
|
+
let products = Array.isArray(merged.products)
|
|
48
|
+
? merged.products
|
|
49
|
+
: [];
|
|
50
|
+
// Migrate legacy flat productId/repoPath into products[] (add-path parity).
|
|
51
|
+
if (products.length === 0 && typeof merged.productId === 'string' && merged.productId) {
|
|
52
|
+
const legacyRepoPath = typeof merged.repoPath === 'string' ? merged.repoPath : '';
|
|
53
|
+
products = [{ id: merged.productId, repoPath: legacyRepoPath }];
|
|
54
|
+
}
|
|
55
|
+
// Deprecated flat fields are never resurrected: products[] is canonical.
|
|
56
|
+
delete merged.productId;
|
|
57
|
+
delete merged.repoPath;
|
|
58
|
+
merged.products = products.filter((p) => !removeSet.has(p.id));
|
|
59
|
+
return merged;
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Config-path resolution + disk write (I/O)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the daemon.json the running daemon reads: the global
|
|
66
|
+
* ~/.telora/daemon.json when it exists (the daemon is launched with
|
|
67
|
+
* `--config <global>` in that case), else the repo-local .telora/daemon.json.
|
|
68
|
+
*
|
|
69
|
+
* Mirrors resolveDaemonPaths() in the MCP daemonConfig helper so add and remove
|
|
70
|
+
* target the identical file.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveDaemonConfigPath(repoPath) {
|
|
73
|
+
const globalPath = join(homedir(), '.telora', 'daemon.json');
|
|
74
|
+
if (existsSync(globalPath))
|
|
75
|
+
return globalPath;
|
|
76
|
+
return join(repoPath, '.telora', 'daemon.json');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read-merge-write daemon.json removing one product entry, at mode 0600.
|
|
80
|
+
* Skips (never partially writes) when the config is missing or corrupt.
|
|
81
|
+
*/
|
|
82
|
+
export function writeConfigRemovingProduct(configPath, productId) {
|
|
83
|
+
if (!existsSync(configPath)) {
|
|
84
|
+
return { written: false, configPath, reason: 'no_config' };
|
|
85
|
+
}
|
|
86
|
+
let existing;
|
|
87
|
+
try {
|
|
88
|
+
existing = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Corrupt config: do not silently overwrite. In-memory prune still happens.
|
|
92
|
+
return { written: false, configPath, reason: 'corrupt_config' };
|
|
93
|
+
}
|
|
94
|
+
const merged = removeProductsFromConfig(existing, [productId]);
|
|
95
|
+
writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
|
|
96
|
+
return { written: true, configPath };
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Backend lookup classification (I/O)
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
/**
|
|
102
|
+
* Look up a product by id and classify the outcome for reconciliation.
|
|
103
|
+
*
|
|
104
|
+
* Uses callApiOnce (a single attempt that BYPASSES the retry + circuit breaker)
|
|
105
|
+
* so a 404 resolves immediately and repeated not-found lookups never open the
|
|
106
|
+
* shared 'delivery' breaker. Only HTTP 404 is a definitive not-found; every
|
|
107
|
+
* other failure -- network error (no statusCode), 5xx, timeout, read-only-mode
|
|
108
|
+
* refusal (503) -- classifies as transient and keeps the product.
|
|
109
|
+
*/
|
|
110
|
+
export async function classifyProductLookup(productId) {
|
|
111
|
+
try {
|
|
112
|
+
await callApiOnce('get', { productId });
|
|
113
|
+
return 'found';
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
const statusCode = err.statusCode;
|
|
117
|
+
return statusCode === 404 ? 'not_found' : 'transient';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Orchestrator
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
/**
|
|
124
|
+
* Build the production dependency set for reconcileDaemonProducts. The config
|
|
125
|
+
* path is resolved once from the daemon's repoPath (stable for the run).
|
|
126
|
+
*/
|
|
127
|
+
export function buildReconcileDeps(config) {
|
|
128
|
+
const configPath = resolveDaemonConfigPath(config.repoPath);
|
|
129
|
+
return {
|
|
130
|
+
lookupProduct: (productId) => classifyProductLookup(productId),
|
|
131
|
+
pruneFromDisk: (productId) => writeConfigRemovingProduct(configPath, productId),
|
|
132
|
+
log: (message) => console.warn(message),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Reconcile config.products against backend reality once.
|
|
137
|
+
*
|
|
138
|
+
* For each configured product: look it up; on a DEFINITIVE not-found, remove it
|
|
139
|
+
* from the on-disk daemon.json and splice it out of the in-memory config.products
|
|
140
|
+
* array IN PLACE (so the array reference shared with the poll loop / listener /
|
|
141
|
+
* heartbeat sees the removal on its next cycle, and in-flight teams on surviving
|
|
142
|
+
* products are untouched). Any non-definitive outcome keeps the product.
|
|
143
|
+
*
|
|
144
|
+
* All fallible work is per-product isolated: a lookup or disk error for one
|
|
145
|
+
* product never aborts reconciliation of the others.
|
|
146
|
+
*/
|
|
147
|
+
export async function reconcileDaemonProducts(config, deps) {
|
|
148
|
+
// Snapshot the entries to check so mutating config.products mid-loop is safe.
|
|
149
|
+
const toCheck = [...config.products];
|
|
150
|
+
const pruned = [];
|
|
151
|
+
for (const product of toCheck) {
|
|
152
|
+
let outcome;
|
|
153
|
+
try {
|
|
154
|
+
outcome = await deps.lookupProduct(product.id);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Defensive: an unexpected throw is treated as transient -> keep.
|
|
158
|
+
outcome = 'transient';
|
|
159
|
+
}
|
|
160
|
+
// Safety constraint: prune ONLY on a definitive backend not-found.
|
|
161
|
+
if (outcome !== 'not_found')
|
|
162
|
+
continue;
|
|
163
|
+
let disk;
|
|
164
|
+
try {
|
|
165
|
+
disk = deps.pruneFromDisk(product.id);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
disk = {
|
|
169
|
+
written: false,
|
|
170
|
+
configPath: 'unknown',
|
|
171
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// In-memory self-heal: splice in place to preserve the shared array ref.
|
|
175
|
+
const idx = config.products.findIndex((p) => p.id === product.id);
|
|
176
|
+
if (idx >= 0)
|
|
177
|
+
config.products.splice(idx, 1);
|
|
178
|
+
pruned.push(product.id);
|
|
179
|
+
deps.log(`[config-reconcile] pruned product ${product.id} (repoPath: ${product.repoPath}) ` +
|
|
180
|
+
'-- backend returned not-found; ' +
|
|
181
|
+
(disk.written
|
|
182
|
+
? `removed from ${disk.configPath}`
|
|
183
|
+
: `on-disk write skipped (${disk.reason ?? 'unknown'}); in-memory only`));
|
|
184
|
+
}
|
|
185
|
+
return { checked: toCheck.length, pruned, kept: config.products.length };
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=config-reconcile.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config-reconcile.js","sourceRoot":"","sources":["../src/config-reconcile.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drain-to-idle teardown controller.
|
|
3
|
+
*
|
|
4
|
+
* A CLI restart/stop/update (and a restart-only config change) should not kill
|
|
5
|
+
* the running fleet mid-work. Instead it should ARM intake-drain (no new focus
|
|
6
|
+
* team spawns) and DEFER the actual teardown until the daemon reaches a
|
|
7
|
+
* zero-team boundary -- the exact `isBatchSafeIdle` seam the routine
|
|
8
|
+
* auto-updater already waits for. A `--now` escape forces the immediate
|
|
9
|
+
* teardown (today's SIGTERM behavior) for an operator who cannot wait.
|
|
10
|
+
*
|
|
11
|
+
* This module is the pure controller: it takes the idle gate, the drain arm,
|
|
12
|
+
* and the teardown thunk as injected seams, so it unit-tests without a live
|
|
13
|
+
* daemon, real teams, or signals. The daemon wires it to the live
|
|
14
|
+
* `isBatchSafeIdle` gate + `armDrainForUpdate` + the graceful `shutdown()`.
|
|
15
|
+
*/
|
|
16
|
+
/** Injected seams + policy for {@link runDrainToIdleTeardown}. */
|
|
17
|
+
export interface DrainToIdleOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Force immediate teardown -- skip the drain wait entirely (the `--now`
|
|
20
|
+
* escape). Preserves today's behavior for an operator who cannot wait.
|
|
21
|
+
*/
|
|
22
|
+
immediate?: boolean;
|
|
23
|
+
/** Batch-safe idle gate: true when no focus team is in flight (zero-team boundary). */
|
|
24
|
+
isBatchSafeIdle: () => boolean;
|
|
25
|
+
/** Arm intake drain so no new focus team spawns while we wait. */
|
|
26
|
+
armDrain: () => void;
|
|
27
|
+
/** Release intake drain (best-effort, e.g. if teardown is aborted). Optional. */
|
|
28
|
+
releaseDrain?: () => void;
|
|
29
|
+
/** The teardown to run once the boundary is reached (the daemon's shutdown()). */
|
|
30
|
+
teardown: () => void | Promise<void>;
|
|
31
|
+
/** Sleep seam. Defaults to setTimeout. */
|
|
32
|
+
sleep?: (ms: number) => Promise<void>;
|
|
33
|
+
/** Poll cadence for the idle gate (ms). Default 1000. */
|
|
34
|
+
pollIntervalMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Optional safety cap (ms). When set and the fleet has not idled within it,
|
|
37
|
+
* the teardown is FORCED with a loud warning -- an operator-set bound that
|
|
38
|
+
* opts into a forced stop after waiting. Unset (default) waits indefinitely;
|
|
39
|
+
* the fleet is never killed unless `immediate` is passed. The daemon runs
|
|
40
|
+
* this in the background, so an indefinite wait never blocks the CLI.
|
|
41
|
+
*/
|
|
42
|
+
maxWaitMs?: number;
|
|
43
|
+
/** Logical clock (ms), injectable for tests. Default Date.now. */
|
|
44
|
+
now?: () => number;
|
|
45
|
+
/** Logger seam. Defaults to console.log. */
|
|
46
|
+
log?: (msg: string) => void;
|
|
47
|
+
}
|
|
48
|
+
/** Outcome of a drain-to-idle teardown, for logging + tests. */
|
|
49
|
+
export interface DrainToIdleResult {
|
|
50
|
+
/** True when the teardown thunk ran (always true on a normal completion). */
|
|
51
|
+
firedTeardown: boolean;
|
|
52
|
+
/** True when `immediate` bypassed the drain wait. */
|
|
53
|
+
immediate: boolean;
|
|
54
|
+
/** Logical ms spent waiting for the boundary (0 when immediate). */
|
|
55
|
+
waitedMs: number;
|
|
56
|
+
/** True when the wait hit `maxWaitMs` before idling (teardown forced). */
|
|
57
|
+
timedOut: boolean;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Run the drain-to-idle teardown. When `immediate`, tears down at once.
|
|
61
|
+
* Otherwise arms the drain, waits for the zero-team boundary (polling the idle
|
|
62
|
+
* gate), then tears down exactly once. Never throws out of the wait loop; a
|
|
63
|
+
* teardown thunk that throws propagates to the caller.
|
|
64
|
+
*/
|
|
65
|
+
export declare function runDrainToIdleTeardown(opts: DrainToIdleOptions): Promise<DrainToIdleResult>;
|
|
66
|
+
//# sourceMappingURL=drain-teardown.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"drain-teardown.d.ts","sourceRoot":"","sources":["../src/drain-teardown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,kEAAkE;AAClE,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,uFAAuF;IACvF,eAAe,EAAE,MAAM,OAAO,CAAC;IAC/B,kEAAkE;IAClE,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,iFAAiF;IACjF,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,kFAAkF;IAClF,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,0CAA0C;IAC1C,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,yDAAyD;IACzD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kEAAkE;IAClE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,4CAA4C;IAC5C,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED,gEAAgE;AAChE,MAAM,WAAW,iBAAiB;IAChC,6EAA6E;IAC7E,aAAa,EAAE,OAAO,CAAC;IACvB,qDAAqD;IACrD,SAAS,EAAE,OAAO,CAAC;IACnB,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,0EAA0E;IAC1E,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAwCjG"}
|