@programinglive/dev-workflow-mcp-server 1.13.12 → 1.13.13
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/factory-client.js +69 -14
- package/factory-credentials.js +156 -0
- package/index.js +16 -0
- package/package.json +1 -1
- package/process-lock.js +134 -0
package/factory-client.js
CHANGED
|
@@ -25,6 +25,7 @@ import os from "os";
|
|
|
25
25
|
import { assertSafeKnowledge } from './security/policy.js';
|
|
26
26
|
import { sanitize } from './security/sanitizer.js';
|
|
27
27
|
import { registerRuntimeSecret } from './security/runtime-secrets.js';
|
|
28
|
+
import { refreshFactoryCredentials, describeLoginFailure } from './factory-credentials.js';
|
|
28
29
|
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
29
30
|
import path from "path";
|
|
30
31
|
|
|
@@ -397,6 +398,27 @@ export function buildWorkFinishPayload({ sessionId, deviceId, outcome, summary,
|
|
|
397
398
|
// HTTP layer
|
|
398
399
|
// ---------------------------------------------------------------------------
|
|
399
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Kapan proses MCP ini hidup.
|
|
403
|
+
*
|
|
404
|
+
* Dipakai di pesan gagal login: tanpa ini pengguna tidak punya cara tahu bahwa
|
|
405
|
+
* environment yang dipakai sudah usang.
|
|
406
|
+
*/
|
|
407
|
+
function processStartedAt() {
|
|
408
|
+
return new Date(Date.now() - process.uptime() * 1000);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Satu percobaan login dengan pasangan email/password tertentu. */
|
|
412
|
+
function attemptLogin(config, fetchImpl, { email, password }) {
|
|
413
|
+
registerRuntimeSecret(password);
|
|
414
|
+
|
|
415
|
+
return fetchImpl(`${config.pocketBaseUrl}/api/collections/activity_users/auth-with-password`, {
|
|
416
|
+
method: "POST",
|
|
417
|
+
headers: { "content-type": "application/json" },
|
|
418
|
+
body: JSON.stringify({ identity: email, password }),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
400
422
|
async function resolveToken(config, fetchImpl = fetch) {
|
|
401
423
|
registerRuntimeSecret(config.token); registerRuntimeSecret(config.password);
|
|
402
424
|
if (config.token) return config.token;
|
|
@@ -405,24 +427,57 @@ async function resolveToken(config, fetchImpl = fetch) {
|
|
|
405
427
|
const cached = tokenCache.get(key);
|
|
406
428
|
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
|
407
429
|
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
430
|
+
const remember = (token) => {
|
|
431
|
+
registerRuntimeSecret(token);
|
|
432
|
+
tokenCache.set(key, { token, expiresAt: Date.now() + TOKEN_TTL_MS });
|
|
433
|
+
return token;
|
|
434
|
+
};
|
|
413
435
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
);
|
|
436
|
+
const response = await attemptLogin(config, fetchImpl, config);
|
|
437
|
+
|
|
438
|
+
if (response.ok) {
|
|
439
|
+
const body = await response.json();
|
|
440
|
+
if (!body.token) throw new Error("Factory login returned no token.");
|
|
441
|
+
return remember(body.token);
|
|
418
442
|
}
|
|
419
443
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
444
|
+
// Kredensial ditolak. Sebelum menyerah, baca ulang berkas konfigurasi: proses
|
|
445
|
+
// MCP mewarisi environment saat dijalankan dan tidak pernah memuat ulang, jadi
|
|
446
|
+
// password yang baru diubah di opencode.json tidak pernah terpakai sampai
|
|
447
|
+
// OpenCode di-restart. 31 Oktober 2026 itu mematikan otomasi berjam-jam.
|
|
448
|
+
const refreshed = refreshFactoryCredentials(config);
|
|
449
|
+
|
|
450
|
+
if (refreshed && refreshed.changed) {
|
|
451
|
+
if (refreshed.token) {
|
|
452
|
+
return remember(refreshed.token);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (refreshed.email && refreshed.password) {
|
|
456
|
+
const retry = await attemptLogin(config, fetchImpl, refreshed);
|
|
457
|
+
|
|
458
|
+
if (retry.ok) {
|
|
459
|
+
const body = await retry.json();
|
|
460
|
+
if (!body.token) throw new Error("Factory login returned no token.");
|
|
461
|
+
return remember(body.token);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
throw new Error(
|
|
465
|
+
describeLoginFailure({
|
|
466
|
+
status: retry.status,
|
|
467
|
+
processStartedAt: processStartedAt(),
|
|
468
|
+
credentialsChanged: true,
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
423
473
|
|
|
424
|
-
|
|
425
|
-
|
|
474
|
+
throw new Error(
|
|
475
|
+
describeLoginFailure({
|
|
476
|
+
status: response.status,
|
|
477
|
+
processStartedAt: processStartedAt(),
|
|
478
|
+
credentialsChanged: Boolean(refreshed && refreshed.changed),
|
|
479
|
+
}),
|
|
480
|
+
);
|
|
426
481
|
}
|
|
427
482
|
|
|
428
483
|
/** Test helper: forget cached tokens (for example after a password change). */
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Membaca ulang kredensial Factory dari berkas konfigurasi OpenCode.
|
|
3
|
+
*
|
|
4
|
+
* Kenapa perlu: proses MCP mewarisi environment saat ia di-spawn dan tidak
|
|
5
|
+
* pernah memuat ulang. 31 Oktober 2026 otomasi Factory mati berjam-jam dengan
|
|
6
|
+
* pesan "login failed (HTTP 400). Check FACTORY_EMAIL and FACTORY_PASSWORD"
|
|
7
|
+
* padahal password di `~/.config/opencode/opencode.json` sudah benar dan login
|
|
8
|
+
* langsung ke PocketBase menjawab 200. Penyebabnya: proses MCP hidup sejak
|
|
9
|
+
* 30 Oktober dan memegang password LAMA di memorinya.
|
|
10
|
+
*
|
|
11
|
+
* Modul ini TIDAK menyimpan rahasia baru ke disk. Ia hanya membaca berkas yang
|
|
12
|
+
* sudah ada dan sudah berisi rahasia itu.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "fs";
|
|
16
|
+
import os from "os";
|
|
17
|
+
import path from "path";
|
|
18
|
+
|
|
19
|
+
/** Nama server di blok `mcp` pada konfigurasi OpenCode. */
|
|
20
|
+
export const DEFAULT_SERVER_NAME = "dev-workflow";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Berkas kredensial bawaan: konfigurasi OpenCode.
|
|
24
|
+
*
|
|
25
|
+
* Bisa diarahkan lewat `FACTORY_CREDENTIALS_FILE` bila konfigurasinya di tempat
|
|
26
|
+
* lain (misalnya berkas khusus tanpa isi lain).
|
|
27
|
+
*/
|
|
28
|
+
export function defaultCredentialsFile(env = process.env) {
|
|
29
|
+
const explicit = clean(env.FACTORY_CREDENTIALS_FILE);
|
|
30
|
+
if (explicit) return explicit;
|
|
31
|
+
|
|
32
|
+
const home = clean(env.HOME) || clean(env.USERPROFILE) || os.homedir();
|
|
33
|
+
const configHome = clean(env.XDG_CONFIG_HOME) || path.join(home, ".config");
|
|
34
|
+
|
|
35
|
+
return path.join(configHome, "opencode", "opencode.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clean(value) {
|
|
39
|
+
return typeof value === "string" ? value.trim() : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mengambil blok `environment` milik server MCP kita dari teks konfigurasi.
|
|
44
|
+
*
|
|
45
|
+
* Dua bentuk didukung, karena keduanya ada di lapangan:
|
|
46
|
+
* { mcp: { "<nama>": {...} } } (konfigurasi lama)
|
|
47
|
+
* { mcp: { servers: { "<nama>": {...} } } } (konfigurasi OpenCode di mesin ini)
|
|
48
|
+
*
|
|
49
|
+
* Mengembalikan null bila teks bukan JSON, tidak punya `mcp`, atau tidak punya
|
|
50
|
+
* blok server yang diminta. Mengambil server LAIN akan lebih buruk daripada
|
|
51
|
+
* tidak mengambil apa pun: kredensial proyek lain bisa terpakai.
|
|
52
|
+
*/
|
|
53
|
+
export function extractFactoryEnv(text, { serverName = DEFAULT_SERVER_NAME } = {}) {
|
|
54
|
+
let parsed;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mcp = parsed && parsed.mcp;
|
|
63
|
+
if (!mcp || typeof mcp !== "object") return null;
|
|
64
|
+
|
|
65
|
+
const block = mcp[serverName] || (mcp.servers && mcp.servers[serverName]);
|
|
66
|
+
if (!block || typeof block !== "object") return null;
|
|
67
|
+
|
|
68
|
+
const environment = block.environment;
|
|
69
|
+
if (!environment || typeof environment !== "object") return null;
|
|
70
|
+
|
|
71
|
+
return environment;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Membandingkan kredensial yang sedang dipakai proses dengan yang ada di berkas.
|
|
76
|
+
*
|
|
77
|
+
* Mengembalikan null bila tidak ada yang bisa disegarkan (berkas tidak ada,
|
|
78
|
+
* rusak, atau tanpa blok server kita). Selain itu objek dengan kredensial
|
|
79
|
+
* TERBARU, plus `changed` yang menandakan apakah ia berbeda dari yang dipakai.
|
|
80
|
+
*/
|
|
81
|
+
export function refreshFactoryCredentials(
|
|
82
|
+
config = {},
|
|
83
|
+
{ env = process.env, readFileImpl = readFileSync, serverName = DEFAULT_SERVER_NAME } = {}
|
|
84
|
+
) {
|
|
85
|
+
const file = defaultCredentialsFile(env);
|
|
86
|
+
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
text = String(readFileImpl(file, "utf8"));
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const environment = extractFactoryEnv(text, { serverName });
|
|
95
|
+
if (!environment) return null;
|
|
96
|
+
|
|
97
|
+
const token = clean(environment.FACTORY_TOKEN);
|
|
98
|
+
const email = clean(environment.FACTORY_EMAIL);
|
|
99
|
+
const password = clean(environment.FACTORY_PASSWORD);
|
|
100
|
+
|
|
101
|
+
const current = {
|
|
102
|
+
token: clean(config.token),
|
|
103
|
+
email: clean(config.email),
|
|
104
|
+
password: clean(config.password),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const changed =
|
|
108
|
+
token !== current.token || email !== current.email || password !== current.password;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
token,
|
|
112
|
+
email,
|
|
113
|
+
password,
|
|
114
|
+
changed,
|
|
115
|
+
source: "file",
|
|
116
|
+
file,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pesan gagal login yang bisa ditindaklanjuti.
|
|
122
|
+
*
|
|
123
|
+
* Dua hal yang membedakannya dari pesan lama ("check FACTORY_EMAIL and
|
|
124
|
+
* FACTORY_PASSWORD"): ia menyebut KAPAN proses ini hidup, dan ia menyebut
|
|
125
|
+
* apakah kredensial di berkas berbeda dari yang sedang dipakai. Tanpa itu,
|
|
126
|
+
* pengguna disuruh memeriksa password yang sebenarnya sudah benar.
|
|
127
|
+
*/
|
|
128
|
+
export function describeLoginFailure({ status, processStartedAt, credentialsChanged = false }) {
|
|
129
|
+
const started = processStartedAt instanceof Date ? processStartedAt.toISOString() : "";
|
|
130
|
+
|
|
131
|
+
const lines = [
|
|
132
|
+
`Factory login failed (HTTP ${status}).`,
|
|
133
|
+
started
|
|
134
|
+
? `Proses MCP ini hidup sejak ${started} dan memakai environment yang diwarisi saat itu - ia tidak pernah memuat ulang berkas konfigurasi.`
|
|
135
|
+
: "Proses MCP ini memakai environment yang diwarisi saat ia dijalankan.",
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
if (credentialsChanged) {
|
|
139
|
+
lines.push(
|
|
140
|
+
"Kredensial di berkas konfigurasi BERBEDA dari yang sedang dipakai proses ini. " +
|
|
141
|
+
"Kredensial terbaru sudah dicoba dan tetap ditolak, jadi kemungkinan besar " +
|
|
142
|
+
"password-nya memang salah, atau akunnya belum bisa login."
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
lines.push(
|
|
146
|
+
"Kredensial di berkas konfigurasi SAMA dengan yang dipakai, atau berkasnya tidak ditemukan."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lines.push(
|
|
151
|
+
"Periksa FACTORY_EMAIL/FACTORY_PASSWORD, atau set FACTORY_TOKEN. " +
|
|
152
|
+
"Bila password baru saja diubah, restart OpenCode agar proses MCP memakai kredensial terbaru."
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return lines.join(" ");
|
|
156
|
+
}
|
package/index.js
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
GetPromptRequestSchema,
|
|
14
14
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
15
15
|
import { fileURLToPath } from "url";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import { acquireProcessLock, releaseProcessLock, lockWarning } from "./process-lock.js";
|
|
16
18
|
import { exec, setActiveProjectDir } from "./exec.js";
|
|
17
19
|
import { normalizeRequestArgs, shellEscape } from "./utils.js";
|
|
18
20
|
import { createGitFacade } from "./git-facade.js";
|
|
@@ -197,6 +199,20 @@ export async function main() {
|
|
|
197
199
|
// Select request metadata before the first disk write. Creating an idle
|
|
198
200
|
// PID fallback here leaves a phantom session alongside the real sessions.
|
|
199
201
|
safeLog('error', `[${new Date().toISOString()}] Workflow state initialization complete.`);
|
|
202
|
+
|
|
203
|
+
// Penanda proses: 31 Oktober 2026 dua proses MCP hidup bersamaan dan
|
|
204
|
+
// memegang kredensial Factory yang berbeda, sehingga tool yang sama
|
|
205
|
+
// menjawab berbeda tergantung proses mana yang melayaninya. Proses lain
|
|
206
|
+
// TIDAK dibunuh (itu berbahaya); cukup dilaporkan supaya pengguna tahu
|
|
207
|
+
// harus menutup OpenCode dan membukanya lagi.
|
|
208
|
+
try {
|
|
209
|
+
const stateDir = path.dirname(path.resolve(workflowState.stateFile));
|
|
210
|
+
const warning = lockWarning(acquireProcessLock({ dir: stateDir }));
|
|
211
|
+
if (warning) safeLog('error', warning);
|
|
212
|
+
process.once('exit', () => releaseProcessLock({ dir: stateDir }));
|
|
213
|
+
} catch (lockError) {
|
|
214
|
+
safeLog('error', 'Warning: could not record process lock:', lockError.message);
|
|
215
|
+
}
|
|
200
216
|
} catch (error) {
|
|
201
217
|
safeLog('error', `[${new Date().toISOString()}] Warning: Failed to initialize workflow state:`, error.message);
|
|
202
218
|
// Continue anyway - workflow state is optional for MCP to function
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@programinglive/dev-workflow-mcp-server",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.13",
|
|
4
4
|
"description": "MCP server to enforce development workflow discipline",
|
|
5
5
|
"homepage": "https://devworkflow.programinglive.com/",
|
|
6
6
|
"mcpName": "io.github.programinglive/dev-workflow-mcp-server",
|
package/process-lock.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Satu penanda "proses MCP mana yang sedang hidup" per direktori state.
|
|
3
|
+
*
|
|
4
|
+
* Kenapa perlu: 31 Oktober 2026 ada DUA proses dev-workflow MCP hidup
|
|
5
|
+
* bersamaan. Yang lebih tua memegang password Factory lama di memorinya, yang
|
|
6
|
+
* lebih muda memegang yang baru, jadi tool yang sama menjawab berbeda
|
|
7
|
+
* tergantung proses mana yang melayaninya - dan keduanya menulis berkas state
|
|
8
|
+
* workflow yang sama.
|
|
9
|
+
*
|
|
10
|
+
* Modul ini TIDAK membunuh proses lain. Membunuh proses secara otomatis adalah
|
|
11
|
+
* tindakan berbahaya (ada guard tersendiri untuk itu). Yang dilakukan: proses
|
|
12
|
+
* kedua mengetahui bahwa ia bukan satu-satunya, lalu melaporkannya, sehingga
|
|
13
|
+
* pengguna bisa menutup OpenCode dan memulai ulang dengan bersih.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
17
|
+
import os from "os";
|
|
18
|
+
import path from "path";
|
|
19
|
+
|
|
20
|
+
export const LOCK_FILENAME = "mcp-process.lock";
|
|
21
|
+
|
|
22
|
+
/** Lokasi berkas lock untuk satu direktori state. */
|
|
23
|
+
export function processLockPath({ dir }) {
|
|
24
|
+
return path.join(dir, LOCK_FILENAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Membaca isi lock, atau null bila tidak ada / tidak terbaca.
|
|
29
|
+
*
|
|
30
|
+
* Berkas rusak sengaja dianggap "tidak ada lock": penanda yang tidak bisa
|
|
31
|
+
* dibaca tidak boleh membuat server gagal start. Lebih baik dua proses
|
|
32
|
+
* bertabrakan daripada server tidak hidup sama sekali.
|
|
33
|
+
*/
|
|
34
|
+
export function readProcessLock({ dir }) {
|
|
35
|
+
const file = processLockPath({ dir });
|
|
36
|
+
|
|
37
|
+
if (!existsSync(file)) return null;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
41
|
+
if (!parsed || typeof parsed.pid !== "number") return null;
|
|
42
|
+
return parsed;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultIsAlive(pid) {
|
|
49
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Sinyal 0 tidak mengirim apa pun; ia hanya memeriksa apakah proses ada.
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// EPERM berarti proses ADA tetapi milik pengguna lain.
|
|
57
|
+
return error && error.code === "EPERM";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mencoba menjadi pemegang lock.
|
|
63
|
+
*
|
|
64
|
+
* Mengembalikan:
|
|
65
|
+
* { acquired: true } -> kita pemegangnya
|
|
66
|
+
* { acquired: true, tookOverFrom: <pid> } -> pemegang lama sudah mati
|
|
67
|
+
* { acquired: false, holder: {...} } -> ada proses lain yang hidup
|
|
68
|
+
*/
|
|
69
|
+
export function acquireProcessLock({ dir, pid = process.pid, now = Date.now(), isAlive = defaultIsAlive }) {
|
|
70
|
+
const existing = readProcessLock({ dir });
|
|
71
|
+
|
|
72
|
+
if (existing && existing.pid !== pid && isAlive(existing.pid)) {
|
|
73
|
+
return { acquired: false, holder: existing };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tookOverFrom = existing && existing.pid !== pid ? existing.pid : undefined;
|
|
77
|
+
|
|
78
|
+
const payload = { pid, startedAt: now, host: safeHostname() };
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
mkdirSync(dir, { recursive: true });
|
|
82
|
+
writeFileSync(processLockPath({ dir }), JSON.stringify(payload), "utf8");
|
|
83
|
+
} catch {
|
|
84
|
+
// Tidak bisa menulis penanda bukan alasan mematikan server.
|
|
85
|
+
return { acquired: true, wrote: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return tookOverFrom ? { acquired: true, tookOverFrom } : { acquired: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Melepas lock hanya bila memang milik kita. */
|
|
92
|
+
export function releaseProcessLock({ dir, pid = process.pid }) {
|
|
93
|
+
const existing = readProcessLock({ dir });
|
|
94
|
+
|
|
95
|
+
if (!existing || existing.pid !== pid) return false;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
unlinkSync(processLockPath({ dir }));
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function safeHostname() {
|
|
106
|
+
try {
|
|
107
|
+
return os.hostname();
|
|
108
|
+
} catch {
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pesan yang dicatat saat proses lain sudah memegang lock, atau null bila tidak
|
|
115
|
+
* ada yang perlu dilaporkan.
|
|
116
|
+
*
|
|
117
|
+
* Dipisah dari `acquireProcessLock` supaya bisa diuji tanpa menyentuh proses
|
|
118
|
+
* nyata, dan supaya pemanggil bebas memilih tidak menampilkan apa pun.
|
|
119
|
+
*/
|
|
120
|
+
export function lockWarning(result) {
|
|
121
|
+
if (!result || result.acquired !== false || !result.holder) return null;
|
|
122
|
+
|
|
123
|
+
const started = Number.isFinite(result.holder.startedAt)
|
|
124
|
+
? new Date(result.holder.startedAt).toISOString()
|
|
125
|
+
: "waktu tidak diketahui";
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
`Proses dev-workflow MCP lain (PID ${result.holder.pid}, hidup sejak ${started}) ` +
|
|
129
|
+
"masih berjalan dan memakai direktori state yang sama. " +
|
|
130
|
+
"Proses yang lebih tua memegang environment saat ia dijalankan, jadi kredensial Factory " +
|
|
131
|
+
"dan state workflow bisa berbeda antar proses. Tutup OpenCode lalu buka lagi untuk " +
|
|
132
|
+
"memastikan hanya satu proses yang hidup."
|
|
133
|
+
);
|
|
134
|
+
}
|