@programinglive/dev-workflow-mcp-server 1.12.1 → 1.13.1
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 +32 -0
- package/db/adapters/mysql.js +13 -7
- package/db/adapters/postgres.js +12 -7
- package/db/adapters/sqlite.js +17 -9
- 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 +78 -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 +6 -1
- package/utils.js +1 -0
- package/verify-build.js +7 -6
- package/vite.config.js +4 -0
- package/workflow-state.js +249 -34
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
|
/**
|
|
@@ -486,7 +521,7 @@ export function resolveProjectRootForState(stateFilePath) {
|
|
|
486
521
|
return path.dirname(resolved);
|
|
487
522
|
}
|
|
488
523
|
|
|
489
|
-
export function resolveStateFile() {
|
|
524
|
+
export function resolveStateFile({ sessionId = null } = {}) {
|
|
490
525
|
const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
|
|
491
526
|
if (customPath) {
|
|
492
527
|
return path.resolve(customPath);
|
|
@@ -516,7 +551,7 @@ export function resolveStateFile() {
|
|
|
516
551
|
|
|
517
552
|
const stateDir = path.join(projectRoot, STATE_DIR);
|
|
518
553
|
const userDir = getUserScopedDir(stateDir);
|
|
519
|
-
return path.join(userDir, sessionStateFilename());
|
|
554
|
+
return path.join(userDir, sessionStateFilename(process.env, sessionId));
|
|
520
555
|
}
|
|
521
556
|
|
|
522
557
|
export class WorkflowState {
|
|
@@ -525,6 +560,55 @@ export class WorkflowState {
|
|
|
525
560
|
// History milik proyek, state milik sesi. Dalam tata letak nyata keduanya
|
|
526
561
|
// berkas berbeda; untuk path eksplisit keduanya menunjuk berkas yang sama.
|
|
527
562
|
this.historyFile = resolveHistoryFile(stateFilePath);
|
|
563
|
+
// Task yang terakhir kita baca dari / tulis ke disk. Dipakai untuk
|
|
564
|
+
// membedakan "task ini milik kita" dari "task ini ditulis proses lain
|
|
565
|
+
// sesudah kita memuat" - tanpa ini penulis basi menang tanpa perlawanan.
|
|
566
|
+
this.loadedTask = "";
|
|
567
|
+
// Konflik penyimpanan terakhir, dibaca oleh lapisan tool untuk memberi tahu
|
|
568
|
+
// pengguna. Null berarti penyimpanan terakhir aman.
|
|
569
|
+
this.lastConflict = null;
|
|
570
|
+
this.state = {
|
|
571
|
+
currentPhase: "idle",
|
|
572
|
+
classification: "INTERNAL",
|
|
573
|
+
taskDescription: "",
|
|
574
|
+
bugFixed: false,
|
|
575
|
+
testsCreated: false,
|
|
576
|
+
testsPassed: false,
|
|
577
|
+
testsSkipped: false,
|
|
578
|
+
testsSkippedReason: "",
|
|
579
|
+
documentationCreated: false,
|
|
580
|
+
readyToCommit: false,
|
|
581
|
+
readyCheckCompleted: false,
|
|
582
|
+
released: false,
|
|
583
|
+
releaseSkipped: false,
|
|
584
|
+
releaseSkippedReason: "",
|
|
585
|
+
releaseCommand: "",
|
|
586
|
+
releaseNotes: "",
|
|
587
|
+
commitAndPushCompleted: false,
|
|
588
|
+
lastCommitMessage: "",
|
|
589
|
+
lastPushBranch: "",
|
|
590
|
+
featureFlowCreated: false,
|
|
591
|
+
featureFlowDescription: "",
|
|
592
|
+
history: [],
|
|
593
|
+
taskType: "",
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Berpindah ke state milik sesi tertentu.
|
|
599
|
+
*
|
|
600
|
+
* Satu proses MCP bisa melayani beberapa sesi bergantian, jadi state harus
|
|
601
|
+
* ditentukan per panggilan - bukan sekali saat proses start. Mengembalikan
|
|
602
|
+
* true bila berkas benar-benar berpindah.
|
|
603
|
+
*/
|
|
604
|
+
async useSession(sessionId) {
|
|
605
|
+
const target = resolveStateFile({ sessionId });
|
|
606
|
+
if (target === this.stateFile) return false;
|
|
607
|
+
|
|
608
|
+
this.stateFile = target;
|
|
609
|
+
this.historyFile = resolveHistoryFile(target);
|
|
610
|
+
// Mulai dari state bersih, lalu muat milik sesi itu. Menyalin state sesi
|
|
611
|
+
// sebelumnya akan membuat sesi baru mewarisi gerbang kualitas sesi lain.
|
|
528
612
|
this.state = {
|
|
529
613
|
currentPhase: "idle",
|
|
530
614
|
taskDescription: "",
|
|
@@ -549,13 +633,15 @@ export class WorkflowState {
|
|
|
549
633
|
history: [],
|
|
550
634
|
taskType: "",
|
|
551
635
|
};
|
|
636
|
+
await this.load();
|
|
637
|
+
return true;
|
|
552
638
|
}
|
|
553
639
|
|
|
554
640
|
async load() {
|
|
555
641
|
await migrateLegacyStateFile(this.stateFile);
|
|
556
642
|
try {
|
|
557
643
|
const data = await fs.readFile(this.stateFile, "utf-8");
|
|
558
|
-
const parsed = JSON.parse(data);
|
|
644
|
+
const parsed = sanitize(migrateOwnerMetadata(JSON.parse(data)));
|
|
559
645
|
this.state = {
|
|
560
646
|
...this.state,
|
|
561
647
|
...parsed,
|
|
@@ -564,6 +650,7 @@ export class WorkflowState {
|
|
|
564
650
|
// Run this as non-blocking to speed up startup
|
|
565
651
|
ensureCompatibilityLinks(this.stateFile).catch(() => { });
|
|
566
652
|
await this.mergeSharedHistory();
|
|
653
|
+
this.loadedTask = this.state.taskDescription || "";
|
|
567
654
|
if (typeof this.state.testsCreated !== "boolean") {
|
|
568
655
|
this.state.testsCreated = false;
|
|
569
656
|
}
|
|
@@ -663,11 +750,14 @@ export class WorkflowState {
|
|
|
663
750
|
}
|
|
664
751
|
}
|
|
665
752
|
|
|
753
|
+
const payload = { ...shared, history: merged };
|
|
754
|
+
assertSafeKnowledge(payload);
|
|
666
755
|
await fs.mkdir(path.dirname(this.historyFile), { recursive: true });
|
|
667
|
-
await fs.writeFile(this.historyFile, JSON.stringify(
|
|
756
|
+
await fs.writeFile(this.historyFile, JSON.stringify(payload, null, 2));
|
|
668
757
|
}
|
|
669
758
|
|
|
670
759
|
async ensurePrimaryFile() {
|
|
760
|
+
assertSafeKnowledge(this.state);
|
|
671
761
|
let created = false;
|
|
672
762
|
try {
|
|
673
763
|
await fs.access(this.stateFile);
|
|
@@ -684,10 +774,27 @@ export class WorkflowState {
|
|
|
684
774
|
return created;
|
|
685
775
|
}
|
|
686
776
|
|
|
687
|
-
async save() {
|
|
777
|
+
async save(options = {}) {
|
|
778
|
+
assertSafeKnowledge(this.state);
|
|
688
779
|
const reconciled = await this.reconcileWithDisk();
|
|
780
|
+
assertSafeKnowledge(this.state);
|
|
781
|
+
|
|
782
|
+
// Penulis basi tidak boleh menang.
|
|
783
|
+
//
|
|
784
|
+
// Bug 29 Okt 2026: satu proses MCP memegang state lama, lalu menyimpan
|
|
785
|
+
// sesudah proses lain memulai task baru. Task baru tertimpa dan pemanggil
|
|
786
|
+
// tetap diberi tahu "berhasil" - `get_workflow_status` berikutnya
|
|
787
|
+
// menampilkan task yang sama sekali lain.
|
|
788
|
+
const conflict = this.detectStaleOverwrite(reconciled, options);
|
|
789
|
+
if (conflict) {
|
|
790
|
+
await this.adoptDiskState();
|
|
791
|
+
this.lastConflict = conflict;
|
|
792
|
+
return { ...reconciled, conflict, verified: false };
|
|
793
|
+
}
|
|
794
|
+
this.lastConflict = null;
|
|
795
|
+
|
|
689
796
|
this.state.owner = {
|
|
690
|
-
|
|
797
|
+
processMarker: PROCESS_TOKEN,
|
|
691
798
|
pid: process.pid,
|
|
692
799
|
updatedAt: new Date().toISOString(),
|
|
693
800
|
};
|
|
@@ -699,9 +806,82 @@ export class WorkflowState {
|
|
|
699
806
|
await this.updateProjectSummary();
|
|
700
807
|
await this.syncToDatabase();
|
|
701
808
|
this.lastReconcile = reconciled;
|
|
809
|
+
this.loadedTask = this.state.taskDescription || "";
|
|
810
|
+
await this.verifyPersisted(this.loadedTask);
|
|
702
811
|
return reconciled;
|
|
703
812
|
}
|
|
704
813
|
|
|
814
|
+
/**
|
|
815
|
+
* Apakah penyimpanan ini akan menimpa task berjalan milik proses lain?
|
|
816
|
+
*
|
|
817
|
+
* Konflik hanya kalau SEMUA ini benar:
|
|
818
|
+
* - disk memuat task yang SEDANG berjalan (bukan idle),
|
|
819
|
+
* - task di disk bukan task yang terakhir kita muat (jadi ditulis orang
|
|
820
|
+
* lain sesudah kita membaca),
|
|
821
|
+
* - task di disk bukan task yang akan kita tulis (jadi bukan kita sendiri),
|
|
822
|
+
* - pemanggil tidak menyatakan maksud mengganti task.
|
|
823
|
+
*
|
|
824
|
+
* `start_task` memang bermaksud mengganti task, jadi ia memakai
|
|
825
|
+
* `allowReplaceRunningTask: true` dan tetap menang - tetapi task yang diganti
|
|
826
|
+
* diarsipkan oleh `reconcileWithDisk`, bukan dihapus.
|
|
827
|
+
*/
|
|
828
|
+
detectStaleOverwrite(reconciled, options = {}) {
|
|
829
|
+
if (options.allowReplaceRunningTask === true) {
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
const diskTask = reconciled?.diskTask || "";
|
|
833
|
+
const diskPhase = reconciled?.diskPhase || "idle";
|
|
834
|
+
if (!diskTask || diskPhase === "idle") {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
if (diskTask === this.loadedTask) {
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
840
|
+
if (diskTask === (this.state.taskDescription || "")) {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
diskTask,
|
|
845
|
+
staleTask: this.state.taskDescription || "",
|
|
846
|
+
diskPhase,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Ambil state dari disk apa adanya, dengan history digabung, tanpa menulis. */
|
|
851
|
+
async adoptDiskState() {
|
|
852
|
+
const disk = await this.readDiskState();
|
|
853
|
+
if (!disk) {
|
|
854
|
+
return false;
|
|
855
|
+
}
|
|
856
|
+
const mergedHistory = this.state.history || [];
|
|
857
|
+
this.state = { ...disk, history: mergedHistory };
|
|
858
|
+
this.mergeDiskHistory(disk);
|
|
859
|
+
this.loadedTask = this.state.taskDescription || "";
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Pastikan tulisan kita benar-benar bertahan.
|
|
865
|
+
*
|
|
866
|
+
* Tanpa ini, proses yang kalah balapan tetap melaporkan sukses. Lebih baik
|
|
867
|
+
* gagal terang-terangan daripada memberi tahu "berhasil" untuk state yang
|
|
868
|
+
* sudah ditimpa proses lain.
|
|
869
|
+
*/
|
|
870
|
+
async verifyPersisted(expectedTask) {
|
|
871
|
+
const disk = await this.readDiskState();
|
|
872
|
+
if (!disk) {
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
const actual = disk.taskDescription || "";
|
|
876
|
+
if (actual !== (expectedTask || "")) {
|
|
877
|
+
throw new Error(
|
|
878
|
+
`State workflow tidak bertahan: yang ditulis "${expectedTask}" tetapi di disk "${actual}". ` +
|
|
879
|
+
`Proses MCP lain menimpanya. Muat ulang state sebelum melanjutkan.`
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
|
|
705
885
|
/**
|
|
706
886
|
* Baca isi berkas state APA ADANYA dari disk (bukan dari memori).
|
|
707
887
|
* Mengembalikan null kalau belum ada / rusak.
|
|
@@ -709,7 +889,7 @@ export class WorkflowState {
|
|
|
709
889
|
async readDiskState() {
|
|
710
890
|
try {
|
|
711
891
|
const data = await fs.readFile(this.stateFile, "utf-8");
|
|
712
|
-
return JSON.parse(data);
|
|
892
|
+
return sanitize(migrateOwnerMetadata(JSON.parse(data)));
|
|
713
893
|
} catch {
|
|
714
894
|
return null;
|
|
715
895
|
}
|
|
@@ -717,7 +897,7 @@ export class WorkflowState {
|
|
|
717
897
|
|
|
718
898
|
/** True kalau berkas di disk terakhir ditulis proses MCP lain. */
|
|
719
899
|
isForeignWrite(diskState) {
|
|
720
|
-
const token = diskState?.owner?.
|
|
900
|
+
const token = diskState?.owner?.processMarker;
|
|
721
901
|
return typeof token === "string" && token.length > 0 && token !== PROCESS_TOKEN;
|
|
722
902
|
}
|
|
723
903
|
|
|
@@ -756,19 +936,27 @@ export class WorkflowState {
|
|
|
756
936
|
* pekerjaan resize docker-alma tidak pernah muncul di view_history.
|
|
757
937
|
*/
|
|
758
938
|
async reconcileWithDisk() {
|
|
759
|
-
const result = {
|
|
939
|
+
const result = {
|
|
940
|
+
mergedHistory: 0,
|
|
941
|
+
preservedTask: null,
|
|
942
|
+
foreignToken: null,
|
|
943
|
+
diskTask: "",
|
|
944
|
+
diskPhase: "idle",
|
|
945
|
+
};
|
|
760
946
|
const disk = await this.readDiskState();
|
|
761
947
|
if (!disk) {
|
|
762
948
|
return result;
|
|
763
949
|
}
|
|
764
950
|
|
|
951
|
+
result.diskTask = disk.taskDescription || "";
|
|
952
|
+
result.diskPhase = disk.currentPhase || "idle";
|
|
765
953
|
result.mergedHistory = this.mergeDiskHistory(disk);
|
|
766
954
|
|
|
767
955
|
if (!this.isForeignWrite(disk)) {
|
|
768
956
|
return result;
|
|
769
957
|
}
|
|
770
958
|
|
|
771
|
-
result.foreignToken = disk.owner.
|
|
959
|
+
result.foreignToken = disk.owner.processMarker;
|
|
772
960
|
|
|
773
961
|
const hasRunningTask =
|
|
774
962
|
typeof disk.taskDescription === "string" &&
|
|
@@ -788,6 +976,7 @@ export class WorkflowState {
|
|
|
788
976
|
}
|
|
789
977
|
|
|
790
978
|
this.state.history.push({
|
|
979
|
+
classification: disk.classification || "INTERNAL",
|
|
791
980
|
taskDescription: disk.taskDescription,
|
|
792
981
|
taskType: disk.taskType || "other",
|
|
793
982
|
commitMessage: disk.lastCommitMessage || "",
|
|
@@ -796,7 +985,7 @@ export class WorkflowState {
|
|
|
796
985
|
dropReason:
|
|
797
986
|
"Ditimpa proses agen lain: state workflow dipakai bersama antar sesi di mesin ini",
|
|
798
987
|
interruptedAtPhase: disk.currentPhase,
|
|
799
|
-
interruptedBy: disk.owner.
|
|
988
|
+
interruptedBy: disk.owner.processMarker,
|
|
800
989
|
timestamp: new Date().toISOString(),
|
|
801
990
|
});
|
|
802
991
|
result.preservedTask = disk.taskDescription;
|
|
@@ -804,29 +993,52 @@ export class WorkflowState {
|
|
|
804
993
|
}
|
|
805
994
|
|
|
806
995
|
async updateProjectSummary() {
|
|
996
|
+
assertSafeKnowledge(this.state);
|
|
807
997
|
const summaryPath = path.join(path.dirname(this.stateFile), PROJECT_SUMMARY_FILENAME);
|
|
808
998
|
const summary = this.generateProjectSummaryData();
|
|
999
|
+
assertSafeKnowledge(summary);
|
|
809
1000
|
await fs.mkdir(path.dirname(summaryPath), { recursive: true });
|
|
810
1001
|
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
|
|
811
1002
|
}
|
|
812
1003
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
1004
|
+
/**
|
|
1005
|
+
* Menyalin history, ringkasan, dan state ke database.
|
|
1006
|
+
*
|
|
1007
|
+
* `db` dapat diinjeksi untuk pengujian; pemanggil biasa tidak mengirimnya.
|
|
1008
|
+
*
|
|
1009
|
+
* Satu entri yang gagal TIDAK boleh menghentikan sisanya. Dulu kegagalan
|
|
1010
|
+
* pertama (mis. entri lama tanpa `task_type`) membatalkan seluruh fungsi,
|
|
1011
|
+
* sehingga `updateSummaryForUser` dan `saveState` ikut terlewat dan riwayat
|
|
1012
|
+
* proyek diam-diam berhenti tersimpan sama sekali.
|
|
1013
|
+
*/
|
|
1014
|
+
async syncToDatabase(options = {}) {
|
|
1015
|
+
assertSafeKnowledge(this.state);
|
|
1016
|
+
const db = options.db ?? (await import("./db/index.js"));
|
|
1017
|
+
const userId = getCurrentUserId();
|
|
1018
|
+
// projectRoot dipakai sebagai identitas proyek di database. Ditentukan
|
|
1019
|
+
// lewat resolveProjectRootForState, bukan dengan menghitung `dirname`
|
|
1020
|
+
// berkali-kali: jumlah itu hanya benar untuk satu kedalaman tertentu.
|
|
1021
|
+
const projectRoot = resolveProjectRootForState(this.stateFile);
|
|
1022
|
+
|
|
1023
|
+
let skipped = 0;
|
|
1024
|
+
for (const entry of this.state.history || []) {
|
|
1025
|
+
try {
|
|
1026
|
+
await db.insertHistoryEntry(userId, projectRoot, entry);
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
skipped++;
|
|
1029
|
+
safeLog("error", "History entry skipped:", error.message);
|
|
824
1030
|
}
|
|
825
|
-
|
|
826
|
-
|
|
1031
|
+
}
|
|
1032
|
+
if (skipped > 0) {
|
|
1033
|
+
safeLog("error", `History sync skipped ${skipped} entr${skipped === 1 ? "y" : "ies"}.`);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
try {
|
|
1037
|
+
await db.updateSummaryForUser(userId, projectRoot);
|
|
1038
|
+
await db.saveState(userId, projectRoot, this.state);
|
|
827
1039
|
} catch (error) {
|
|
828
|
-
//
|
|
829
|
-
|
|
1040
|
+
// Ringkasan dan state tetap opsional: jangan pernah menggagalkan save().
|
|
1041
|
+
safeLog("error", "Database sync failed:", error.message);
|
|
830
1042
|
}
|
|
831
1043
|
}
|
|
832
1044
|
|
|
@@ -892,6 +1104,7 @@ export class WorkflowState {
|
|
|
892
1104
|
if (archive && hasRunningTask) {
|
|
893
1105
|
archivedTask = previous.taskDescription;
|
|
894
1106
|
history.push({
|
|
1107
|
+
classification: previous.classification || "INTERNAL",
|
|
895
1108
|
taskDescription: previous.taskDescription,
|
|
896
1109
|
taskType: previous.taskType || "other",
|
|
897
1110
|
commitMessage: previous.lastCommitMessage || "",
|
|
@@ -906,6 +1119,7 @@ export class WorkflowState {
|
|
|
906
1119
|
|
|
907
1120
|
this.state = {
|
|
908
1121
|
currentPhase: "idle",
|
|
1122
|
+
classification: "INTERNAL",
|
|
909
1123
|
taskDescription: "",
|
|
910
1124
|
bugFixed: false,
|
|
911
1125
|
testsCreated: false,
|
|
@@ -934,6 +1148,7 @@ export class WorkflowState {
|
|
|
934
1148
|
|
|
935
1149
|
addToHistory(entry) {
|
|
936
1150
|
this.state.history.push({
|
|
1151
|
+
classification: this.state.classification || "INTERNAL",
|
|
937
1152
|
...entry,
|
|
938
1153
|
timestamp: new Date().toISOString(),
|
|
939
1154
|
});
|