@phenx-inc/ctlsurf 0.3.13 → 0.3.15
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/ctlsurf-worker.js +38 -22
- package/out/headless/index.mjs +295 -3
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +351 -48
- package/out/preload/index.js +11 -0
- package/out/renderer/assets/{cssMode-CYoo4t9f.js → cssMode-D5dPwEy5.js} +3 -3
- package/out/renderer/assets/{freemarker2--UQnPZsn.js → freemarker2-c5jJjQ9s.js} +1 -1
- package/out/renderer/assets/{handlebars-DVDrmX0C.js → handlebars-BTbmOxx9.js} +1 -1
- package/out/renderer/assets/{html-D1-cXoLy.js → html-3cIIQcxO.js} +1 -1
- package/out/renderer/assets/{htmlMode-f5nBuprq.js → htmlMode-DYbpW1yY.js} +3 -3
- package/out/renderer/assets/{index-65hyKM_8.css → index-6KvOnYL1.css} +404 -0
- package/out/renderer/assets/{index-D23nru43.js → index-D2MUZin7.js} +332 -23
- package/out/renderer/assets/{javascript-CcarFzBL.js → javascript-CDuCMm-6.js} +2 -2
- package/out/renderer/assets/{jsonMode-BvF-xK9U.js → jsonMode-COLqbq0s.js} +3 -3
- package/out/renderer/assets/{liquid-CHLtUKl2.js → liquid-BFcqZizB.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-B9aNeatS.js → lspLanguageFeatures-CbkEcL-z.js} +1 -1
- package/out/renderer/assets/{mdx-HGDrkifZ.js → mdx-DyK93oEE.js} +1 -1
- package/out/renderer/assets/{python-B_dPzjJ6.js → python-D4lCwSVr.js} +1 -1
- package/out/renderer/assets/{razor-CHheM4ot.js → razor-DdkE9XVt.js} +1 -1
- package/out/renderer/assets/{tsMode-CdC3i1gG.js → tsMode-BrQ4Fsc-.js} +1 -1
- package/out/renderer/assets/{typescript-BX6guVRK.js → typescript-BakbYMnC.js} +1 -1
- package/out/renderer/assets/{xml-CpS-pOPE.js → xml-DHDW9Xhp.js} +1 -1
- package/out/renderer/assets/{yaml-Du0AjOHW.js → yaml-1Ayv_J3q.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agents.ts +36 -1
- package/src/main/ctlsurfApi.ts +11 -0
- package/src/main/headless.ts +5 -3
- package/src/main/index.ts +24 -2
- package/src/main/orchestrator.ts +66 -0
- package/src/main/ticketStore.ts +252 -0
- package/src/preload/index.ts +17 -0
- package/src/renderer/App.tsx +40 -1
- package/src/renderer/components/TicketPanel.tsx +308 -0
- package/src/renderer/styles.css +404 -0
package/out/main/index.js
CHANGED
|
@@ -59,6 +59,21 @@ function getShellCommand() {
|
|
|
59
59
|
if (process.platform === "win32") return "powershell.exe";
|
|
60
60
|
return process.env.SHELL || "/bin/zsh";
|
|
61
61
|
}
|
|
62
|
+
function isCommandAvailable(command) {
|
|
63
|
+
const dirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
|
64
|
+
const isWin = process.platform === "win32";
|
|
65
|
+
const exts = isWin ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean) : [""];
|
|
66
|
+
for (const dir of dirs) {
|
|
67
|
+
for (const ext of exts) {
|
|
68
|
+
try {
|
|
69
|
+
fs.accessSync(path.join(dir, command + ext), isWin ? fs.constants.F_OK : fs.constants.X_OK);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
62
77
|
function getBuiltinAgents() {
|
|
63
78
|
return [
|
|
64
79
|
{
|
|
@@ -85,8 +100,14 @@ function getBuiltinAgents() {
|
|
|
85
100
|
}
|
|
86
101
|
];
|
|
87
102
|
}
|
|
103
|
+
function getAvailableAgents() {
|
|
104
|
+
const all = getBuiltinAgents();
|
|
105
|
+
const coding = all.filter((a) => isCodingAgent(a) && isCommandAvailable(a.command));
|
|
106
|
+
const shell = all.filter((a) => !isCodingAgent(a));
|
|
107
|
+
return [...coding, ...shell];
|
|
108
|
+
}
|
|
88
109
|
function getDefaultAgent() {
|
|
89
|
-
return
|
|
110
|
+
return getAvailableAgents()[0];
|
|
90
111
|
}
|
|
91
112
|
function isCodingAgent(agent) {
|
|
92
113
|
return agent.id !== "shell";
|
|
@@ -230,6 +251,14 @@ class CtlsurfApi {
|
|
|
230
251
|
async updateRow(blockId, rowId, data) {
|
|
231
252
|
return this.request("PUT", `/datastore/${blockId}/rows/${rowId}`, { data });
|
|
232
253
|
}
|
|
254
|
+
async queryRows(blockId, opts) {
|
|
255
|
+
const params = new URLSearchParams();
|
|
256
|
+
if (opts?.orderBy) params.set("order_by", opts.orderBy);
|
|
257
|
+
if (opts?.order) params.set("order", opts.order);
|
|
258
|
+
params.set("limit", String(opts?.limit ?? 200));
|
|
259
|
+
const qs = params.toString();
|
|
260
|
+
return this.request("GET", `/datastore/${blockId}/rows${qs ? `?${qs}` : ""}`);
|
|
261
|
+
}
|
|
233
262
|
async getDatastoreSchema(blockId) {
|
|
234
263
|
return this.request("GET", `/datastore/${blockId}/schema`);
|
|
235
264
|
}
|
|
@@ -9902,7 +9931,7 @@ function requireWebsocketServer() {
|
|
|
9902
9931
|
return websocketServer;
|
|
9903
9932
|
}
|
|
9904
9933
|
requireWebsocketServer();
|
|
9905
|
-
function log$
|
|
9934
|
+
function log$3(...args) {
|
|
9906
9935
|
try {
|
|
9907
9936
|
console.log(...args);
|
|
9908
9937
|
} catch {
|
|
@@ -9999,7 +10028,7 @@ class WorkerWsClient {
|
|
|
9999
10028
|
}
|
|
10000
10029
|
doConnect() {
|
|
10001
10030
|
if (!this.apiKey || !this.registration) {
|
|
10002
|
-
log$
|
|
10031
|
+
log$3("[worker-ws] No API key or registration, skipping connect");
|
|
10003
10032
|
return;
|
|
10004
10033
|
}
|
|
10005
10034
|
this.clearTimers();
|
|
@@ -10022,22 +10051,22 @@ class WorkerWsClient {
|
|
|
10022
10051
|
doConnectNow() {
|
|
10023
10052
|
if (!this.apiKey || !this.registration) return;
|
|
10024
10053
|
if (!this.shouldReconnect) {
|
|
10025
|
-
log$
|
|
10054
|
+
log$3("[worker-ws] shouldReconnect is false, aborting connect");
|
|
10026
10055
|
return;
|
|
10027
10056
|
}
|
|
10028
10057
|
this.setStatus("connecting");
|
|
10029
10058
|
const wsBase = this.baseUrl.replace(/^http/, "ws");
|
|
10030
10059
|
const url = `${wsBase}/api/ws/worker?token=${encodeURIComponent(this.apiKey)}`;
|
|
10031
|
-
log$
|
|
10060
|
+
log$3(`[worker-ws] Connecting to ${url.replace(/token=.*/, "token=***")}...`);
|
|
10032
10061
|
try {
|
|
10033
10062
|
this.ws = new WS(url);
|
|
10034
10063
|
} catch (err) {
|
|
10035
|
-
log$
|
|
10064
|
+
log$3("[worker-ws] Failed to create WebSocket:", err);
|
|
10036
10065
|
this.scheduleReconnect();
|
|
10037
10066
|
return;
|
|
10038
10067
|
}
|
|
10039
10068
|
this.ws.onopen = () => {
|
|
10040
|
-
log$
|
|
10069
|
+
log$3("[worker-ws] Connected, sending register");
|
|
10041
10070
|
this.reconnectDelay = RECONNECT_DELAY_MS;
|
|
10042
10071
|
this.send({
|
|
10043
10072
|
type: "register",
|
|
@@ -10050,11 +10079,11 @@ class WorkerWsClient {
|
|
|
10050
10079
|
const data = JSON.parse(String(event.data));
|
|
10051
10080
|
this.handleMessage(data);
|
|
10052
10081
|
} catch (err) {
|
|
10053
|
-
log$
|
|
10082
|
+
log$3("[worker-ws] Failed to parse message:", err);
|
|
10054
10083
|
}
|
|
10055
10084
|
};
|
|
10056
10085
|
this.ws.onclose = (event) => {
|
|
10057
|
-
log$
|
|
10086
|
+
log$3(`[worker-ws] Disconnected: ${event.code} ${event.reason}`);
|
|
10058
10087
|
this.ws = null;
|
|
10059
10088
|
this.clearHeartbeat();
|
|
10060
10089
|
this.setStatus("disconnected");
|
|
@@ -10063,7 +10092,7 @@ class WorkerWsClient {
|
|
|
10063
10092
|
}
|
|
10064
10093
|
};
|
|
10065
10094
|
this.ws.onerror = () => {
|
|
10066
|
-
log$
|
|
10095
|
+
log$3("[worker-ws] WebSocket error");
|
|
10067
10096
|
};
|
|
10068
10097
|
}
|
|
10069
10098
|
handleMessage(data) {
|
|
@@ -10072,7 +10101,7 @@ class WorkerWsClient {
|
|
|
10072
10101
|
case "registered": {
|
|
10073
10102
|
this.workerId = data.worker_id;
|
|
10074
10103
|
const workerStatus = data.status;
|
|
10075
|
-
log$
|
|
10104
|
+
log$3(`[worker-ws] Registered as ${this.workerId}, status: ${workerStatus}`);
|
|
10076
10105
|
if (workerStatus === "pending_approval") {
|
|
10077
10106
|
this.setStatus("pending_approval");
|
|
10078
10107
|
} else {
|
|
@@ -10091,14 +10120,14 @@ class WorkerWsClient {
|
|
|
10091
10120
|
break;
|
|
10092
10121
|
}
|
|
10093
10122
|
case "approved": {
|
|
10094
|
-
log$
|
|
10123
|
+
log$3("[worker-ws] Worker approved!");
|
|
10095
10124
|
this.setStatus("connected");
|
|
10096
10125
|
break;
|
|
10097
10126
|
}
|
|
10098
10127
|
case "message": {
|
|
10099
10128
|
const msg = data.message;
|
|
10100
10129
|
if (msg) {
|
|
10101
|
-
log$
|
|
10130
|
+
log$3(`[worker-ws] Received message: ${msg.id}`);
|
|
10102
10131
|
this.events.onMessage(msg);
|
|
10103
10132
|
}
|
|
10104
10133
|
break;
|
|
@@ -10113,7 +10142,7 @@ class WorkerWsClient {
|
|
|
10113
10142
|
case "heartbeat_ack":
|
|
10114
10143
|
break;
|
|
10115
10144
|
default:
|
|
10116
|
-
log$
|
|
10145
|
+
log$3(`[worker-ws] Unknown message type: ${msgType}`);
|
|
10117
10146
|
}
|
|
10118
10147
|
}
|
|
10119
10148
|
send(data) {
|
|
@@ -10135,7 +10164,7 @@ class WorkerWsClient {
|
|
|
10135
10164
|
}
|
|
10136
10165
|
scheduleReconnect() {
|
|
10137
10166
|
if (!this.shouldReconnect) return;
|
|
10138
|
-
log$
|
|
10167
|
+
log$3(`[worker-ws] Reconnecting in ${this.reconnectDelay / 1e3}s...`);
|
|
10139
10168
|
this.reconnectTimer = setTimeout(() => {
|
|
10140
10169
|
this.doConnect();
|
|
10141
10170
|
}, this.reconnectDelay);
|
|
@@ -10149,12 +10178,12 @@ class WorkerWsClient {
|
|
|
10149
10178
|
}
|
|
10150
10179
|
}
|
|
10151
10180
|
}
|
|
10152
|
-
const DATASTORE_TITLE = "Time Tracking";
|
|
10153
|
-
const AGENT_DATASTORE_PAGE_TITLE = "Agent Datastore";
|
|
10154
|
-
const SYSTEM_KEY = "time_tracking";
|
|
10181
|
+
const DATASTORE_TITLE$1 = "Time Tracking";
|
|
10182
|
+
const AGENT_DATASTORE_PAGE_TITLE$1 = "Agent Datastore";
|
|
10183
|
+
const SYSTEM_KEY$1 = "time_tracking";
|
|
10155
10184
|
const FIRST_CHECKPOINT_DELAY_MS = 30 * 1e3;
|
|
10156
10185
|
const CHECKPOINT_INTERVAL_MS = 5 * 60 * 1e3;
|
|
10157
|
-
const COLUMNS = [
|
|
10186
|
+
const COLUMNS$1 = [
|
|
10158
10187
|
{ name: "Started", type: "text" },
|
|
10159
10188
|
{ name: "Active Time", type: "number" },
|
|
10160
10189
|
{ name: "Last Updated", type: "date" },
|
|
@@ -10178,17 +10207,17 @@ function isSameLocalDay(a, b2) {
|
|
|
10178
10207
|
const db = new Date(b2);
|
|
10179
10208
|
return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate();
|
|
10180
10209
|
}
|
|
10181
|
-
function log$
|
|
10210
|
+
function log$2(...args) {
|
|
10182
10211
|
try {
|
|
10183
10212
|
console.log("[time-tracker]", ...args);
|
|
10184
10213
|
} catch {
|
|
10185
10214
|
}
|
|
10186
10215
|
}
|
|
10187
|
-
function findPageByTitle(pages, title) {
|
|
10216
|
+
function findPageByTitle$1(pages, title) {
|
|
10188
10217
|
for (const p2 of pages) {
|
|
10189
10218
|
if (p2?.title === title) return p2;
|
|
10190
10219
|
if (p2?.children?.length) {
|
|
10191
|
-
const c = findPageByTitle(p2.children, title);
|
|
10220
|
+
const c = findPageByTitle$1(p2.children, title);
|
|
10192
10221
|
if (c) return c;
|
|
10193
10222
|
}
|
|
10194
10223
|
}
|
|
@@ -10234,7 +10263,7 @@ class TimeTracker {
|
|
|
10234
10263
|
}
|
|
10235
10264
|
}, FIRST_CHECKPOINT_DELAY_MS);
|
|
10236
10265
|
const pending = !state.blockId || !state.rowId;
|
|
10237
|
-
log$
|
|
10266
|
+
log$2(`Started tracking tab=${tabId} agent="${agentName}" cwd=${cwd}${pending ? " (pending datastore — will retry on each checkpoint)" : ""}`);
|
|
10238
10267
|
}
|
|
10239
10268
|
/** Attempts to locate (or create) the datastore + add the session row.
|
|
10240
10269
|
* Returns true once the session is resolved (blockId + rowId set).
|
|
@@ -10258,15 +10287,15 @@ class TimeTracker {
|
|
|
10258
10287
|
});
|
|
10259
10288
|
const rowId = row?.id;
|
|
10260
10289
|
if (!rowId) {
|
|
10261
|
-
log$
|
|
10290
|
+
log$2(`addRow returned no id for tab=${tabId}; will retry on next checkpoint`);
|
|
10262
10291
|
return false;
|
|
10263
10292
|
}
|
|
10264
10293
|
s.blockId = blockId;
|
|
10265
10294
|
s.rowId = rowId;
|
|
10266
|
-
log$
|
|
10295
|
+
log$2(`Resolved datastore for tab=${tabId} (cwd=${s.cwd})`);
|
|
10267
10296
|
return true;
|
|
10268
10297
|
} catch (err) {
|
|
10269
|
-
log$
|
|
10298
|
+
log$2(`tryResolve failed for tab=${tabId}: ${err?.message || err}`);
|
|
10270
10299
|
return false;
|
|
10271
10300
|
}
|
|
10272
10301
|
}
|
|
@@ -10294,12 +10323,12 @@ class TimeTracker {
|
|
|
10294
10323
|
if (!s || s.ended) return;
|
|
10295
10324
|
this.rollingOver.add(tabId);
|
|
10296
10325
|
const { cwd, agentName, idleTimeoutMin } = s;
|
|
10297
|
-
log$
|
|
10326
|
+
log$2(`Day rolled over for tab=${tabId}; ending session and starting fresh`);
|
|
10298
10327
|
try {
|
|
10299
10328
|
await this.endSession(tabId);
|
|
10300
10329
|
await this.startSession(tabId, cwd, agentName, idleTimeoutMin);
|
|
10301
10330
|
} catch (err) {
|
|
10302
|
-
log$
|
|
10331
|
+
log$2(`rollover failed: ${err?.message || err}`);
|
|
10303
10332
|
} finally {
|
|
10304
10333
|
this.rollingOver.delete(tabId);
|
|
10305
10334
|
}
|
|
@@ -10316,10 +10345,10 @@ class TimeTracker {
|
|
|
10316
10345
|
if (s.blockId && s.rowId) {
|
|
10317
10346
|
await this.writeRow(s, Date.now());
|
|
10318
10347
|
} else {
|
|
10319
|
-
log$
|
|
10348
|
+
log$2(`endSession for tab=${tabId}: never resolved datastore; ${Math.round(s.activeMs / 6e4)}min not recorded`);
|
|
10320
10349
|
}
|
|
10321
10350
|
} catch (err) {
|
|
10322
|
-
log$
|
|
10351
|
+
log$2(`endSession write failed: ${err?.message || err}`);
|
|
10323
10352
|
}
|
|
10324
10353
|
s.ended = true;
|
|
10325
10354
|
this.sessions.delete(tabId);
|
|
@@ -10337,12 +10366,12 @@ class TimeTracker {
|
|
|
10337
10366
|
try {
|
|
10338
10367
|
await this.writeRow(s, Date.now());
|
|
10339
10368
|
} catch (err) {
|
|
10340
|
-
log$
|
|
10369
|
+
log$2(`checkpoint failed: ${err?.message || err}; retrying in 2s`);
|
|
10341
10370
|
setTimeout(() => {
|
|
10342
10371
|
const live = this.sessions.get(tabId);
|
|
10343
10372
|
if (!live || live.ended || !live.blockId || !live.rowId) return;
|
|
10344
10373
|
this.writeRow(live, Date.now()).catch((err2) => {
|
|
10345
|
-
log$
|
|
10374
|
+
log$2(`checkpoint retry failed: ${err2?.message || err2}`);
|
|
10346
10375
|
});
|
|
10347
10376
|
}, 2e3);
|
|
10348
10377
|
}
|
|
@@ -10356,6 +10385,205 @@ class TimeTracker {
|
|
|
10356
10385
|
});
|
|
10357
10386
|
}
|
|
10358
10387
|
async ensureDatastore(cwd) {
|
|
10388
|
+
const cached = this.blockCache.get(cwd);
|
|
10389
|
+
if (cached) return cached;
|
|
10390
|
+
let folder = null;
|
|
10391
|
+
try {
|
|
10392
|
+
folder = await this.api.findFolderByPath(cwd);
|
|
10393
|
+
} catch {
|
|
10394
|
+
return null;
|
|
10395
|
+
}
|
|
10396
|
+
if (!folder?.id) return null;
|
|
10397
|
+
const folderDetail = await this.api.getFolder(folder.id);
|
|
10398
|
+
const agentPage = findPageByTitle$1(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE$1);
|
|
10399
|
+
if (!agentPage?.id) return null;
|
|
10400
|
+
const blockId = await this.findOrAdoptBlock(agentPage.id);
|
|
10401
|
+
if (blockId) {
|
|
10402
|
+
await this.ensureColumns(blockId);
|
|
10403
|
+
this.blockCache.set(cwd, blockId);
|
|
10404
|
+
return blockId;
|
|
10405
|
+
}
|
|
10406
|
+
const columns = COLUMNS$1.map((c, i) => ({ id: `col_${i}`, name: c.name, type: c.type }));
|
|
10407
|
+
const created = await this.api.createBlock(agentPage.id, {
|
|
10408
|
+
type: "datastore",
|
|
10409
|
+
title: DATASTORE_TITLE$1,
|
|
10410
|
+
props: { columns, system_key: SYSTEM_KEY$1 }
|
|
10411
|
+
});
|
|
10412
|
+
if (created?.id) {
|
|
10413
|
+
log$2(`Created "${DATASTORE_TITLE$1}" datastore on Agent Datastore page for ${cwd}`);
|
|
10414
|
+
this.blockCache.set(cwd, created.id);
|
|
10415
|
+
return created.id;
|
|
10416
|
+
}
|
|
10417
|
+
return null;
|
|
10418
|
+
}
|
|
10419
|
+
/** Finds an existing Time Tracking block on the page. Prefers a system_key match
|
|
10420
|
+
* (which survives title renames). Falls back to title match for legacy blocks
|
|
10421
|
+
* created before system_key existed, and backfills system_key on the way out. */
|
|
10422
|
+
async findOrAdoptBlock(pageId) {
|
|
10423
|
+
const summaries = await this.api.getPageBlockSummaries(pageId) || [];
|
|
10424
|
+
const datastoreSummaries = summaries.filter((b2) => b2?.type === "datastore" && b2?.id);
|
|
10425
|
+
if (datastoreSummaries.length === 0) return null;
|
|
10426
|
+
let titleFallbackId = null;
|
|
10427
|
+
let titleFallbackProps = null;
|
|
10428
|
+
for (const s of datastoreSummaries) {
|
|
10429
|
+
try {
|
|
10430
|
+
const block = await this.api.getBlock(s.id);
|
|
10431
|
+
const props = block?.props || {};
|
|
10432
|
+
if (props.system_key === SYSTEM_KEY$1) {
|
|
10433
|
+
return s.id;
|
|
10434
|
+
}
|
|
10435
|
+
if (s.title === DATASTORE_TITLE$1 && titleFallbackId === null) {
|
|
10436
|
+
titleFallbackId = s.id;
|
|
10437
|
+
titleFallbackProps = props;
|
|
10438
|
+
}
|
|
10439
|
+
} catch (err) {
|
|
10440
|
+
log$2(`getBlock(${s.id}) failed during lookup: ${err?.message || err}`);
|
|
10441
|
+
}
|
|
10442
|
+
}
|
|
10443
|
+
if (titleFallbackId) {
|
|
10444
|
+
try {
|
|
10445
|
+
await this.api.updateBlock(titleFallbackId, {
|
|
10446
|
+
props: { ...titleFallbackProps || {}, system_key: SYSTEM_KEY$1 }
|
|
10447
|
+
});
|
|
10448
|
+
log$2(`Backfilled system_key on legacy Time Tracking block ${titleFallbackId}`);
|
|
10449
|
+
} catch (err) {
|
|
10450
|
+
log$2(`backfill system_key failed on ${titleFallbackId}: ${err?.message || err}`);
|
|
10451
|
+
}
|
|
10452
|
+
return titleFallbackId;
|
|
10453
|
+
}
|
|
10454
|
+
return null;
|
|
10455
|
+
}
|
|
10456
|
+
async ensureColumns(blockId) {
|
|
10457
|
+
try {
|
|
10458
|
+
const schema = await this.api.getDatastoreSchema(blockId);
|
|
10459
|
+
const existingCols = schema.columns || [];
|
|
10460
|
+
const existingNames = new Set(existingCols.map((c) => c.name));
|
|
10461
|
+
const missing = COLUMNS$1.filter((c) => !existingNames.has(c.name));
|
|
10462
|
+
if (missing.length === 0) return;
|
|
10463
|
+
const usedIds = new Set(existingCols.map((c) => c.id));
|
|
10464
|
+
let nextIdx = existingCols.length;
|
|
10465
|
+
const appended = missing.map((c) => {
|
|
10466
|
+
let id = `col_${nextIdx++}`;
|
|
10467
|
+
while (usedIds.has(id)) id = `col_${nextIdx++}`;
|
|
10468
|
+
usedIds.add(id);
|
|
10469
|
+
return { id, name: c.name, type: c.type };
|
|
10470
|
+
});
|
|
10471
|
+
const merged = [...existingCols, ...appended];
|
|
10472
|
+
await this.api.updateDatastoreSchema(blockId, merged);
|
|
10473
|
+
log$2(`Added ${missing.length} missing column(s) to existing Time Tracking datastore: ${missing.map((c) => c.name).join(", ")}`);
|
|
10474
|
+
} catch (err) {
|
|
10475
|
+
log$2(`ensureColumns failed: ${err?.message || err}`);
|
|
10476
|
+
}
|
|
10477
|
+
}
|
|
10478
|
+
}
|
|
10479
|
+
const DATASTORE_TITLE = "Tickets";
|
|
10480
|
+
const AGENT_DATASTORE_PAGE_TITLE = "Agent Datastore";
|
|
10481
|
+
const SYSTEM_KEY = "tickets";
|
|
10482
|
+
const STATUS_OPTIONS = [
|
|
10483
|
+
{ value: "Open", color: "blue" },
|
|
10484
|
+
{ value: "In Progress", color: "yellow" },
|
|
10485
|
+
{ value: "Blocked", color: "red" },
|
|
10486
|
+
{ value: "Done", color: "green" }
|
|
10487
|
+
];
|
|
10488
|
+
const PRIORITY_OPTIONS = [
|
|
10489
|
+
{ value: "Low", color: "gray" },
|
|
10490
|
+
{ value: "Med", color: "yellow" },
|
|
10491
|
+
{ value: "High", color: "red" }
|
|
10492
|
+
];
|
|
10493
|
+
const COLUMNS = [
|
|
10494
|
+
{ name: "Title", type: "text" },
|
|
10495
|
+
{ name: "Description", type: "text" },
|
|
10496
|
+
{ name: "Status", type: "select", options: STATUS_OPTIONS },
|
|
10497
|
+
{ name: "Priority", type: "select", options: PRIORITY_OPTIONS },
|
|
10498
|
+
{ name: "Created", type: "date" }
|
|
10499
|
+
];
|
|
10500
|
+
function log$1(...args) {
|
|
10501
|
+
try {
|
|
10502
|
+
console.log("[ticket-store]", ...args);
|
|
10503
|
+
} catch {
|
|
10504
|
+
}
|
|
10505
|
+
}
|
|
10506
|
+
function findPageByTitle(pages, title) {
|
|
10507
|
+
for (const p2 of pages) {
|
|
10508
|
+
if (p2?.title === title) return p2;
|
|
10509
|
+
if (p2?.children?.length) {
|
|
10510
|
+
const c = findPageByTitle(p2.children, title);
|
|
10511
|
+
if (c) return c;
|
|
10512
|
+
}
|
|
10513
|
+
}
|
|
10514
|
+
return null;
|
|
10515
|
+
}
|
|
10516
|
+
class TicketStore {
|
|
10517
|
+
api;
|
|
10518
|
+
blockCache = /* @__PURE__ */ new Map();
|
|
10519
|
+
constructor(api) {
|
|
10520
|
+
this.api = api;
|
|
10521
|
+
}
|
|
10522
|
+
/** Resolves (or creates) the project's Tickets datastore and appends a row. */
|
|
10523
|
+
async addTicket(cwd, input) {
|
|
10524
|
+
const title = input.title?.trim();
|
|
10525
|
+
if (!title) return { ok: false, error: "Title is required" };
|
|
10526
|
+
try {
|
|
10527
|
+
const blockId = await this.ensureDatastore(cwd, true);
|
|
10528
|
+
if (!blockId) {
|
|
10529
|
+
return { ok: false, error: "No ctlsurf project found for this folder" };
|
|
10530
|
+
}
|
|
10531
|
+
await this.api.addRow(blockId, {
|
|
10532
|
+
Title: title,
|
|
10533
|
+
Description: input.description?.trim() || "",
|
|
10534
|
+
Status: input.status || "Open",
|
|
10535
|
+
Priority: input.priority || "Med",
|
|
10536
|
+
Created: (/* @__PURE__ */ new Date()).toISOString()
|
|
10537
|
+
});
|
|
10538
|
+
log$1(`Added ticket "${title}" for ${cwd}`);
|
|
10539
|
+
return { ok: true };
|
|
10540
|
+
} catch (err) {
|
|
10541
|
+
log$1(`addTicket failed for ${cwd}: ${err?.message || err}`);
|
|
10542
|
+
return { ok: false, error: err?.message || String(err) };
|
|
10543
|
+
}
|
|
10544
|
+
}
|
|
10545
|
+
/** Updates an existing ticket row in the project's Tickets datastore. */
|
|
10546
|
+
async updateTicket(cwd, rowId, input) {
|
|
10547
|
+
const title = input.title?.trim();
|
|
10548
|
+
if (!title) return { ok: false, error: "Title is required" };
|
|
10549
|
+
try {
|
|
10550
|
+
const blockId = await this.ensureDatastore(cwd, false);
|
|
10551
|
+
if (!blockId) return { ok: false, error: "No Tickets datastore for this project" };
|
|
10552
|
+
await this.api.updateRow(blockId, rowId, {
|
|
10553
|
+
Title: title,
|
|
10554
|
+
Description: input.description?.trim() || "",
|
|
10555
|
+
Status: input.status || "Open",
|
|
10556
|
+
Priority: input.priority || "Med"
|
|
10557
|
+
});
|
|
10558
|
+
log$1(`Updated ticket ${rowId} for ${cwd}`);
|
|
10559
|
+
return { ok: true };
|
|
10560
|
+
} catch (err) {
|
|
10561
|
+
log$1(`updateTicket failed for ${cwd}: ${err?.message || err}`);
|
|
10562
|
+
return { ok: false, error: err?.message || String(err) };
|
|
10563
|
+
}
|
|
10564
|
+
}
|
|
10565
|
+
/** Lists existing tickets for the project, newest first. Does not create the
|
|
10566
|
+
* datastore — an unconfigured project simply has no tickets yet. */
|
|
10567
|
+
async listTickets(cwd) {
|
|
10568
|
+
try {
|
|
10569
|
+
const blockId = await this.ensureDatastore(cwd, false);
|
|
10570
|
+
if (!blockId) return { ok: true, tickets: [] };
|
|
10571
|
+
const res = await this.api.queryRows(blockId, { orderBy: "Created", order: "desc", limit: 200 });
|
|
10572
|
+
const tickets = (res?.rows || []).map((r) => ({
|
|
10573
|
+
id: r.id,
|
|
10574
|
+
title: String(r.data?.Title ?? ""),
|
|
10575
|
+
description: String(r.data?.Description ?? ""),
|
|
10576
|
+
status: String(r.data?.Status ?? "Open"),
|
|
10577
|
+
priority: String(r.data?.Priority ?? "Med"),
|
|
10578
|
+
created: r.data?.Created ?? r.created_at ?? null
|
|
10579
|
+
}));
|
|
10580
|
+
return { ok: true, tickets };
|
|
10581
|
+
} catch (err) {
|
|
10582
|
+
log$1(`listTickets failed for ${cwd}: ${err?.message || err}`);
|
|
10583
|
+
return { ok: false, tickets: [], error: err?.message || String(err) };
|
|
10584
|
+
}
|
|
10585
|
+
}
|
|
10586
|
+
async ensureDatastore(cwd, create) {
|
|
10359
10587
|
const cached = this.blockCache.get(cwd);
|
|
10360
10588
|
if (cached) return cached;
|
|
10361
10589
|
let folder = null;
|
|
@@ -10374,7 +10602,13 @@ class TimeTracker {
|
|
|
10374
10602
|
this.blockCache.set(cwd, blockId);
|
|
10375
10603
|
return blockId;
|
|
10376
10604
|
}
|
|
10377
|
-
|
|
10605
|
+
if (!create) return null;
|
|
10606
|
+
const columns = COLUMNS.map((c, i) => ({
|
|
10607
|
+
id: `col_${i}`,
|
|
10608
|
+
name: c.name,
|
|
10609
|
+
type: c.type,
|
|
10610
|
+
...c.options ? { options: c.options } : {}
|
|
10611
|
+
}));
|
|
10378
10612
|
const created = await this.api.createBlock(agentPage.id, {
|
|
10379
10613
|
type: "datastore",
|
|
10380
10614
|
title: DATASTORE_TITLE,
|
|
@@ -10387,9 +10621,9 @@ class TimeTracker {
|
|
|
10387
10621
|
}
|
|
10388
10622
|
return null;
|
|
10389
10623
|
}
|
|
10390
|
-
/** Finds an existing
|
|
10624
|
+
/** Finds an existing Tickets block on the page. Prefers a system_key match
|
|
10391
10625
|
* (which survives title renames). Falls back to title match for legacy blocks
|
|
10392
|
-
*
|
|
10626
|
+
* and backfills system_key on the way out. */
|
|
10393
10627
|
async findOrAdoptBlock(pageId) {
|
|
10394
10628
|
const summaries = await this.api.getPageBlockSummaries(pageId) || [];
|
|
10395
10629
|
const datastoreSummaries = summaries.filter((b2) => b2?.type === "datastore" && b2?.id);
|
|
@@ -10416,7 +10650,7 @@ class TimeTracker {
|
|
|
10416
10650
|
await this.api.updateBlock(titleFallbackId, {
|
|
10417
10651
|
props: { ...titleFallbackProps || {}, system_key: SYSTEM_KEY }
|
|
10418
10652
|
});
|
|
10419
|
-
log$1(`Backfilled system_key on legacy
|
|
10653
|
+
log$1(`Backfilled system_key on legacy Tickets block ${titleFallbackId}`);
|
|
10420
10654
|
} catch (err) {
|
|
10421
10655
|
log$1(`backfill system_key failed on ${titleFallbackId}: ${err?.message || err}`);
|
|
10422
10656
|
}
|
|
@@ -10437,11 +10671,11 @@ class TimeTracker {
|
|
|
10437
10671
|
let id = `col_${nextIdx++}`;
|
|
10438
10672
|
while (usedIds.has(id)) id = `col_${nextIdx++}`;
|
|
10439
10673
|
usedIds.add(id);
|
|
10440
|
-
return { id, name: c.name, type: c.type };
|
|
10674
|
+
return { id, name: c.name, type: c.type, ...c.options ? { options: c.options } : {} };
|
|
10441
10675
|
});
|
|
10442
10676
|
const merged = [...existingCols, ...appended];
|
|
10443
10677
|
await this.api.updateDatastoreSchema(blockId, merged);
|
|
10444
|
-
log$1(`Added ${missing.length} missing column(s) to existing
|
|
10678
|
+
log$1(`Added ${missing.length} missing column(s) to existing Tickets datastore: ${missing.map((c) => c.name).join(", ")}`);
|
|
10445
10679
|
} catch (err) {
|
|
10446
10680
|
log$1(`ensureColumns failed: ${err?.message || err}`);
|
|
10447
10681
|
}
|
|
@@ -10468,6 +10702,7 @@ class Orchestrator {
|
|
|
10468
10702
|
bridge = new ConversationBridge();
|
|
10469
10703
|
workerWs;
|
|
10470
10704
|
timeTracker = new TimeTracker(this.ctlsurfApi);
|
|
10705
|
+
ticketStore = new TicketStore(this.ctlsurfApi);
|
|
10471
10706
|
// State
|
|
10472
10707
|
tabs = /* @__PURE__ */ new Map();
|
|
10473
10708
|
activeTabId = null;
|
|
@@ -10480,16 +10715,17 @@ class Orchestrator {
|
|
|
10480
10715
|
};
|
|
10481
10716
|
noProjectPollTimer = null;
|
|
10482
10717
|
noProjectPollCwd = null;
|
|
10718
|
+
currentProjectName = null;
|
|
10483
10719
|
constructor(settingsDir, events) {
|
|
10484
10720
|
this.settingsDir = settingsDir;
|
|
10485
10721
|
this.events = events;
|
|
10486
10722
|
this.workerWs = new WorkerWsClient({
|
|
10487
10723
|
onStatusChange: (status) => {
|
|
10488
|
-
log$
|
|
10724
|
+
log$3(`[worker-ws] Status: ${status}`);
|
|
10489
10725
|
events.onWorkerStatus(status);
|
|
10490
10726
|
},
|
|
10491
10727
|
onMessage: (message) => {
|
|
10492
|
-
log$
|
|
10728
|
+
log$3(`[worker-ws] Incoming message: ${message.id} (${message.type})`);
|
|
10493
10729
|
events.onWorkerMessage(message);
|
|
10494
10730
|
this.workerWs.sendAck(message.id);
|
|
10495
10731
|
if (message.type === "prompt" || message.type === "task_dispatch") {
|
|
@@ -10501,15 +10737,17 @@ class Orchestrator {
|
|
|
10501
10737
|
}
|
|
10502
10738
|
},
|
|
10503
10739
|
onRegistered: (data) => {
|
|
10504
|
-
log$
|
|
10740
|
+
log$3(`[worker-ws] Registered: worker_id=${data.worker_id}, folder_id=${data.folder_id}, status=${data.status}`);
|
|
10505
10741
|
events.onWorkerRegistered(data);
|
|
10506
10742
|
if (!data.folder_id) {
|
|
10743
|
+
this.setProjectName(null);
|
|
10507
10744
|
events.onWorkerStatus("no_project");
|
|
10508
10745
|
if (this.currentCwd && data.status !== "pending_approval") {
|
|
10509
10746
|
this.startNoProjectPolling(this.currentCwd);
|
|
10510
10747
|
}
|
|
10511
10748
|
} else {
|
|
10512
10749
|
this.stopNoProjectPolling();
|
|
10750
|
+
this.resolveProjectName(data.folder_id);
|
|
10513
10751
|
}
|
|
10514
10752
|
},
|
|
10515
10753
|
onTerminalInput: (data) => {
|
|
@@ -10544,6 +10782,26 @@ class Orchestrator {
|
|
|
10544
10782
|
get cwd() {
|
|
10545
10783
|
return this.currentCwd;
|
|
10546
10784
|
}
|
|
10785
|
+
// Name of the connected ctlsurf project (folder) for the desktop header.
|
|
10786
|
+
get projectName() {
|
|
10787
|
+
return this.currentProjectName;
|
|
10788
|
+
}
|
|
10789
|
+
setProjectName(name) {
|
|
10790
|
+
if (this.currentProjectName === name) return;
|
|
10791
|
+
this.currentProjectName = name;
|
|
10792
|
+
this.events.onProjectChanged?.(name);
|
|
10793
|
+
}
|
|
10794
|
+
// Resolve the connected folder's human-readable name. Best-effort: a failed
|
|
10795
|
+
// lookup just leaves the project name unset rather than blocking anything.
|
|
10796
|
+
async resolveProjectName(folderId) {
|
|
10797
|
+
try {
|
|
10798
|
+
const folder = await this.ctlsurfApi.getFolder(folderId);
|
|
10799
|
+
const name = folder?.name ?? folder?.title;
|
|
10800
|
+
this.setProjectName(typeof name === "string" && name ? name : null);
|
|
10801
|
+
} catch (err) {
|
|
10802
|
+
log$3(`[worker-ws] Failed to resolve project name for folder ${folderId}: ${err}`);
|
|
10803
|
+
}
|
|
10804
|
+
}
|
|
10547
10805
|
get agent() {
|
|
10548
10806
|
return this.currentAgent;
|
|
10549
10807
|
}
|
|
@@ -10559,7 +10817,7 @@ class Orchestrator {
|
|
|
10559
10817
|
const baseUrl = profile.baseUrl || process.env.CTLSURF_BASE_URL || "https://app.ctlsurf.com";
|
|
10560
10818
|
this.ctlsurfApi.setBaseUrl(baseUrl);
|
|
10561
10819
|
this.workerWs.setBaseUrl(baseUrl);
|
|
10562
|
-
log$
|
|
10820
|
+
log$3(`[settings] Profile applied: ${profile.name} (${baseUrl})`);
|
|
10563
10821
|
}
|
|
10564
10822
|
loadSettings() {
|
|
10565
10823
|
try {
|
|
@@ -10584,7 +10842,7 @@ class Orchestrator {
|
|
|
10584
10842
|
logChat: !!raw.logChat
|
|
10585
10843
|
};
|
|
10586
10844
|
this.saveSettings();
|
|
10587
|
-
log$
|
|
10845
|
+
log$3("[settings] Migrated legacy settings to profiles");
|
|
10588
10846
|
} else {
|
|
10589
10847
|
this.settings = raw;
|
|
10590
10848
|
if (!this.settings.profiles.production) {
|
|
@@ -10611,7 +10869,7 @@ class Orchestrator {
|
|
|
10611
10869
|
fs.mkdirSync(this.settingsDir, { recursive: true });
|
|
10612
10870
|
fs.writeFileSync(settingsPath, JSON.stringify(this.settings, null, 2));
|
|
10613
10871
|
} catch (err) {
|
|
10614
|
-
log$
|
|
10872
|
+
log$3("[settings] Failed to save:", err.message);
|
|
10615
10873
|
}
|
|
10616
10874
|
}
|
|
10617
10875
|
overrideApiKey(key) {
|
|
@@ -10816,12 +11074,42 @@ class Orchestrator {
|
|
|
10816
11074
|
await this.timeTracker.endSession(this.activeTabId);
|
|
10817
11075
|
}
|
|
10818
11076
|
}
|
|
11077
|
+
// ─── Tickets (active tab) ───────────────────────
|
|
11078
|
+
/** cwd of the focused terminal tab, or null if no tab is active. */
|
|
11079
|
+
getActiveTabCwd() {
|
|
11080
|
+
if (!this.activeTabId) return null;
|
|
11081
|
+
return this.tabs.get(this.activeTabId)?.cwd ?? null;
|
|
11082
|
+
}
|
|
11083
|
+
async addTicketForActiveTab(input) {
|
|
11084
|
+
const cwd = this.getActiveTabCwd();
|
|
11085
|
+
if (!cwd) return { ok: false, error: "No active terminal tab" };
|
|
11086
|
+
if (!this.ctlsurfApi.getApiKey()) {
|
|
11087
|
+
return { ok: false, error: "ctlsurf API key not configured" };
|
|
11088
|
+
}
|
|
11089
|
+
return this.ticketStore.addTicket(cwd, input);
|
|
11090
|
+
}
|
|
11091
|
+
async updateTicketForActiveTab(rowId, input) {
|
|
11092
|
+
const cwd = this.getActiveTabCwd();
|
|
11093
|
+
if (!cwd) return { ok: false, error: "No active terminal tab" };
|
|
11094
|
+
if (!this.ctlsurfApi.getApiKey()) {
|
|
11095
|
+
return { ok: false, error: "ctlsurf API key not configured" };
|
|
11096
|
+
}
|
|
11097
|
+
return this.ticketStore.updateTicket(cwd, rowId, input);
|
|
11098
|
+
}
|
|
11099
|
+
async listTicketsForActiveTab() {
|
|
11100
|
+
const cwd = this.getActiveTabCwd();
|
|
11101
|
+
if (!cwd) return { ok: false, tickets: [], error: "No active terminal tab" };
|
|
11102
|
+
if (!this.ctlsurfApi.getApiKey()) {
|
|
11103
|
+
return { ok: false, tickets: [], error: "ctlsurf API key not configured" };
|
|
11104
|
+
}
|
|
11105
|
+
return this.ticketStore.listTickets(cwd);
|
|
11106
|
+
}
|
|
10819
11107
|
// ─── Worker WebSocket ───────────────────────────
|
|
10820
11108
|
connectWorkerWs(agent, cwd) {
|
|
10821
11109
|
const profile = this.getActiveProfile();
|
|
10822
11110
|
const apiKey = profile.apiKey || process.env.CTLSURF_API_KEY;
|
|
10823
11111
|
if (!apiKey) {
|
|
10824
|
-
log$
|
|
11112
|
+
log$3("[worker-ws] No API key, skipping WS connect");
|
|
10825
11113
|
return;
|
|
10826
11114
|
}
|
|
10827
11115
|
this.stopNoProjectPolling();
|
|
@@ -10835,7 +11123,7 @@ class Orchestrator {
|
|
|
10835
11123
|
if (this.noProjectPollTimer && this.noProjectPollCwd === cwd) return;
|
|
10836
11124
|
this.stopNoProjectPolling();
|
|
10837
11125
|
this.noProjectPollCwd = cwd;
|
|
10838
|
-
log$
|
|
11126
|
+
log$3(`[worker-ws] Polling for project folder at ${cwd}`);
|
|
10839
11127
|
this.noProjectPollTimer = setInterval(() => {
|
|
10840
11128
|
void this.checkForProjectFolder(cwd);
|
|
10841
11129
|
}, NO_PROJECT_POLL_MS);
|
|
@@ -10856,7 +11144,7 @@ class Orchestrator {
|
|
|
10856
11144
|
try {
|
|
10857
11145
|
const folder = await this.ctlsurfApi.findFolderByPath(cwd);
|
|
10858
11146
|
if (folder?.id && this.currentCwd === cwd && this.currentAgent) {
|
|
10859
|
-
log$
|
|
11147
|
+
log$3(`[worker-ws] Project folder appeared (${folder.id}); reconnecting`);
|
|
10860
11148
|
const agent = this.currentAgent;
|
|
10861
11149
|
this.stopNoProjectPolling();
|
|
10862
11150
|
this.workerWs.disconnect();
|
|
@@ -10978,6 +11266,7 @@ const orchestrator = new Orchestrator(
|
|
|
10978
11266
|
onWorkerStatus: (status) => mainWindow?.webContents.send("worker:status", status),
|
|
10979
11267
|
onWorkerMessage: (message) => mainWindow?.webContents.send("worker:message", message),
|
|
10980
11268
|
onWorkerRegistered: (data) => mainWindow?.webContents.send("worker:registered", data),
|
|
11269
|
+
onProjectChanged: (name) => mainWindow?.webContents.send("app:projectChanged", name),
|
|
10981
11270
|
onCwdChanged: () => {
|
|
10982
11271
|
mainWindow?.webContents.send("app:cwdChanged");
|
|
10983
11272
|
updateProjectBadge(orchestrator.cwd);
|
|
@@ -11025,10 +11314,11 @@ electron.ipcMain.handle("pty:kill", async (_event, tabId) => {
|
|
|
11025
11314
|
electron.ipcMain.handle("pty:setActiveTab", (_event, tabId) => {
|
|
11026
11315
|
orchestrator.setActiveTab(tabId);
|
|
11027
11316
|
});
|
|
11028
|
-
electron.ipcMain.handle("agents:list", () =>
|
|
11317
|
+
electron.ipcMain.handle("agents:list", () => getAvailableAgents());
|
|
11029
11318
|
electron.ipcMain.handle("agents:default", () => getDefaultAgent());
|
|
11030
11319
|
electron.ipcMain.handle("app:homePath", () => electron.app.getPath("home"));
|
|
11031
11320
|
electron.ipcMain.handle("app:cwd", () => process.env.CTLSURF_WORKER_CWD || process.cwd());
|
|
11321
|
+
electron.ipcMain.handle("app:projectName", () => orchestrator.projectName);
|
|
11032
11322
|
electron.ipcMain.handle("app:browseCwd", async () => {
|
|
11033
11323
|
if (!mainWindow) return null;
|
|
11034
11324
|
const result = await electron.dialog.showOpenDialog(mainWindow, {
|
|
@@ -11181,6 +11471,19 @@ electron.ipcMain.handle("tracking:set", async (_event, enabled) => {
|
|
|
11181
11471
|
await orchestrator.setActiveTabTracking(enabled);
|
|
11182
11472
|
return { active: orchestrator.isActiveTabTracking() };
|
|
11183
11473
|
});
|
|
11474
|
+
electron.ipcMain.handle("tickets:project", () => {
|
|
11475
|
+
const cwd = orchestrator.getActiveTabCwd();
|
|
11476
|
+
return { cwd, name: cwd ? cwd.split("/").filter(Boolean).pop() || cwd : null };
|
|
11477
|
+
});
|
|
11478
|
+
electron.ipcMain.handle("tickets:add", async (_event, input) => {
|
|
11479
|
+
return orchestrator.addTicketForActiveTab(input);
|
|
11480
|
+
});
|
|
11481
|
+
electron.ipcMain.handle("tickets:update", async (_event, rowId, input) => {
|
|
11482
|
+
return orchestrator.updateTicketForActiveTab(rowId, input);
|
|
11483
|
+
});
|
|
11484
|
+
electron.ipcMain.handle("tickets:list", async () => {
|
|
11485
|
+
return orchestrator.listTicketsForActiveTab();
|
|
11486
|
+
});
|
|
11184
11487
|
electron.ipcMain.handle("settings:get", (_event, key) => {
|
|
11185
11488
|
const profile = orchestrator.getActiveProfile();
|
|
11186
11489
|
if (key === "ctlsurfApiKey") return profile.apiKey ? "***configured***" : null;
|