chainlesschain 0.45.10 → 0.45.11
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/bin/chainlesschain.js +0 -0
- package/package.json +1 -1
- package/src/commands/config.js +44 -0
- package/src/constants.js +1 -0
- package/src/lib/background-task-manager.js +305 -0
- package/src/lib/background-task-worker.js +50 -0
- package/src/lib/feature-flags.js +182 -0
- package/src/lib/jsonl-session-store.js +237 -0
- package/src/lib/prompt-compressor.js +351 -0
- package/src/lib/worktree-isolator.js +231 -0
- package/src/repl/agent-repl.js +38 -2
package/bin/chainlesschain.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
package/src/commands/config.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "../lib/config-manager.js";
|
|
9
9
|
import { getConfigPath } from "../lib/paths.js";
|
|
10
10
|
import logger from "../lib/logger.js";
|
|
11
|
+
import { listFeatures, setFeature, getFlagInfo } from "../lib/feature-flags.js";
|
|
11
12
|
|
|
12
13
|
export function registerConfigCommand(program) {
|
|
13
14
|
const cmd = program
|
|
@@ -69,6 +70,49 @@ export function registerConfigCommand(program) {
|
|
|
69
70
|
}
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
// ── Feature Flags ──────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const featuresCmd = cmd
|
|
76
|
+
.command("features")
|
|
77
|
+
.description("Manage feature flags");
|
|
78
|
+
|
|
79
|
+
featuresCmd
|
|
80
|
+
.command("list")
|
|
81
|
+
.alias("ls")
|
|
82
|
+
.description("Show all feature flags and their status")
|
|
83
|
+
.action(() => {
|
|
84
|
+
const flags = listFeatures();
|
|
85
|
+
logger.log(chalk.bold("\n Feature Flags\n"));
|
|
86
|
+
for (const f of flags) {
|
|
87
|
+
const status = f.enabled ? chalk.green("● ON ") : chalk.gray("○ OFF");
|
|
88
|
+
const src = chalk.gray(`[${f.source}]`);
|
|
89
|
+
logger.log(` ${status} ${chalk.cyan(f.name)} ${src}`);
|
|
90
|
+
logger.log(` ${chalk.gray(f.description)}`);
|
|
91
|
+
}
|
|
92
|
+
logger.newline();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
featuresCmd
|
|
96
|
+
.command("enable")
|
|
97
|
+
.description("Enable a feature flag")
|
|
98
|
+
.argument("<name>", "Flag name (e.g. CONTEXT_SNIP)")
|
|
99
|
+
.action((name) => {
|
|
100
|
+
setFeature(name, true);
|
|
101
|
+
const info = getFlagInfo(name);
|
|
102
|
+
logger.success(`Enabled ${name}${info ? ` — ${info.description}` : ""}`);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
featuresCmd
|
|
106
|
+
.command("disable")
|
|
107
|
+
.description("Disable a feature flag")
|
|
108
|
+
.argument("<name>", "Flag name (e.g. CONTEXT_SNIP)")
|
|
109
|
+
.action((name) => {
|
|
110
|
+
setFeature(name, false);
|
|
111
|
+
logger.success(`Disabled ${name}`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── Reset ──────────────────────────────────────────────────────────
|
|
115
|
+
|
|
72
116
|
cmd
|
|
73
117
|
.command("reset")
|
|
74
118
|
.description("Reset configuration to defaults")
|
package/src/constants.js
CHANGED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background Task Manager — daemon task queue with completion notifications.
|
|
3
|
+
*
|
|
4
|
+
* Tasks run in child_process.fork() for isolation.
|
|
5
|
+
* Queue persisted to .chainlesschain/tasks/queue.jsonl.
|
|
6
|
+
* Completion notifications delivered to REPL callback.
|
|
7
|
+
*
|
|
8
|
+
* Feature-flag gated: BACKGROUND_TASKS
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { fork } from "node:child_process";
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
appendFileSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { EventEmitter } from "node:events";
|
|
22
|
+
import { getHomeDir } from "./paths.js";
|
|
23
|
+
|
|
24
|
+
function getTasksDir() {
|
|
25
|
+
const dir = join(getHomeDir(), "tasks");
|
|
26
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function queuePath() {
|
|
31
|
+
return join(getTasksDir(), "queue.jsonl");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Task Status ─────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export const TaskStatus = {
|
|
37
|
+
PENDING: "pending",
|
|
38
|
+
RUNNING: "running",
|
|
39
|
+
COMPLETED: "completed",
|
|
40
|
+
FAILED: "failed",
|
|
41
|
+
TIMEOUT: "timeout",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ── BackgroundTaskManager ───────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export class BackgroundTaskManager extends EventEmitter {
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} options
|
|
49
|
+
* @param {number} [options.maxConcurrent=3] — Max parallel background tasks
|
|
50
|
+
* @param {number} [options.heartbeatTimeout=60000] — Task heartbeat timeout (ms)
|
|
51
|
+
*/
|
|
52
|
+
constructor(options = {}) {
|
|
53
|
+
super();
|
|
54
|
+
this.maxConcurrent = options.maxConcurrent || 3;
|
|
55
|
+
this.heartbeatTimeout = options.heartbeatTimeout || 60000;
|
|
56
|
+
this.tasks = new Map(); // id -> task object
|
|
57
|
+
this.processes = new Map(); // id -> child process
|
|
58
|
+
this._checkInterval = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create and queue a new background task.
|
|
63
|
+
* @param {object} spec
|
|
64
|
+
* @param {string} spec.type — Task type (e.g. "shell", "agent", "script")
|
|
65
|
+
* @param {string} spec.command — Command or script to execute
|
|
66
|
+
* @param {string} [spec.cwd] — Working directory
|
|
67
|
+
* @param {string} [spec.description] — Human-readable description
|
|
68
|
+
* @returns {object} Created task
|
|
69
|
+
*/
|
|
70
|
+
create(spec) {
|
|
71
|
+
if (this._runningCount() >= this.maxConcurrent) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Max concurrent tasks reached (${this.maxConcurrent}). Wait for a task to finish.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const id = `task-${Date.now()}-${createHash("sha256").update(Math.random().toString()).digest("hex").slice(0, 6)}`;
|
|
78
|
+
|
|
79
|
+
const task = {
|
|
80
|
+
id,
|
|
81
|
+
type: spec.type || "shell",
|
|
82
|
+
command: spec.command,
|
|
83
|
+
cwd: spec.cwd || process.cwd(),
|
|
84
|
+
description: spec.description || spec.command,
|
|
85
|
+
status: TaskStatus.PENDING,
|
|
86
|
+
createdAt: Date.now(),
|
|
87
|
+
startedAt: null,
|
|
88
|
+
completedAt: null,
|
|
89
|
+
lastHeartbeat: null,
|
|
90
|
+
result: null,
|
|
91
|
+
error: null,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
this.tasks.set(id, task);
|
|
95
|
+
this._persistTask(task);
|
|
96
|
+
return task;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Start a pending task (runs in child process).
|
|
101
|
+
*/
|
|
102
|
+
start(taskId) {
|
|
103
|
+
const task = this.tasks.get(taskId);
|
|
104
|
+
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
105
|
+
if (task.status !== TaskStatus.PENDING)
|
|
106
|
+
throw new Error(`Task ${taskId} is not pending (status: ${task.status})`);
|
|
107
|
+
|
|
108
|
+
task.status = TaskStatus.RUNNING;
|
|
109
|
+
task.startedAt = Date.now();
|
|
110
|
+
task.lastHeartbeat = Date.now();
|
|
111
|
+
|
|
112
|
+
// Create a wrapper script that executes the command
|
|
113
|
+
const child = fork(
|
|
114
|
+
join(import.meta.dirname || ".", "background-task-worker.js"),
|
|
115
|
+
[task.command, task.cwd, task.type],
|
|
116
|
+
{
|
|
117
|
+
cwd: task.cwd,
|
|
118
|
+
silent: true,
|
|
119
|
+
env: { ...process.env, CC_TASK_ID: taskId },
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
this.processes.set(taskId, child);
|
|
124
|
+
|
|
125
|
+
child.on("message", (msg) => {
|
|
126
|
+
if (msg.type === "heartbeat") {
|
|
127
|
+
task.lastHeartbeat = Date.now();
|
|
128
|
+
} else if (msg.type === "result") {
|
|
129
|
+
this._complete(taskId, TaskStatus.COMPLETED, msg.data, null);
|
|
130
|
+
} else if (msg.type === "error") {
|
|
131
|
+
this._complete(taskId, TaskStatus.FAILED, null, msg.error);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
child.on("exit", (code) => {
|
|
136
|
+
if (task.status === TaskStatus.RUNNING) {
|
|
137
|
+
if (code === 0) {
|
|
138
|
+
this._complete(
|
|
139
|
+
taskId,
|
|
140
|
+
TaskStatus.COMPLETED,
|
|
141
|
+
"Process exited (0)",
|
|
142
|
+
null,
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
this._complete(
|
|
146
|
+
taskId,
|
|
147
|
+
TaskStatus.FAILED,
|
|
148
|
+
null,
|
|
149
|
+
`Process exited with code ${code}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
this.processes.delete(taskId);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
child.on("error", (err) => {
|
|
157
|
+
this._complete(taskId, TaskStatus.FAILED, null, err.message);
|
|
158
|
+
this.processes.delete(taskId);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
this._persistTask(task);
|
|
162
|
+
this._ensureHeartbeatChecker();
|
|
163
|
+
return task;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Create and immediately start a task.
|
|
168
|
+
*/
|
|
169
|
+
run(spec) {
|
|
170
|
+
const task = this.create(spec);
|
|
171
|
+
this.start(task.id);
|
|
172
|
+
return task;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get a task by ID.
|
|
177
|
+
*/
|
|
178
|
+
get(taskId) {
|
|
179
|
+
return this.tasks.get(taskId) || null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* List all tasks.
|
|
184
|
+
*/
|
|
185
|
+
list(filter = {}) {
|
|
186
|
+
let tasks = [...this.tasks.values()];
|
|
187
|
+
if (filter.status) {
|
|
188
|
+
tasks = tasks.filter((t) => t.status === filter.status);
|
|
189
|
+
}
|
|
190
|
+
return tasks.sort((a, b) => b.createdAt - a.createdAt);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Stop a running task.
|
|
195
|
+
*/
|
|
196
|
+
stop(taskId) {
|
|
197
|
+
const child = this.processes.get(taskId);
|
|
198
|
+
if (child) {
|
|
199
|
+
child.kill("SIGTERM");
|
|
200
|
+
setTimeout(() => {
|
|
201
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
202
|
+
}, 2000);
|
|
203
|
+
}
|
|
204
|
+
this._complete(taskId, TaskStatus.FAILED, null, "Stopped by user");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Clean up completed/failed tasks older than maxAge.
|
|
209
|
+
* @param {number} [maxAge=3600000] — Max age in ms (default 1 hour)
|
|
210
|
+
*/
|
|
211
|
+
cleanup(maxAge = 3600000) {
|
|
212
|
+
const cutoff = Date.now() - maxAge;
|
|
213
|
+
let removed = 0;
|
|
214
|
+
for (const [id, task] of this.tasks) {
|
|
215
|
+
if (
|
|
216
|
+
(task.status === TaskStatus.COMPLETED ||
|
|
217
|
+
task.status === TaskStatus.FAILED ||
|
|
218
|
+
task.status === TaskStatus.TIMEOUT) &&
|
|
219
|
+
task.completedAt &&
|
|
220
|
+
task.completedAt < cutoff
|
|
221
|
+
) {
|
|
222
|
+
this.tasks.delete(id);
|
|
223
|
+
removed++;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return removed;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Destroy the manager (kill all running tasks, clear intervals).
|
|
231
|
+
*/
|
|
232
|
+
destroy() {
|
|
233
|
+
if (this._checkInterval) {
|
|
234
|
+
clearInterval(this._checkInterval);
|
|
235
|
+
this._checkInterval = null;
|
|
236
|
+
}
|
|
237
|
+
for (const [id] of this.processes) {
|
|
238
|
+
this.stop(id);
|
|
239
|
+
}
|
|
240
|
+
this.tasks.clear();
|
|
241
|
+
this.processes.clear();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Internal ──────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
_complete(taskId, status, result, error) {
|
|
247
|
+
const task = this.tasks.get(taskId);
|
|
248
|
+
if (!task) return;
|
|
249
|
+
|
|
250
|
+
task.status = status;
|
|
251
|
+
task.completedAt = Date.now();
|
|
252
|
+
task.result = result;
|
|
253
|
+
task.error = error;
|
|
254
|
+
|
|
255
|
+
this._persistTask(task);
|
|
256
|
+
this.emit("task:complete", task);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
_runningCount() {
|
|
260
|
+
let count = 0;
|
|
261
|
+
for (const task of this.tasks.values()) {
|
|
262
|
+
if (task.status === TaskStatus.RUNNING) count++;
|
|
263
|
+
}
|
|
264
|
+
return count;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_persistTask(task) {
|
|
268
|
+
try {
|
|
269
|
+
const line = JSON.stringify(task) + "\n";
|
|
270
|
+
appendFileSync(queuePath(), line, "utf-8");
|
|
271
|
+
} catch (_e) {
|
|
272
|
+
// Non-critical
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
_ensureHeartbeatChecker() {
|
|
277
|
+
if (this._checkInterval) return;
|
|
278
|
+
|
|
279
|
+
this._checkInterval = setInterval(
|
|
280
|
+
() => {
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
for (const [id, task] of this.tasks) {
|
|
283
|
+
if (
|
|
284
|
+
task.status === TaskStatus.RUNNING &&
|
|
285
|
+
task.lastHeartbeat &&
|
|
286
|
+
now - task.lastHeartbeat > this.heartbeatTimeout
|
|
287
|
+
) {
|
|
288
|
+
this._complete(id, TaskStatus.TIMEOUT, null, "Heartbeat timeout");
|
|
289
|
+
const child = this.processes.get(id);
|
|
290
|
+
if (child) {
|
|
291
|
+
child.kill("SIGKILL");
|
|
292
|
+
this.processes.delete(id);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
Math.min(this.heartbeatTimeout / 2, 10000),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Don't keep process alive for the checker
|
|
301
|
+
if (this._checkInterval.unref) {
|
|
302
|
+
this._checkInterval.unref();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background Task Worker — child process that executes a command.
|
|
3
|
+
*
|
|
4
|
+
* Args: [command, cwd, type]
|
|
5
|
+
* Sends messages to parent: { type: "heartbeat"|"result"|"error", ... }
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
const [command, cwd, type] = process.argv.slice(2);
|
|
11
|
+
|
|
12
|
+
// Heartbeat every 5 seconds
|
|
13
|
+
const heartbeat = setInterval(() => {
|
|
14
|
+
if (process.send) process.send({ type: "heartbeat" });
|
|
15
|
+
}, 5000);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
let result;
|
|
19
|
+
|
|
20
|
+
if (type === "shell") {
|
|
21
|
+
result = execSync(command, {
|
|
22
|
+
cwd: cwd || process.cwd(),
|
|
23
|
+
encoding: "utf-8",
|
|
24
|
+
timeout: 300000, // 5 min max
|
|
25
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
26
|
+
});
|
|
27
|
+
} else {
|
|
28
|
+
// Default: treat as shell
|
|
29
|
+
result = execSync(command, {
|
|
30
|
+
cwd: cwd || process.cwd(),
|
|
31
|
+
encoding: "utf-8",
|
|
32
|
+
timeout: 300000,
|
|
33
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (process.send) {
|
|
38
|
+
process.send({ type: "result", data: result || "Done" });
|
|
39
|
+
}
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (process.send) {
|
|
42
|
+
process.send({
|
|
43
|
+
type: "error",
|
|
44
|
+
error: err.stderr || err.message || String(err),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
} finally {
|
|
49
|
+
clearInterval(heartbeat);
|
|
50
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature Flag System — runtime feature gating for gradual rollout.
|
|
3
|
+
*
|
|
4
|
+
* Flags are stored in .chainlesschain/config.json under "features" key.
|
|
5
|
+
* Each flag can be:
|
|
6
|
+
* - boolean (true/false)
|
|
7
|
+
* - number 0-100 (percentage rollout, hashed by machine-id)
|
|
8
|
+
* - object { enabled, variant, description }
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { feature, featureVariant, listFeatures } from "./feature-flags.js";
|
|
12
|
+
* if (feature("CONTEXT_SNIP")) { ... }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { loadConfig, getConfigValue, saveConfig } from "./config-manager.js";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { hostname } from "node:os";
|
|
18
|
+
|
|
19
|
+
// ── Flag Registry (source of truth for known flags) ────────────────────
|
|
20
|
+
|
|
21
|
+
const FLAG_REGISTRY = {
|
|
22
|
+
BACKGROUND_TASKS: {
|
|
23
|
+
description: "Enable background task queue with daemon execution",
|
|
24
|
+
default: false,
|
|
25
|
+
},
|
|
26
|
+
WORKTREE_ISOLATION: {
|
|
27
|
+
description: "Enable git worktree isolation for agent tasks",
|
|
28
|
+
default: false,
|
|
29
|
+
},
|
|
30
|
+
CONTEXT_SNIP: {
|
|
31
|
+
description: "Enable snipCompact strategy in context compression",
|
|
32
|
+
default: false,
|
|
33
|
+
},
|
|
34
|
+
CONTEXT_COLLAPSE: {
|
|
35
|
+
description: "Enable contextCollapse strategy in context compression",
|
|
36
|
+
default: false,
|
|
37
|
+
},
|
|
38
|
+
JSONL_SESSION: {
|
|
39
|
+
description: "Use JSONL append-only format for session persistence",
|
|
40
|
+
default: false,
|
|
41
|
+
},
|
|
42
|
+
PROMPT_COMPRESSOR: {
|
|
43
|
+
description: "Enable CLI prompt compressor (auto/snip/collapse)",
|
|
44
|
+
default: true,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ── Core API ────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if a feature flag is enabled.
|
|
52
|
+
* @param {string} name - Flag name (e.g. "CONTEXT_SNIP")
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function feature(name) {
|
|
56
|
+
const value = _resolve(name);
|
|
57
|
+
if (typeof value === "boolean") return value;
|
|
58
|
+
if (typeof value === "number") return _percentageCheck(name, value);
|
|
59
|
+
if (value && typeof value === "object") return Boolean(value.enabled);
|
|
60
|
+
return _getDefault(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the variant string for a feature (for A/B style flags).
|
|
65
|
+
* @param {string} name
|
|
66
|
+
* @returns {string|null}
|
|
67
|
+
*/
|
|
68
|
+
export function featureVariant(name) {
|
|
69
|
+
const value = _resolve(name);
|
|
70
|
+
if (value && typeof value === "object" && value.variant) {
|
|
71
|
+
return value.variant;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* List all known feature flags with their current values.
|
|
78
|
+
* @returns {Array<{name, enabled, description, source, raw}>}
|
|
79
|
+
*/
|
|
80
|
+
export function listFeatures() {
|
|
81
|
+
const config = loadConfig();
|
|
82
|
+
const features = config.features || {};
|
|
83
|
+
const result = [];
|
|
84
|
+
|
|
85
|
+
for (const [name, meta] of Object.entries(FLAG_REGISTRY)) {
|
|
86
|
+
const raw = features[name];
|
|
87
|
+
const enabled = feature(name);
|
|
88
|
+
const source =
|
|
89
|
+
raw !== undefined
|
|
90
|
+
? "config"
|
|
91
|
+
: process.env[`CC_FLAG_${name}`] !== undefined
|
|
92
|
+
? "env"
|
|
93
|
+
: "default";
|
|
94
|
+
|
|
95
|
+
result.push({
|
|
96
|
+
name,
|
|
97
|
+
enabled,
|
|
98
|
+
description: meta.description,
|
|
99
|
+
source,
|
|
100
|
+
raw: raw !== undefined ? raw : meta.default,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Include unknown flags from config (user-defined)
|
|
105
|
+
for (const [name, raw] of Object.entries(features)) {
|
|
106
|
+
if (!FLAG_REGISTRY[name]) {
|
|
107
|
+
result.push({
|
|
108
|
+
name,
|
|
109
|
+
enabled: Boolean(raw),
|
|
110
|
+
description: "(user-defined)",
|
|
111
|
+
source: "config",
|
|
112
|
+
raw,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Set a feature flag value in config.
|
|
122
|
+
* @param {string} name
|
|
123
|
+
* @param {boolean|number|string} value
|
|
124
|
+
*/
|
|
125
|
+
export function setFeature(name, value) {
|
|
126
|
+
const config = loadConfig();
|
|
127
|
+
if (!config.features) config.features = {};
|
|
128
|
+
config.features[name] = value;
|
|
129
|
+
saveConfig(config);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get the registry entry for a known flag (or null).
|
|
134
|
+
* @param {string} name
|
|
135
|
+
* @returns {{description: string, default: boolean}|null}
|
|
136
|
+
*/
|
|
137
|
+
export function getFlagInfo(name) {
|
|
138
|
+
return FLAG_REGISTRY[name] || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Internal helpers ───────────────────────────────���────────────────────
|
|
142
|
+
|
|
143
|
+
function _resolve(name) {
|
|
144
|
+
// Priority: env var > config > default
|
|
145
|
+
const envKey = `CC_FLAG_${name}`;
|
|
146
|
+
if (process.env[envKey] !== undefined) {
|
|
147
|
+
const envVal = process.env[envKey];
|
|
148
|
+
if (envVal === "true" || envVal === "1") return true;
|
|
149
|
+
if (envVal === "false" || envVal === "0") return false;
|
|
150
|
+
const num = Number(envVal);
|
|
151
|
+
if (!isNaN(num)) return num;
|
|
152
|
+
return envVal;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const configVal = getConfigValue(`features.${name}`);
|
|
156
|
+
if (configVal !== undefined) return configVal;
|
|
157
|
+
|
|
158
|
+
return undefined; // let caller fall to default
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function _getDefault(name) {
|
|
162
|
+
const meta = FLAG_REGISTRY[name];
|
|
163
|
+
return meta ? meta.default : false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _percentageCheck(name, percentage) {
|
|
167
|
+
if (percentage <= 0) return false;
|
|
168
|
+
if (percentage >= 100) return true;
|
|
169
|
+
const hash = createHash("md5")
|
|
170
|
+
.update(`${name}:${_machineId()}`)
|
|
171
|
+
.digest("hex");
|
|
172
|
+
const bucket = parseInt(hash.slice(0, 8), 16) % 100;
|
|
173
|
+
return bucket < percentage;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let _cachedMachineId = null;
|
|
177
|
+
function _machineId() {
|
|
178
|
+
if (!_cachedMachineId) {
|
|
179
|
+
_cachedMachineId = hostname() || "unknown";
|
|
180
|
+
}
|
|
181
|
+
return _cachedMachineId;
|
|
182
|
+
}
|