project-state-manager 1.0.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/README.md +51 -0
- package/dist/shared/schemas/process.schema.d.ts +969 -0
- package/dist/shared/schemas/process.schema.js +119 -0
- package/dist/src/core/error-formatter.d.ts +31 -0
- package/dist/src/core/error-formatter.js +97 -0
- package/dist/src/core/signal-calculator.d.ts +12 -0
- package/dist/src/core/signal-calculator.js +19 -0
- package/dist/src/core/yaml-engine.d.ts +15 -0
- package/dist/src/core/yaml-engine.js +162 -0
- package/dist/src/gui-worker.d.ts +1 -0
- package/dist/src/gui-worker.js +221 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +281 -0
- package/dist/src/infra/ipc-hub.d.ts +18 -0
- package/dist/src/infra/ipc-hub.js +71 -0
- package/dist/src/infra/proxy-client.d.ts +32 -0
- package/dist/src/infra/proxy-client.js +134 -0
- package/dist/src/tools/check-readiness.d.ts +17 -0
- package/dist/src/tools/check-readiness.js +48 -0
- package/dist/src/tools/define-process.d.ts +14 -0
- package/dist/src/tools/define-process.js +39 -0
- package/dist/src/tools/discover-process.d.ts +7 -0
- package/dist/src/tools/discover-process.js +41 -0
- package/dist/src/tools/get-health-status.d.ts +11 -0
- package/dist/src/tools/get-health-status.js +62 -0
- package/dist/src/tools/list-existing-processes.d.ts +7 -0
- package/dist/src/tools/list-existing-processes.js +25 -0
- package/dist/src/tools/scan-node.d.ts +14 -0
- package/dist/src/tools/scan-node.js +47 -0
- package/dist/src/tools/submit-node-data.d.ts +18 -0
- package/dist/src/tools/submit-node-data.js +86 -0
- package/gui/README.md +73 -0
- package/gui/dist/assets/index-BpHg-2LI.css +1 -0
- package/gui/dist/assets/index-Cq6Ut9u2.js +9 -0
- package/gui/dist/index.html +14 -0
- package/gui/dist/vite.svg +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Schema for Process nodes (Input, Flow, Output).
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Individual schema for Entry node findings.
|
|
7
|
+
* Exported for GUI reuse (Story 3.1).
|
|
8
|
+
*/
|
|
9
|
+
export const EntryFindingsSchema = z.object({
|
|
10
|
+
type: z.literal("entry"),
|
|
11
|
+
sources: z.array(z.string()),
|
|
12
|
+
validation_rules: z.array(z.string()),
|
|
13
|
+
sensitive_data: z.boolean(),
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* Individual schema for Flow node findings.
|
|
17
|
+
* Exported for GUI reuse (Story 3.1).
|
|
18
|
+
*/
|
|
19
|
+
export const FlowFindingsSchema = z.object({
|
|
20
|
+
type: z.literal("flow"),
|
|
21
|
+
transformations: z.array(z.string()),
|
|
22
|
+
affected_services: z.array(z.string()),
|
|
23
|
+
side_effects: z.array(z.string()),
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Individual schema for Exit node findings.
|
|
27
|
+
* Exported for GUI reuse (Story 3.1).
|
|
28
|
+
*/
|
|
29
|
+
export const ExitFindingsSchema = z.object({
|
|
30
|
+
type: z.literal("exit"),
|
|
31
|
+
destinations: z.array(z.string()),
|
|
32
|
+
response_formats: z.array(z.string()),
|
|
33
|
+
error_handling: z.array(z.string()),
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* Discriminated union of all node findings schemas.
|
|
37
|
+
*/
|
|
38
|
+
export const NodeFindingsSchema = z.discriminatedUnion("type", [
|
|
39
|
+
EntryFindingsSchema,
|
|
40
|
+
FlowFindingsSchema,
|
|
41
|
+
ExitFindingsSchema,
|
|
42
|
+
]);
|
|
43
|
+
export const ProcessNodeSchema = z.object({
|
|
44
|
+
content: z.string(),
|
|
45
|
+
status: z.enum(["todo", "in-progress", "done", "valid", "error"]),
|
|
46
|
+
lastUpdated: z.string().datetime(),
|
|
47
|
+
manuallyModified: z.boolean().optional(),
|
|
48
|
+
data: NodeFindingsSchema.optional(),
|
|
49
|
+
});
|
|
50
|
+
export const ProcessSchema = z.object({
|
|
51
|
+
id: z.string(),
|
|
52
|
+
name: z.string(),
|
|
53
|
+
entryPoint: z.string(),
|
|
54
|
+
nodes: z.object({
|
|
55
|
+
input: ProcessNodeSchema,
|
|
56
|
+
flow: ProcessNodeSchema,
|
|
57
|
+
output: ProcessNodeSchema,
|
|
58
|
+
}),
|
|
59
|
+
is_executable: z.boolean().default(false),
|
|
60
|
+
});
|
|
61
|
+
/**
|
|
62
|
+
* Schema for IPC messages between Master and Worker.
|
|
63
|
+
* Follows the standard: { type: string, data: any, timestamp: string }
|
|
64
|
+
*/
|
|
65
|
+
export const IPCMessageSchema = z.object({
|
|
66
|
+
type: z.string(),
|
|
67
|
+
correlationId: z.string().optional(),
|
|
68
|
+
data: z.any().optional(),
|
|
69
|
+
error: z.string().optional(),
|
|
70
|
+
timestamp: z.string().datetime(),
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* Schema for Proxy registration responses.
|
|
74
|
+
*/
|
|
75
|
+
export const ProxyResponseSchema = z.object({
|
|
76
|
+
port: z.number().int().positive(),
|
|
77
|
+
url: z.string().url().optional(),
|
|
78
|
+
});
|
|
79
|
+
/**
|
|
80
|
+
* Tool Argument Schemas
|
|
81
|
+
*/
|
|
82
|
+
export const DefineProcessArgsSchema = z.object({
|
|
83
|
+
name: z.string().describe('The name of the process (feature name)'),
|
|
84
|
+
entry_point: z.string().describe('The main file or directory associated with this process'),
|
|
85
|
+
});
|
|
86
|
+
export const DiscoverProcessArgsSchema = z.object({
|
|
87
|
+
context: z.string().describe('A keyword, file path, or description to find a relevant process'),
|
|
88
|
+
});
|
|
89
|
+
export const ScanNodeArgsSchema = z.object({
|
|
90
|
+
process_name: z.string().describe('The name (ID) of the process to scan'),
|
|
91
|
+
node_type: z.enum(['entry', 'flow', 'exit']).describe('The type of node to analyze (entry, flow, or exit)'),
|
|
92
|
+
});
|
|
93
|
+
export const SubmitNodeDataArgsSchema = z.object({
|
|
94
|
+
process_name: z.string().describe('The name (ID) of the process'),
|
|
95
|
+
node_type: z.enum(['entry', 'flow', 'exit']).describe('The type of node'),
|
|
96
|
+
findings: z.any().describe('The findings to submit (will be validated against specific schema)'),
|
|
97
|
+
});
|
|
98
|
+
export const CheckReadinessArgsSchema = z.object({
|
|
99
|
+
process_name: z.string().describe('The name (ID) of the process to check readiness for'),
|
|
100
|
+
});
|
|
101
|
+
export const HealthStatusSchema = z.object({
|
|
102
|
+
master: z.object({
|
|
103
|
+
status: z.string(),
|
|
104
|
+
uptime: z.number(),
|
|
105
|
+
memory: z.object({
|
|
106
|
+
rss: z.number(),
|
|
107
|
+
heapUsed: z.number(),
|
|
108
|
+
}),
|
|
109
|
+
}),
|
|
110
|
+
worker: z.object({
|
|
111
|
+
status: z.string(),
|
|
112
|
+
connected: z.boolean(),
|
|
113
|
+
uptime: z.number().optional(),
|
|
114
|
+
memory: z.object({
|
|
115
|
+
rss: z.number(),
|
|
116
|
+
heapUsed: z.number(),
|
|
117
|
+
}).optional(),
|
|
118
|
+
}).optional(),
|
|
119
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ZodError } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Interface pour une erreur formatée avec suggestions.
|
|
4
|
+
*/
|
|
5
|
+
export interface FormattedError {
|
|
6
|
+
path: string;
|
|
7
|
+
message: string;
|
|
8
|
+
expected?: string;
|
|
9
|
+
suggestion?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Formateur d'erreurs intelligent fournissant des suggestions contextuelles pour les agents IA.
|
|
13
|
+
*/
|
|
14
|
+
export declare class ErrorFormatter {
|
|
15
|
+
/**
|
|
16
|
+
* Formate une ZodError en une chaîne structurée avec des suggestions.
|
|
17
|
+
*
|
|
18
|
+
* @param error - L'instance ZodError
|
|
19
|
+
* @param nodeType - Le type de nœud (entry, flow, exit) pour le contexte
|
|
20
|
+
* @returns Une chaîne lisible par l'humain formatée pour la consommation par l'IA
|
|
21
|
+
*/
|
|
22
|
+
static format(error: ZodError, nodeType: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Construit la chaîne de sortie finale avec des sections claires.
|
|
25
|
+
*/
|
|
26
|
+
private static buildOutputString;
|
|
27
|
+
/**
|
|
28
|
+
* Fournit des données d'exemple basées sur le type de nœud.
|
|
29
|
+
*/
|
|
30
|
+
private static getExampleData;
|
|
31
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formateur d'erreurs intelligent fournissant des suggestions contextuelles pour les agents IA.
|
|
3
|
+
*/
|
|
4
|
+
export class ErrorFormatter {
|
|
5
|
+
/**
|
|
6
|
+
* Formate une ZodError en une chaîne structurée avec des suggestions.
|
|
7
|
+
*
|
|
8
|
+
* @param error - L'instance ZodError
|
|
9
|
+
* @param nodeType - Le type de nœud (entry, flow, exit) pour le contexte
|
|
10
|
+
* @returns Une chaîne lisible par l'humain formatée pour la consommation par l'IA
|
|
11
|
+
*/
|
|
12
|
+
static format(error, nodeType) {
|
|
13
|
+
const formattedErrors = error.errors.map((err) => {
|
|
14
|
+
const path = err.path.join('.');
|
|
15
|
+
let suggestion = '';
|
|
16
|
+
let expected = '';
|
|
17
|
+
// Suggestions contextuelles basées sur les codes d'erreur Zod
|
|
18
|
+
switch (err.code) {
|
|
19
|
+
case 'invalid_type':
|
|
20
|
+
expected = `${err.expected}`;
|
|
21
|
+
suggestion = `Le champ "${path}" doit être de type ${err.expected}. Vous avez fourni ${err.received}.`;
|
|
22
|
+
break;
|
|
23
|
+
case 'too_small':
|
|
24
|
+
suggestion = `Le champ "${path}" est trop court ou contient trop peu d'éléments (minimum attendu: ${err.minimum}).`;
|
|
25
|
+
break;
|
|
26
|
+
case 'too_big':
|
|
27
|
+
suggestion = `Le champ "${path}" est trop long ou contient trop d'éléments (maximum autorisé: ${err.maximum}).`;
|
|
28
|
+
break;
|
|
29
|
+
case 'invalid_enum_value':
|
|
30
|
+
expected = `L'une des valeurs: ${err.options.join(', ')}`;
|
|
31
|
+
suggestion = `La valeur fournie pour "${path}" n'est pas valide. Valeurs acceptées: ${err.options.join(', ')}.`;
|
|
32
|
+
break;
|
|
33
|
+
case 'invalid_string':
|
|
34
|
+
suggestion = `Le format de la chaîne pour "${path}" est invalide (attendu: ${err.validation}).`;
|
|
35
|
+
break;
|
|
36
|
+
case 'invalid_date':
|
|
37
|
+
suggestion = `La date fournie pour "${path}" est invalide.`;
|
|
38
|
+
break;
|
|
39
|
+
default:
|
|
40
|
+
suggestion = `Veuillez vérifier la structure du champ "${path}". ${err.message}`;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
path,
|
|
44
|
+
message: err.message,
|
|
45
|
+
expected,
|
|
46
|
+
suggestion
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
return this.buildOutputString(formattedErrors, nodeType);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Construit la chaîne de sortie finale avec des sections claires.
|
|
53
|
+
*/
|
|
54
|
+
static buildOutputString(errors, nodeType) {
|
|
55
|
+
let output = `ERREUR DE VALIDATION - Noeud: ${nodeType}\n`;
|
|
56
|
+
output += `-------------------------------------------\n`;
|
|
57
|
+
errors.forEach((err) => {
|
|
58
|
+
output += `CHEMIN: ${err.path}\n`;
|
|
59
|
+
output += `PROBLEME: ${err.message}\n`;
|
|
60
|
+
if (err.expected)
|
|
61
|
+
output += `TYPE ATTENDU: ${err.expected}\n`;
|
|
62
|
+
if (err.suggestion)
|
|
63
|
+
output += `\nSUGGESTION DE CORRECTION:\n${err.suggestion}\n`;
|
|
64
|
+
output += `-------------------------------------------\n`;
|
|
65
|
+
});
|
|
66
|
+
output += `\nEXEMPLE DE DONNEES VALIDES POUR LE NOEUD "${nodeType}":\n`;
|
|
67
|
+
output += this.getExampleData(nodeType);
|
|
68
|
+
return output;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fournit des données d'exemple basées sur le type de nœud.
|
|
72
|
+
*/
|
|
73
|
+
static getExampleData(nodeType) {
|
|
74
|
+
switch (nodeType) {
|
|
75
|
+
case 'entry':
|
|
76
|
+
return JSON.stringify({
|
|
77
|
+
sources: ["src/auth/login.ts", "src/auth/register.ts"],
|
|
78
|
+
validation_rules: ["Vérification du format email", "Longueur minimale password"],
|
|
79
|
+
sensitive_data: true
|
|
80
|
+
}, null, 2);
|
|
81
|
+
case 'flow':
|
|
82
|
+
return JSON.stringify({
|
|
83
|
+
transformations: ["Hachage du mot de passe via bcrypt", "Génération du token JWT"],
|
|
84
|
+
affected_services: ["DatabaseService", "AuthService"],
|
|
85
|
+
side_effects: ["Log de connexion", "Mise à jour last_login"]
|
|
86
|
+
}, null, 2);
|
|
87
|
+
case 'exit':
|
|
88
|
+
return JSON.stringify({
|
|
89
|
+
destinations: ["Client Frontend", "Audit Log"],
|
|
90
|
+
response_formats: ["JSON { token: string, user: Object }"],
|
|
91
|
+
error_handling: ["401 Unauthorized sur échec auth", "500 Internal Error sur DB failure"]
|
|
92
|
+
}, null, 2);
|
|
93
|
+
default:
|
|
94
|
+
return "{}";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Process } from '../../shared/schemas/process.schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* Calculates if a process is ready for implementation (executable).
|
|
4
|
+
*
|
|
5
|
+
* Logic: A process is executable if all three fundamental nodes
|
|
6
|
+
* (Input, Flow, Output) are present, marked as 'done' or 'valid',
|
|
7
|
+
* AND contain valid findings data.
|
|
8
|
+
*
|
|
9
|
+
* @param process - The process state to evaluate
|
|
10
|
+
* @returns boolean indicating if the process is executable
|
|
11
|
+
*/
|
|
12
|
+
export declare function calculateExecutableSignal(process: Process): boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculates if a process is ready for implementation (executable).
|
|
3
|
+
*
|
|
4
|
+
* Logic: A process is executable if all three fundamental nodes
|
|
5
|
+
* (Input, Flow, Output) are present, marked as 'done' or 'valid',
|
|
6
|
+
* AND contain valid findings data.
|
|
7
|
+
*
|
|
8
|
+
* @param process - The process state to evaluate
|
|
9
|
+
* @returns boolean indicating if the process is executable
|
|
10
|
+
*/
|
|
11
|
+
export function calculateExecutableSignal(process) {
|
|
12
|
+
const { input, flow, output } = process.nodes;
|
|
13
|
+
// Rule 1: All three fundamental nodes must be 'done' or 'valid'
|
|
14
|
+
// Rule 2: All three fundamental nodes must have findings data attached
|
|
15
|
+
const isInputReady = (input.status === 'done' || input.status === 'valid') && !!input.data;
|
|
16
|
+
const isFlowReady = (flow.status === 'done' || flow.status === 'valid') && !!flow.data;
|
|
17
|
+
const isOutputReady = (output.status === 'done' || output.status === 'valid') && !!output.data;
|
|
18
|
+
return isInputReady && isFlowReady && isOutputReady;
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Process } from '../../shared/schemas/process.schema.js';
|
|
2
|
+
export declare class YamlEngine {
|
|
3
|
+
private psmDir;
|
|
4
|
+
private onSaveCallback?;
|
|
5
|
+
constructor(psmDir?: string);
|
|
6
|
+
onSave(callback: (process: Process) => void): void;
|
|
7
|
+
init(): void;
|
|
8
|
+
saveProcess(process: Process): Promise<void>;
|
|
9
|
+
private notifyUpdate;
|
|
10
|
+
loadProcess(id: string): Promise<Process | null>;
|
|
11
|
+
private ensureDir;
|
|
12
|
+
listProcesses(): Promise<string[]>;
|
|
13
|
+
getAllProcesses(): Promise<Process[]>;
|
|
14
|
+
updateNode(processId: string, nodeType: string, content: string, isManual?: boolean): Promise<Process>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import { ProcessSchema } from '../../shared/schemas/process.schema.js';
|
|
5
|
+
import { calculateExecutableSignal } from './signal-calculator.js';
|
|
6
|
+
export class YamlEngine {
|
|
7
|
+
psmDir;
|
|
8
|
+
onSaveCallback;
|
|
9
|
+
constructor(psmDir = '.psm') {
|
|
10
|
+
this.psmDir = psmDir;
|
|
11
|
+
}
|
|
12
|
+
onSave(callback) {
|
|
13
|
+
this.onSaveCallback = callback;
|
|
14
|
+
}
|
|
15
|
+
init() {
|
|
16
|
+
if (!fs.existsSync(this.psmDir)) {
|
|
17
|
+
fs.mkdirSync(this.psmDir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async saveProcess(process) {
|
|
21
|
+
const validated = ProcessSchema.parse(process);
|
|
22
|
+
// Story 4.1: Recalculate executable signal before saving
|
|
23
|
+
validated.is_executable = calculateExecutableSignal(validated);
|
|
24
|
+
// NFR6: Prevent Path Traversal by sanitizing ID and validating it doesn't contain path separators
|
|
25
|
+
const safeId = path.basename(validated.id);
|
|
26
|
+
if (safeId !== validated.id || validated.id.includes('..')) {
|
|
27
|
+
throw new Error(`Invalid process ID: ${validated.id}`);
|
|
28
|
+
}
|
|
29
|
+
const filePath = path.join(this.psmDir, `${safeId}.yaml`);
|
|
30
|
+
const lockPath = `${filePath}.lock`;
|
|
31
|
+
this.ensureDir();
|
|
32
|
+
// Robust file-based locking with staleness detection
|
|
33
|
+
const lockTimeout = 5000; // 5 seconds
|
|
34
|
+
const retryInterval = 100;
|
|
35
|
+
const maxAttempts = 50;
|
|
36
|
+
let attempts = 0;
|
|
37
|
+
let locked = false;
|
|
38
|
+
while (attempts < maxAttempts) {
|
|
39
|
+
try {
|
|
40
|
+
// Atomic creation of lock file
|
|
41
|
+
fs.writeFileSync(lockPath, global.process.pid.toString(), { flag: 'wx' });
|
|
42
|
+
locked = true;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (err.code === 'EEXIST') {
|
|
47
|
+
// Check if lock is stale
|
|
48
|
+
try {
|
|
49
|
+
const stats = fs.statSync(lockPath);
|
|
50
|
+
const age = Date.now() - stats.mtimeMs;
|
|
51
|
+
if (age > lockTimeout) {
|
|
52
|
+
try {
|
|
53
|
+
fs.unlinkSync(lockPath);
|
|
54
|
+
continue; // Retry immediately after clearing stale lock
|
|
55
|
+
}
|
|
56
|
+
catch (unlinkErr) {
|
|
57
|
+
// Ignore error if another process already deleted it
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (statErr) {
|
|
62
|
+
if (statErr.code === 'ENOENT') {
|
|
63
|
+
continue; // Lock was deleted by another process, retry acquisition
|
|
64
|
+
}
|
|
65
|
+
throw statErr;
|
|
66
|
+
}
|
|
67
|
+
await new Promise(resolve => setTimeout(resolve, retryInterval));
|
|
68
|
+
attempts++;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!locked) {
|
|
76
|
+
throw new Error(`Could not acquire lock for ${filePath} after ${maxAttempts} attempts`);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const yamlContent = YAML.stringify(validated);
|
|
80
|
+
fs.writeFileSync(filePath, yamlContent, 'utf8');
|
|
81
|
+
// Notify (to be integrated with IPC Hub later)
|
|
82
|
+
this.notifyUpdate(validated);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
if (locked && fs.existsSync(lockPath)) {
|
|
86
|
+
try {
|
|
87
|
+
fs.unlinkSync(lockPath);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// Ignore unlink errors in finally
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
notifyUpdate(updatedProcess) {
|
|
96
|
+
if (this.onSaveCallback) {
|
|
97
|
+
this.onSaveCallback(updatedProcess);
|
|
98
|
+
}
|
|
99
|
+
if (global.process.env.DEBUG) {
|
|
100
|
+
console.error(`[YamlEngine] State updated for ${updatedProcess.id}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async loadProcess(id) {
|
|
104
|
+
// NFR6: Prevent Path Traversal
|
|
105
|
+
const safeId = path.basename(id);
|
|
106
|
+
if (safeId !== id || id.includes('..')) {
|
|
107
|
+
throw new Error(`Invalid process ID: ${id}`);
|
|
108
|
+
}
|
|
109
|
+
const filePath = path.join(this.psmDir, `${safeId}.yaml`);
|
|
110
|
+
if (!fs.existsSync(filePath)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const yamlContent = fs.readFileSync(filePath, 'utf8');
|
|
114
|
+
const data = YAML.parse(yamlContent);
|
|
115
|
+
return ProcessSchema.parse(data);
|
|
116
|
+
}
|
|
117
|
+
ensureDir() {
|
|
118
|
+
if (!fs.existsSync(this.psmDir)) {
|
|
119
|
+
fs.mkdirSync(this.psmDir, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async listProcesses() {
|
|
123
|
+
if (!fs.existsSync(this.psmDir)) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const files = fs.readdirSync(this.psmDir);
|
|
127
|
+
return files
|
|
128
|
+
.filter(file => file.endsWith('.yaml'))
|
|
129
|
+
.map(file => file.replace('.yaml', ''));
|
|
130
|
+
}
|
|
131
|
+
async getAllProcesses() {
|
|
132
|
+
const ids = await this.listProcesses();
|
|
133
|
+
const processPromises = ids.map(id => this.loadProcess(id));
|
|
134
|
+
const results = await Promise.all(processPromises);
|
|
135
|
+
return results.filter((p) => p !== null);
|
|
136
|
+
}
|
|
137
|
+
async updateNode(processId, nodeType, content, isManual = false) {
|
|
138
|
+
const process = await this.loadProcess(processId);
|
|
139
|
+
if (!process) {
|
|
140
|
+
throw new Error(`Process not found: ${processId}`);
|
|
141
|
+
}
|
|
142
|
+
// Map node type
|
|
143
|
+
let targetKey;
|
|
144
|
+
if (nodeType === 'entry' || nodeType === 'input')
|
|
145
|
+
targetKey = 'input';
|
|
146
|
+
else if (nodeType === 'flow')
|
|
147
|
+
targetKey = 'flow';
|
|
148
|
+
else if (nodeType === 'exit' || nodeType === 'output')
|
|
149
|
+
targetKey = 'output';
|
|
150
|
+
else
|
|
151
|
+
throw new Error(`Invalid node type: ${nodeType}`);
|
|
152
|
+
// Update node content
|
|
153
|
+
process.nodes[targetKey].content = content;
|
|
154
|
+
process.nodes[targetKey].lastUpdated = new Date().toISOString();
|
|
155
|
+
if (isManual) {
|
|
156
|
+
process.nodes[targetKey].manuallyModified = true;
|
|
157
|
+
}
|
|
158
|
+
// Re-validate and save
|
|
159
|
+
await this.saveProcess(process);
|
|
160
|
+
return process;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { streamSSE } from "hono/streaming";
|
|
5
|
+
import { ProxyClient } from "./infra/proxy-client.js";
|
|
6
|
+
import { IPCMessageSchema } from "../shared/schemas/process.schema.js";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
// Resolve root directory reliably (works in both dev and compiled mode)
|
|
12
|
+
function findProjectRoot(startDir) {
|
|
13
|
+
let current = startDir;
|
|
14
|
+
while (current !== path.dirname(current)) {
|
|
15
|
+
if (fs.existsSync(path.join(current, "gui")) || fs.existsSync(path.join(current, "package.json"))) {
|
|
16
|
+
return current;
|
|
17
|
+
}
|
|
18
|
+
current = path.dirname(current);
|
|
19
|
+
}
|
|
20
|
+
return startDir;
|
|
21
|
+
}
|
|
22
|
+
const rootDir = findProjectRoot(__dirname);
|
|
23
|
+
const staticRoot = path.join(rootDir, "gui/dist");
|
|
24
|
+
const absoluteStaticRoot = path.resolve(staticRoot);
|
|
25
|
+
const app = new Hono();
|
|
26
|
+
let proxyClient = null;
|
|
27
|
+
const sseControllers = new Set();
|
|
28
|
+
// Tracking for IPC request-response
|
|
29
|
+
const pendingRequests = new Map();
|
|
30
|
+
/**
|
|
31
|
+
* Setup IPC error handlers
|
|
32
|
+
*/
|
|
33
|
+
function setupIpcErrorHandlers() {
|
|
34
|
+
process.on('error', (err) => {
|
|
35
|
+
if (err.code === 'ERR_IPC_CHANNEL_CLOSED') {
|
|
36
|
+
console.error('[DEBUG] [GUI-WORKER] IPC channel closed.');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.error(`[DEBUG] [GUI-WORKER] Process error: ${err.message}`);
|
|
40
|
+
});
|
|
41
|
+
process.on('disconnect', () => {
|
|
42
|
+
console.error('[DEBUG] [GUI-WORKER] Disconnected from parent, exiting.');
|
|
43
|
+
process.exit(0);
|
|
44
|
+
});
|
|
45
|
+
process.on('message', (msg) => {
|
|
46
|
+
// Handle responses from Master
|
|
47
|
+
if (msg && msg.correlationId && pendingRequests.has(msg.correlationId)) {
|
|
48
|
+
const { resolve } = pendingRequests.get(msg.correlationId);
|
|
49
|
+
pendingRequests.delete(msg.correlationId);
|
|
50
|
+
resolve(msg);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (msg && msg.type === 'GET_HEALTH') {
|
|
54
|
+
const memory = process.memoryUsage();
|
|
55
|
+
process.send({
|
|
56
|
+
type: 'HEALTH_RESPONSE',
|
|
57
|
+
correlationId: msg.correlationId,
|
|
58
|
+
data: {
|
|
59
|
+
status: 'ok',
|
|
60
|
+
uptime: process.uptime(),
|
|
61
|
+
memory: {
|
|
62
|
+
rss: memory.rss,
|
|
63
|
+
heapUsed: memory.heapUsed,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
timestamp: new Date().toISOString()
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (msg && (msg.type === 'STATE_UPDATED' || msg.type === 'INITIAL_STATE')) {
|
|
71
|
+
const typeLabel = msg.type === 'STATE_UPDATED' ? `update for ${msg.data.id}` : `initial state (${msg.data.processes.length} items)`;
|
|
72
|
+
for (const controller of sseControllers) {
|
|
73
|
+
try {
|
|
74
|
+
controller.writeSSE({
|
|
75
|
+
data: JSON.stringify(msg),
|
|
76
|
+
event: "message",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
sseControllers.delete(controller);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// Routes API
|
|
87
|
+
app.get("/proxy/health", (c) => {
|
|
88
|
+
const memory = process.memoryUsage();
|
|
89
|
+
return c.json({
|
|
90
|
+
status: "ok",
|
|
91
|
+
proxy: proxyClient ? proxyClient.getStatus() : (process.env.PROXY_URL ? "error" : "fallback"),
|
|
92
|
+
uptime: process.uptime(),
|
|
93
|
+
memory: {
|
|
94
|
+
rss: memory.rss,
|
|
95
|
+
heapUsed: memory.heapUsed,
|
|
96
|
+
},
|
|
97
|
+
ipc: process.connected ? "connected" : "disconnected",
|
|
98
|
+
timestamp: new Date().toISOString(),
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
app.get("/api/events", (c) => {
|
|
102
|
+
return streamSSE(c, async (stream) => {
|
|
103
|
+
sseControllers.add(stream);
|
|
104
|
+
stream.onAbort(() => {
|
|
105
|
+
sseControllers.delete(stream);
|
|
106
|
+
});
|
|
107
|
+
if (process.send) {
|
|
108
|
+
process.send({ type: "GET_INITIAL_STATE", timestamp: new Date().toISOString() });
|
|
109
|
+
}
|
|
110
|
+
await stream.writeSSE({ data: "connected", event: "ping" });
|
|
111
|
+
while (true) {
|
|
112
|
+
await stream.sleep(30000);
|
|
113
|
+
try {
|
|
114
|
+
await stream.writeSSE({ data: "ping", event: "ping" });
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
sseControllers.delete(stream);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
app.post("/api/update-node", async (c) => {
|
|
124
|
+
const body = await c.req.json();
|
|
125
|
+
const { processName, nodeType, content } = body;
|
|
126
|
+
if (!processName || !nodeType || content === undefined) {
|
|
127
|
+
return c.json({ error: "Missing required fields" }, 400);
|
|
128
|
+
}
|
|
129
|
+
if (!process.send) {
|
|
130
|
+
return c.json({ error: "IPC not available" }, 500);
|
|
131
|
+
}
|
|
132
|
+
const correlationId = Math.random().toString(36).substring(7);
|
|
133
|
+
const message = {
|
|
134
|
+
type: "UPDATE_NODE",
|
|
135
|
+
correlationId,
|
|
136
|
+
data: { processName, nodeType, content },
|
|
137
|
+
timestamp: new Date().toISOString()
|
|
138
|
+
};
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
const timeout = setTimeout(() => {
|
|
141
|
+
if (pendingRequests.has(correlationId)) {
|
|
142
|
+
pendingRequests.delete(correlationId);
|
|
143
|
+
resolve(c.json({ error: "Request timed out" }, 504));
|
|
144
|
+
}
|
|
145
|
+
}, 5000);
|
|
146
|
+
pendingRequests.set(correlationId, {
|
|
147
|
+
resolve: (msg) => {
|
|
148
|
+
clearTimeout(timeout);
|
|
149
|
+
if (msg.error)
|
|
150
|
+
resolve(c.json({ error: msg.error }, 400));
|
|
151
|
+
else
|
|
152
|
+
resolve(c.json({ success: true, process: msg.data?.process }));
|
|
153
|
+
},
|
|
154
|
+
reject: (err) => {
|
|
155
|
+
clearTimeout(timeout);
|
|
156
|
+
resolve(c.json({ error: err.message }, 500));
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
process.send(message);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
// Service Statique
|
|
163
|
+
app.use("/assets/*", serveStatic({ root: path.relative(process.cwd(), absoluteStaticRoot) }));
|
|
164
|
+
// Fallback SPA
|
|
165
|
+
app.get("/*", (c, next) => {
|
|
166
|
+
const urlPath = c.req.path;
|
|
167
|
+
if (urlPath.startsWith('/api') || urlPath.startsWith('/proxy') || urlPath.startsWith('/assets')) {
|
|
168
|
+
return next();
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const indexPath = path.join(absoluteStaticRoot, "index.html");
|
|
172
|
+
if (fs.existsSync(indexPath)) {
|
|
173
|
+
const html = fs.readFileSync(indexPath, "utf8");
|
|
174
|
+
return c.html(html);
|
|
175
|
+
}
|
|
176
|
+
return c.text("GUI Build non trouvé à " + absoluteStaticRoot, 404);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
return next();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
async function startServer() {
|
|
183
|
+
const fallbackPort = parseInt(process.env.FIXED_GUI_PORT || "4000", 10);
|
|
184
|
+
const proxyUrl = process.env.PROXY_URL;
|
|
185
|
+
const appPath = process.env.APP_PATH || "/psm-gui";
|
|
186
|
+
const appName = process.env.APP_NAME || "Project State Manager GUI";
|
|
187
|
+
let finalPort = fallbackPort;
|
|
188
|
+
let publicUrl = `http://localhost:${fallbackPort}`;
|
|
189
|
+
let isProxied = false;
|
|
190
|
+
if (proxyUrl) {
|
|
191
|
+
proxyClient = new ProxyClient(proxyUrl);
|
|
192
|
+
const result = await proxyClient.register({ path: appPath, name: appName }, fallbackPort);
|
|
193
|
+
isProxied = result.success;
|
|
194
|
+
finalPort = result.port;
|
|
195
|
+
if (isProxied)
|
|
196
|
+
publicUrl = result.url || `${proxyUrl}${appPath}`;
|
|
197
|
+
}
|
|
198
|
+
const cleanup = async () => {
|
|
199
|
+
if (proxyClient)
|
|
200
|
+
await proxyClient.unregister();
|
|
201
|
+
process.exit(0);
|
|
202
|
+
};
|
|
203
|
+
process.on("SIGTERM", cleanup);
|
|
204
|
+
process.on("SIGINT", cleanup);
|
|
205
|
+
setupIpcErrorHandlers();
|
|
206
|
+
serve({ fetch: app.fetch, port: finalPort }, (info) => {
|
|
207
|
+
console.error(`[DEBUG] [GUI-WORKER] Dashboard server listening on port ${info.port}`);
|
|
208
|
+
if (process.send) {
|
|
209
|
+
const message = {
|
|
210
|
+
type: "READY",
|
|
211
|
+
data: { port: info.port, isProxied, url: publicUrl },
|
|
212
|
+
timestamp: new Date().toISOString()
|
|
213
|
+
};
|
|
214
|
+
process.send(IPCMessageSchema.parse(message));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
startServer().catch((err) => {
|
|
219
|
+
console.error(`[DEBUG] [GUI-WORKER] Failed to start: ${err.message}`);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
});
|