@programinglive/dev-workflow-mcp-server 1.12.0 → 1.13.0
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/.dockerignore +24 -0
- package/.env.example +11 -31
- package/README.md +109 -6
- package/db/adapters/base.js +12 -0
- package/db/adapters/mysql.js +11 -5
- package/db/adapters/postgres.js +10 -5
- package/db/adapters/sqlite.js +15 -7
- package/db/index.js +2 -1
- package/docker-compose.yml +7 -5
- package/exec.js +78 -12
- package/factory-client.js +142 -10
- package/index.js +28 -19
- package/install.js +6 -5
- package/package.json +4 -1
- package/release-wrapper.js +32 -19
- package/scripts/check-review-guard.mjs +33 -0
- package/scripts/create-admin.js +11 -9
- package/scripts/install-husky.js +3 -2
- package/scripts/lint.mjs +20 -0
- package/scripts/release.cjs +25 -22
- package/scripts/release.js +6 -5
- package/scripts/setup-stack.js +42 -41
- package/scripts/update-release-notes.cjs +13 -12
- package/scripts/update-release-notes.js +8 -7
- package/scripts/verify-bundle.mjs +8 -7
- package/security/audit.js +86 -0
- package/security/commands.js +10 -0
- package/security/config.js +24 -0
- package/security/files.js +27 -0
- package/security/http.js +26 -0
- package/security/log-bridge.cjs +17 -0
- package/security/policy.js +37 -0
- package/security/references.js +21 -0
- package/security/runtime-secrets.js +42 -0
- package/security/sanitizer.js +42 -0
- package/security/scanner.js +108 -0
- package/tools/definitions.js +51 -3
- package/tools/handler-modules/commit-handlers.js +3 -7
- package/tools/handler-modules/factory-handlers.js +84 -0
- package/tools/handler-modules/index.js +45 -3
- package/tools/handler-modules/shared.js +5 -2
- package/tools/handler-modules/status-handlers.js +1 -1
- package/tools/handler-modules/summary-handlers.js +2 -1
- package/tools/handler-modules/workflow-handlers.js +2 -0
- package/utils.js +1 -0
- package/verify-build.js +7 -6
- package/vite.config.js +4 -0
- package/workflow-state.js +142 -42
package/workflow-state.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { safeLog } from './security/sanitizer.js';
|
|
1
2
|
import fs from "fs/promises";
|
|
2
3
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
|
|
3
4
|
import path from "path";
|
|
5
|
+
import { assertSafeKnowledge } from './security/policy.js';
|
|
6
|
+
import { sanitize } from './security/sanitizer.js';
|
|
4
7
|
import { fileURLToPath } from "url";
|
|
5
8
|
|
|
6
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -30,6 +33,14 @@ let cachedUserStateDir = null;
|
|
|
30
33
|
*/
|
|
31
34
|
const PROCESS_TOKEN = `proc-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
|
|
32
35
|
|
|
36
|
+
function migrateOwnerMetadata(value) {
|
|
37
|
+
if(value?.owner && !value.owner.processMarker && /^proc-[A-Za-z0-9-]+$/.test(value.owner.token || '')) {
|
|
38
|
+
value.owner.processMarker=value.owner.token;
|
|
39
|
+
delete value.owner.token;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
function generateUserId() {
|
|
34
45
|
const timestamp = Date.now().toString(16);
|
|
35
46
|
const randomSuffix = Math.random().toString(16).slice(2, 10);
|
|
@@ -249,6 +260,8 @@ async function migrateLegacyStateFile(targetPath) {
|
|
|
249
260
|
}
|
|
250
261
|
|
|
251
262
|
await fs.mkdir(stateDir, { recursive: true });
|
|
263
|
+
const migrated = migrateOwnerMetadata(JSON.parse(await fs.readFile(legacyPath, 'utf8')));
|
|
264
|
+
assertSafeKnowledge(migrated);
|
|
252
265
|
|
|
253
266
|
try {
|
|
254
267
|
await fs.access(targetPath);
|
|
@@ -258,7 +271,8 @@ async function migrateLegacyStateFile(targetPath) {
|
|
|
258
271
|
// New state file doesn't exist yet; fall through to migrate.
|
|
259
272
|
}
|
|
260
273
|
|
|
261
|
-
await fs.
|
|
274
|
+
await fs.writeFile(targetPath, JSON.stringify(migrated, null, 2), { flag: 'wx' });
|
|
275
|
+
await fs.unlink(legacyPath);
|
|
262
276
|
}
|
|
263
277
|
|
|
264
278
|
function isRootPath(candidate) {
|
|
@@ -338,6 +352,7 @@ async function ensureCompatibilityLinks(primaryPath) {
|
|
|
338
352
|
if (!(await pathExists(resolvedPrimary))) {
|
|
339
353
|
return;
|
|
340
354
|
}
|
|
355
|
+
assertSafeKnowledge(JSON.parse(await fs.readFile(resolvedPrimary, 'utf8')));
|
|
341
356
|
|
|
342
357
|
const compatibilityPaths = getCompatibilityPaths(resolvedPrimary);
|
|
343
358
|
|
|
@@ -413,18 +428,38 @@ async function ensureCompatibilityLinks(primaryPath) {
|
|
|
413
428
|
*
|
|
414
429
|
* MENGAPA ADA
|
|
415
430
|
* -----------
|
|
416
|
-
* Berkas state dulu disimpan per USER, jadi
|
|
431
|
+
* Berkas state dulu disimpan per USER, jadi beberapa proses MCP berbagi satu
|
|
417
432
|
* `workflow-state.json`. Akibatnya `start_task` di satu sesi mengganti task
|
|
418
433
|
* sesi lain di tengah jalan, dan gerbang kualitas (ready check, tests, docs)
|
|
419
434
|
* ikut dibagi sehingga sesi B bisa memakai hasil sesi A untuk commit.
|
|
420
435
|
*
|
|
421
436
|
* Sekarang task dan gerbangnya milik SESI; `history` tetap milik PROYEK.
|
|
422
437
|
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
438
|
+
* MENGAPA BUKAN ENV SAJA
|
|
439
|
+
* ----------------------
|
|
440
|
+
* Percobaan pertama membaca `OPENCODE_SESSION_ID` dari environment. OpenCode
|
|
441
|
+
* tidak men-set variabel itu, sehingga hampir semua berkas jatuh ke
|
|
442
|
+
* `pid-<pid>`; karena satu proses MCP dipakai bergantian oleh beberapa sesi,
|
|
443
|
+
* sesi-sesi itu tetap berbagi satu berkas dan saling menimpa.
|
|
444
|
+
*
|
|
445
|
+
* OpenCode v2 mengirim identitas sesi per panggilan di
|
|
446
|
+
* `CallToolRequest.params._meta["ai.opencode/sessionID"]`. Itu sumber utama
|
|
447
|
+
* sekarang; env tetap dibaca sebagai cadangan untuk tes dan pemanggil lain.
|
|
426
448
|
*/
|
|
427
|
-
export
|
|
449
|
+
export const SESSION_META_KEY = "ai.opencode/sessionID";
|
|
450
|
+
|
|
451
|
+
/** Mengambil identitas sesi dari request metadata MCP, atau null. */
|
|
452
|
+
export function sessionKeyFromRequest(request) {
|
|
453
|
+
const value = request?.params?._meta?.[SESSION_META_KEY];
|
|
454
|
+
if (typeof value !== "string") return null;
|
|
455
|
+
const trimmed = value.trim();
|
|
456
|
+
return trimmed === "" ? null : trimmed;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function resolveSessionKey(env = process.env, explicitSessionId = null) {
|
|
460
|
+
const fromRequest = typeof explicitSessionId === "string" ? explicitSessionId.trim() : "";
|
|
461
|
+
if (fromRequest) return sanitizeSessionKey(fromRequest);
|
|
462
|
+
|
|
428
463
|
for (const candidate of [env.DEV_WORKFLOW_SESSION_ID, env.OPENCODE_SESSION_ID, env.CLIENT_SESSION_ID]) {
|
|
429
464
|
const value = typeof candidate === "string" ? candidate.trim() : "";
|
|
430
465
|
if (value) return sanitizeSessionKey(value);
|
|
@@ -438,8 +473,8 @@ function sanitizeSessionKey(value) {
|
|
|
438
473
|
}
|
|
439
474
|
|
|
440
475
|
/** Nama berkas state untuk sesi ini, di direktori user yang sama. */
|
|
441
|
-
function sessionStateFilename(env = process.env) {
|
|
442
|
-
return `workflow-state.${resolveSessionKey(env)}.json`;
|
|
476
|
+
function sessionStateFilename(env = process.env, explicitSessionId = null) {
|
|
477
|
+
return `workflow-state.${resolveSessionKey(env, explicitSessionId)}.json`;
|
|
443
478
|
}
|
|
444
479
|
|
|
445
480
|
/**
|
|
@@ -457,7 +492,36 @@ export function resolveHistoryFile(stateFilePath) {
|
|
|
457
492
|
return inUserTree ? path.join(dir, STATE_FILENAME) : resolved;
|
|
458
493
|
}
|
|
459
494
|
|
|
460
|
-
|
|
495
|
+
/**
|
|
496
|
+
* Induk direktori `.state` untuk sebuah berkas state.
|
|
497
|
+
*
|
|
498
|
+
* Dulu ini dihitung dengan empat `dirname` berurutan, yang hanya benar selama
|
|
499
|
+
* tata letaknya tepat `<root>/.state/users/<user>/<berkas>`. Begitu kedalaman
|
|
500
|
+
* berubah (mis. state sesi ditaruh di subdirektori), angka itu diam-diam
|
|
501
|
+
* menghasilkan root yang salah dan baris database tercatat di proyek lain.
|
|
502
|
+
*
|
|
503
|
+
* Sekarang dicari dengan sengaja: naik sampai menemukan direktori `.state`,
|
|
504
|
+
* lalu ambil induknya. Tidak bergantung pada kedalaman.
|
|
505
|
+
*/
|
|
506
|
+
export function resolveProjectRootForState(stateFilePath) {
|
|
507
|
+
const resolved = path.resolve(stateFilePath);
|
|
508
|
+
let current = path.dirname(resolved);
|
|
509
|
+
|
|
510
|
+
for (;;) {
|
|
511
|
+
if (path.basename(current) === STATE_DIR) {
|
|
512
|
+
return path.dirname(current);
|
|
513
|
+
}
|
|
514
|
+
const parent = path.dirname(current);
|
|
515
|
+
if (parent === current) break;
|
|
516
|
+
current = parent;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Di luar tata letak `.state` (mis. path eksplisit untuk tes): pakai
|
|
520
|
+
// direktori yang memuat berkasnya.
|
|
521
|
+
return path.dirname(resolved);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function resolveStateFile({ sessionId = null } = {}) {
|
|
461
525
|
const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
|
|
462
526
|
if (customPath) {
|
|
463
527
|
return path.resolve(customPath);
|
|
@@ -487,7 +551,7 @@ export function resolveStateFile() {
|
|
|
487
551
|
|
|
488
552
|
const stateDir = path.join(projectRoot, STATE_DIR);
|
|
489
553
|
const userDir = getUserScopedDir(stateDir);
|
|
490
|
-
return path.join(userDir, sessionStateFilename());
|
|
554
|
+
return path.join(userDir, sessionStateFilename(process.env, sessionId));
|
|
491
555
|
}
|
|
492
556
|
|
|
493
557
|
export class WorkflowState {
|
|
@@ -498,6 +562,7 @@ export class WorkflowState {
|
|
|
498
562
|
this.historyFile = resolveHistoryFile(stateFilePath);
|
|
499
563
|
this.state = {
|
|
500
564
|
currentPhase: "idle",
|
|
565
|
+
classification: "INTERNAL",
|
|
501
566
|
taskDescription: "",
|
|
502
567
|
bugFixed: false,
|
|
503
568
|
testsCreated: false,
|
|
@@ -522,11 +587,54 @@ export class WorkflowState {
|
|
|
522
587
|
};
|
|
523
588
|
}
|
|
524
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Berpindah ke state milik sesi tertentu.
|
|
592
|
+
*
|
|
593
|
+
* Satu proses MCP bisa melayani beberapa sesi bergantian, jadi state harus
|
|
594
|
+
* ditentukan per panggilan - bukan sekali saat proses start. Mengembalikan
|
|
595
|
+
* true bila berkas benar-benar berpindah.
|
|
596
|
+
*/
|
|
597
|
+
async useSession(sessionId) {
|
|
598
|
+
const target = resolveStateFile({ sessionId });
|
|
599
|
+
if (target === this.stateFile) return false;
|
|
600
|
+
|
|
601
|
+
this.stateFile = target;
|
|
602
|
+
this.historyFile = resolveHistoryFile(target);
|
|
603
|
+
// Mulai dari state bersih, lalu muat milik sesi itu. Menyalin state sesi
|
|
604
|
+
// sebelumnya akan membuat sesi baru mewarisi gerbang kualitas sesi lain.
|
|
605
|
+
this.state = {
|
|
606
|
+
currentPhase: "idle",
|
|
607
|
+
taskDescription: "",
|
|
608
|
+
bugFixed: false,
|
|
609
|
+
testsCreated: false,
|
|
610
|
+
testsPassed: false,
|
|
611
|
+
testsSkipped: false,
|
|
612
|
+
testsSkippedReason: "",
|
|
613
|
+
documentationCreated: false,
|
|
614
|
+
readyToCommit: false,
|
|
615
|
+
readyCheckCompleted: false,
|
|
616
|
+
released: false,
|
|
617
|
+
releaseSkipped: false,
|
|
618
|
+
releaseSkippedReason: "",
|
|
619
|
+
releaseCommand: "",
|
|
620
|
+
releaseNotes: "",
|
|
621
|
+
commitAndPushCompleted: false,
|
|
622
|
+
lastCommitMessage: "",
|
|
623
|
+
lastPushBranch: "",
|
|
624
|
+
featureFlowCreated: false,
|
|
625
|
+
featureFlowDescription: "",
|
|
626
|
+
history: [],
|
|
627
|
+
taskType: "",
|
|
628
|
+
};
|
|
629
|
+
await this.load();
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
|
|
525
633
|
async load() {
|
|
526
634
|
await migrateLegacyStateFile(this.stateFile);
|
|
527
635
|
try {
|
|
528
636
|
const data = await fs.readFile(this.stateFile, "utf-8");
|
|
529
|
-
const parsed = JSON.parse(data);
|
|
637
|
+
const parsed = sanitize(migrateOwnerMetadata(JSON.parse(data)));
|
|
530
638
|
this.state = {
|
|
531
639
|
...this.state,
|
|
532
640
|
...parsed,
|
|
@@ -634,11 +742,14 @@ export class WorkflowState {
|
|
|
634
742
|
}
|
|
635
743
|
}
|
|
636
744
|
|
|
745
|
+
const payload = { ...shared, history: merged };
|
|
746
|
+
assertSafeKnowledge(payload);
|
|
637
747
|
await fs.mkdir(path.dirname(this.historyFile), { recursive: true });
|
|
638
|
-
await fs.writeFile(this.historyFile, JSON.stringify(
|
|
748
|
+
await fs.writeFile(this.historyFile, JSON.stringify(payload, null, 2));
|
|
639
749
|
}
|
|
640
750
|
|
|
641
751
|
async ensurePrimaryFile() {
|
|
752
|
+
assertSafeKnowledge(this.state);
|
|
642
753
|
let created = false;
|
|
643
754
|
try {
|
|
644
755
|
await fs.access(this.stateFile);
|
|
@@ -656,9 +767,11 @@ export class WorkflowState {
|
|
|
656
767
|
}
|
|
657
768
|
|
|
658
769
|
async save() {
|
|
770
|
+
assertSafeKnowledge(this.state);
|
|
659
771
|
const reconciled = await this.reconcileWithDisk();
|
|
772
|
+
assertSafeKnowledge(this.state);
|
|
660
773
|
this.state.owner = {
|
|
661
|
-
|
|
774
|
+
processMarker: PROCESS_TOKEN,
|
|
662
775
|
pid: process.pid,
|
|
663
776
|
updatedAt: new Date().toISOString(),
|
|
664
777
|
};
|
|
@@ -680,7 +793,7 @@ export class WorkflowState {
|
|
|
680
793
|
async readDiskState() {
|
|
681
794
|
try {
|
|
682
795
|
const data = await fs.readFile(this.stateFile, "utf-8");
|
|
683
|
-
return JSON.parse(data);
|
|
796
|
+
return sanitize(migrateOwnerMetadata(JSON.parse(data)));
|
|
684
797
|
} catch {
|
|
685
798
|
return null;
|
|
686
799
|
}
|
|
@@ -688,7 +801,7 @@ export class WorkflowState {
|
|
|
688
801
|
|
|
689
802
|
/** True kalau berkas di disk terakhir ditulis proses MCP lain. */
|
|
690
803
|
isForeignWrite(diskState) {
|
|
691
|
-
const token = diskState?.owner?.
|
|
804
|
+
const token = diskState?.owner?.processMarker;
|
|
692
805
|
return typeof token === "string" && token.length > 0 && token !== PROCESS_TOKEN;
|
|
693
806
|
}
|
|
694
807
|
|
|
@@ -739,7 +852,7 @@ export class WorkflowState {
|
|
|
739
852
|
return result;
|
|
740
853
|
}
|
|
741
854
|
|
|
742
|
-
result.foreignToken = disk.owner.
|
|
855
|
+
result.foreignToken = disk.owner.processMarker;
|
|
743
856
|
|
|
744
857
|
const hasRunningTask =
|
|
745
858
|
typeof disk.taskDescription === "string" &&
|
|
@@ -759,6 +872,7 @@ export class WorkflowState {
|
|
|
759
872
|
}
|
|
760
873
|
|
|
761
874
|
this.state.history.push({
|
|
875
|
+
classification: disk.classification || "INTERNAL",
|
|
762
876
|
taskDescription: disk.taskDescription,
|
|
763
877
|
taskType: disk.taskType || "other",
|
|
764
878
|
commitMessage: disk.lastCommitMessage || "",
|
|
@@ -767,7 +881,7 @@ export class WorkflowState {
|
|
|
767
881
|
dropReason:
|
|
768
882
|
"Ditimpa proses agen lain: state workflow dipakai bersama antar sesi di mesin ini",
|
|
769
883
|
interruptedAtPhase: disk.currentPhase,
|
|
770
|
-
interruptedBy: disk.owner.
|
|
884
|
+
interruptedBy: disk.owner.processMarker,
|
|
771
885
|
timestamp: new Date().toISOString(),
|
|
772
886
|
});
|
|
773
887
|
result.preservedTask = disk.taskDescription;
|
|
@@ -775,40 +889,23 @@ export class WorkflowState {
|
|
|
775
889
|
}
|
|
776
890
|
|
|
777
891
|
async updateProjectSummary() {
|
|
892
|
+
assertSafeKnowledge(this.state);
|
|
778
893
|
const summaryPath = path.join(path.dirname(this.stateFile), PROJECT_SUMMARY_FILENAME);
|
|
779
894
|
const summary = this.generateProjectSummaryData();
|
|
895
|
+
assertSafeKnowledge(summary);
|
|
780
896
|
await fs.mkdir(path.dirname(summaryPath), { recursive: true });
|
|
781
897
|
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
|
|
782
898
|
}
|
|
783
899
|
|
|
784
900
|
async syncToDatabase() {
|
|
901
|
+
assertSafeKnowledge(this.state);
|
|
785
902
|
try {
|
|
786
903
|
const { insertHistoryEntry, updateSummaryForUser, saveState } = await import("./db/index.js");
|
|
787
904
|
const userId = getCurrentUserId();
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
// A safe bet that is unique per project on this machine is the project root found by resolveStateFile.
|
|
793
|
-
// Let's perform a simple resolution:
|
|
794
|
-
|
|
795
|
-
const stateDir = path.dirname(path.dirname(path.dirname(this.stateFile)));
|
|
796
|
-
// stateFile = <root>/.state/users/<user>/workflow-state.json
|
|
797
|
-
// dirname = <root>/.state/users/<user>
|
|
798
|
-
// dirname = <root>/.state/users
|
|
799
|
-
// dirname = <root>/.state
|
|
800
|
-
// dirname = <root>
|
|
801
|
-
|
|
802
|
-
// Wait, let's look at the path structure from resolveStateFile:
|
|
803
|
-
// path.join(userDir, STATE_FILENAME); where userDir is path.join(stateDir, "users", userId);
|
|
804
|
-
// and stateDir is path.join(projectRoot, STATE_DIR);
|
|
805
|
-
// So: projectRoot/.state/users/userId/workflow-state.json
|
|
806
|
-
// 1. dirname -> .../userId
|
|
807
|
-
// 2. dirname -> .../users
|
|
808
|
-
// 3. dirname -> .../.state
|
|
809
|
-
// 4. dirname -> .../projectRoot
|
|
810
|
-
|
|
811
|
-
const projectRoot = path.dirname(path.dirname(path.dirname(path.dirname(this.stateFile))));
|
|
905
|
+
// projectRoot dipakai sebagai identitas proyek di database. Ditentukan
|
|
906
|
+
// lewat resolveProjectRootForState, bukan dengan menghitung `dirname`
|
|
907
|
+
// berkali-kali: jumlah itu hanya benar untuk satu kedalaman tertentu.
|
|
908
|
+
const projectRoot = resolveProjectRootForState(this.stateFile);
|
|
812
909
|
|
|
813
910
|
for (const entry of this.state.history || []) {
|
|
814
911
|
await insertHistoryEntry(userId, projectRoot, entry);
|
|
@@ -817,7 +914,7 @@ export class WorkflowState {
|
|
|
817
914
|
await saveState(userId, projectRoot, this.state);
|
|
818
915
|
} catch (error) {
|
|
819
916
|
// Database sync failed; log it but don't crash
|
|
820
|
-
|
|
917
|
+
safeLog('error', "Database sync failed:", error.message);
|
|
821
918
|
}
|
|
822
919
|
}
|
|
823
920
|
|
|
@@ -883,6 +980,7 @@ export class WorkflowState {
|
|
|
883
980
|
if (archive && hasRunningTask) {
|
|
884
981
|
archivedTask = previous.taskDescription;
|
|
885
982
|
history.push({
|
|
983
|
+
classification: previous.classification || "INTERNAL",
|
|
886
984
|
taskDescription: previous.taskDescription,
|
|
887
985
|
taskType: previous.taskType || "other",
|
|
888
986
|
commitMessage: previous.lastCommitMessage || "",
|
|
@@ -897,6 +995,7 @@ export class WorkflowState {
|
|
|
897
995
|
|
|
898
996
|
this.state = {
|
|
899
997
|
currentPhase: "idle",
|
|
998
|
+
classification: "INTERNAL",
|
|
900
999
|
taskDescription: "",
|
|
901
1000
|
bugFixed: false,
|
|
902
1001
|
testsCreated: false,
|
|
@@ -925,6 +1024,7 @@ export class WorkflowState {
|
|
|
925
1024
|
|
|
926
1025
|
addToHistory(entry) {
|
|
927
1026
|
this.state.history.push({
|
|
1027
|
+
classification: this.state.classification || "INTERNAL",
|
|
928
1028
|
...entry,
|
|
929
1029
|
timestamp: new Date().toISOString(),
|
|
930
1030
|
});
|