web-agent-bridge 2.5.0 → 2.6.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/README.md +14 -0
- package/package.json +1 -1
- package/public/.well-known/agent-tools.json +180 -0
- package/sdk/package.json +1 -1
- package/server/adapters/index.js +520 -0
- package/server/index.js +4 -0
- package/server/migrations/004_agent_os.sql +158 -0
- package/server/observability/failure-analysis.js +337 -0
- package/server/registry/certification.js +271 -0
- package/server/routes/runtime.js +411 -0
- package/server/runtime/replay.js +264 -0
- package/server/runtime/session-engine.js +293 -0
- package/server/security/index.js +13 -0
package/server/routes/runtime.js
CHANGED
|
@@ -19,11 +19,86 @@ const router = express.Router();
|
|
|
19
19
|
const protocol = require('../protocol');
|
|
20
20
|
const { runtime, bus } = require('../runtime');
|
|
21
21
|
const { logger, tracer, metrics } = require('../observability');
|
|
22
|
+
const { failureAnalyzer } = require('../observability/failure-analysis');
|
|
22
23
|
const { identity, signer, isolation } = require('../security');
|
|
23
24
|
const { agentManager, policyEngine } = require('../control-plane');
|
|
24
25
|
const { executor } = require('../data-plane');
|
|
25
26
|
const { llm } = require('../llm');
|
|
26
27
|
const { commandRegistry, siteRegistry, templateRegistry } = require('../registry');
|
|
28
|
+
const { certificationEngine } = require('../registry/certification');
|
|
29
|
+
const { adapterManager, mcpAdapter, restAdapter, browserAdapter } = require('../adapters');
|
|
30
|
+
const { replayEngine } = require('../runtime/replay');
|
|
31
|
+
const { sessionEngine } = require('../runtime/session-engine');
|
|
32
|
+
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
34
|
+
// AUTH MIDDLEWARE
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Authenticate requests via API key or session token.
|
|
39
|
+
* Public endpoints (protocol info, agent registration, health) bypass auth.
|
|
40
|
+
*/
|
|
41
|
+
const PUBLIC_PATHS = [
|
|
42
|
+
'/protocol',
|
|
43
|
+
'/agents/register',
|
|
44
|
+
'/agents/authenticate',
|
|
45
|
+
'/observability/health',
|
|
46
|
+
'/llm/models',
|
|
47
|
+
'/llm/status',
|
|
48
|
+
'/registry/commands',
|
|
49
|
+
'/registry/sites',
|
|
50
|
+
'/registry/templates',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function authMiddleware(req, res, next) {
|
|
54
|
+
// Allow public GET endpoints
|
|
55
|
+
const matchesPublic = PUBLIC_PATHS.some(p =>
|
|
56
|
+
req.path === p || (req.method === 'GET' && req.path.startsWith(p))
|
|
57
|
+
);
|
|
58
|
+
if (matchesPublic) return next();
|
|
59
|
+
|
|
60
|
+
// Check session token
|
|
61
|
+
const authHeader = req.headers['authorization'];
|
|
62
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
63
|
+
const token = authHeader.slice(7);
|
|
64
|
+
const session = identity.validateSession(token);
|
|
65
|
+
if (session) {
|
|
66
|
+
req.agentId = session.agentId;
|
|
67
|
+
req.session = session;
|
|
68
|
+
return next();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check API key
|
|
73
|
+
const apiKey = req.headers['x-wab-key'];
|
|
74
|
+
if (apiKey) {
|
|
75
|
+
const ip = req.ip || req.connection?.remoteAddress;
|
|
76
|
+
const session = identity.authenticate(apiKey, ip);
|
|
77
|
+
if (session) {
|
|
78
|
+
req.agentId = session.agentId;
|
|
79
|
+
req.session = session;
|
|
80
|
+
return next();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check agent ID header (for internal/trusted calls)
|
|
85
|
+
const agentHeader = req.headers['x-wab-agent'];
|
|
86
|
+
if (agentHeader) {
|
|
87
|
+
const agent = identity.getAgent(agentHeader);
|
|
88
|
+
if (agent && agent.status === 'active') {
|
|
89
|
+
req.agentId = agentHeader;
|
|
90
|
+
return next();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// No auth on non-mutation GET requests (read-only)
|
|
95
|
+
if (req.method === 'GET') return next();
|
|
96
|
+
|
|
97
|
+
metrics.increment('auth.rejected');
|
|
98
|
+
return res.status(401).json({ error: 'Authentication required. Provide X-WAB-Key or Authorization: Bearer <token>' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
router.use(authMiddleware);
|
|
27
102
|
|
|
28
103
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
29
104
|
// PROTOCOL ENDPOINTS
|
|
@@ -443,6 +518,11 @@ router.get('/observability/health', (req, res) => {
|
|
|
443
518
|
};
|
|
444
519
|
health.executor = executor.getStats();
|
|
445
520
|
health.llm = llm.getStatus();
|
|
521
|
+
health.adapters = adapterManager.getStats();
|
|
522
|
+
health.replay = replayEngine.getStats();
|
|
523
|
+
health.sessions = sessionEngine.getStats();
|
|
524
|
+
health.failures = failureAnalyzer.getStats();
|
|
525
|
+
health.certification = certificationEngine.getStats();
|
|
446
526
|
res.json(health);
|
|
447
527
|
});
|
|
448
528
|
|
|
@@ -722,4 +802,335 @@ protocolHandler.handle('wab.commerce.compare', async (payload) => {
|
|
|
722
802
|
});
|
|
723
803
|
});
|
|
724
804
|
|
|
805
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
806
|
+
// ADAPTERS
|
|
807
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* List adapters
|
|
811
|
+
*/
|
|
812
|
+
router.get('/adapters', (req, res) => {
|
|
813
|
+
res.json({ adapters: adapterManager.list() });
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Adapter stats
|
|
818
|
+
*/
|
|
819
|
+
router.get('/adapters/stats', (req, res) => {
|
|
820
|
+
res.json(adapterManager.getStats());
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* MCP: list tools
|
|
825
|
+
*/
|
|
826
|
+
router.get('/adapters/mcp/tools', (req, res) => {
|
|
827
|
+
const commands = protocol.schema.listCommands();
|
|
828
|
+
res.json(mcpAdapter.handleListTools(commands));
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* MCP: call tool
|
|
833
|
+
*/
|
|
834
|
+
router.post('/adapters/mcp/call', async (req, res) => {
|
|
835
|
+
try {
|
|
836
|
+
const result = await mcpAdapter.handleCallTool(req.body, async (wapReq) => {
|
|
837
|
+
const request = protocol.createRequest(wapReq.command, wapReq.payload);
|
|
838
|
+
return protocolHandler.process(request);
|
|
839
|
+
});
|
|
840
|
+
res.json(result);
|
|
841
|
+
} catch (err) {
|
|
842
|
+
res.status(500).json({ error: err.message });
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* REST adapter: register endpoint
|
|
848
|
+
*/
|
|
849
|
+
router.post('/adapters/rest/endpoints', (req, res) => {
|
|
850
|
+
try {
|
|
851
|
+
const endpoint = restAdapter.registerEndpoint(req.body.id, req.body);
|
|
852
|
+
res.json(endpoint);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
res.status(400).json({ error: err.message });
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* REST adapter: list endpoints
|
|
860
|
+
*/
|
|
861
|
+
router.get('/adapters/rest/endpoints', (req, res) => {
|
|
862
|
+
res.json({ endpoints: restAdapter.listEndpoints() });
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* REST adapter: execute
|
|
867
|
+
*/
|
|
868
|
+
router.post('/adapters/rest/execute', async (req, res) => {
|
|
869
|
+
try {
|
|
870
|
+
const result = await restAdapter.execute(req.body.endpoint, req.body.params);
|
|
871
|
+
res.json(result);
|
|
872
|
+
} catch (err) {
|
|
873
|
+
res.status(500).json({ error: err.message });
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Browser adapter: list semantic mappings
|
|
879
|
+
*/
|
|
880
|
+
router.get('/adapters/browser/mappings', (req, res) => {
|
|
881
|
+
res.json({ mappings: browserAdapter.listMappings() });
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Browser adapter: resolve semantic action
|
|
886
|
+
*/
|
|
887
|
+
router.post('/adapters/browser/resolve', (req, res) => {
|
|
888
|
+
const { domain, action, params } = req.body;
|
|
889
|
+
const plan = browserAdapter.fromWAP({ domain, action, params });
|
|
890
|
+
if (!plan) return res.status(404).json({ error: 'No mapping for this semantic action' });
|
|
891
|
+
res.json(plan);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Browser adapter: register mapping
|
|
896
|
+
*/
|
|
897
|
+
router.post('/adapters/browser/mappings', (req, res) => {
|
|
898
|
+
const { domainAction, plan } = req.body;
|
|
899
|
+
if (!domainAction || !plan) return res.status(400).json({ error: 'domainAction and plan required' });
|
|
900
|
+
browserAdapter.registerMapping(domainAction, plan);
|
|
901
|
+
res.json({ success: true });
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
905
|
+
// REPLAY ENGINE
|
|
906
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* List recordings
|
|
910
|
+
*/
|
|
911
|
+
router.get('/replay/recordings', (req, res) => {
|
|
912
|
+
res.json({ recordings: replayEngine.listRecordings(parseInt(req.query.limit) || 50) });
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Get recording
|
|
917
|
+
*/
|
|
918
|
+
router.get('/replay/recordings/:taskId', (req, res) => {
|
|
919
|
+
const rec = replayEngine.getRecording(req.params.taskId);
|
|
920
|
+
if (!rec) return res.status(404).json({ error: 'Recording not found' });
|
|
921
|
+
res.json(rec);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Replay a task
|
|
926
|
+
*/
|
|
927
|
+
router.post('/replay/:taskId', async (req, res) => {
|
|
928
|
+
try {
|
|
929
|
+
const result = await replayEngine.replay(req.params.taskId, {
|
|
930
|
+
verify: req.body.verify !== false,
|
|
931
|
+
continueOnMismatch: !!req.body.continueOnMismatch,
|
|
932
|
+
});
|
|
933
|
+
res.json(result);
|
|
934
|
+
} catch (err) {
|
|
935
|
+
res.status(400).json({ error: err.message });
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Diff two recordings
|
|
941
|
+
*/
|
|
942
|
+
router.get('/replay/diff/:taskId1/:taskId2', (req, res) => {
|
|
943
|
+
const diff = replayEngine.diff(req.params.taskId1, req.params.taskId2);
|
|
944
|
+
if (!diff) return res.status(404).json({ error: 'One or both recordings not found' });
|
|
945
|
+
res.json(diff);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Replay stats
|
|
950
|
+
*/
|
|
951
|
+
router.get('/replay/stats', (req, res) => {
|
|
952
|
+
res.json(replayEngine.getStats());
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
956
|
+
// SESSION ENGINE
|
|
957
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Create browser session
|
|
961
|
+
*/
|
|
962
|
+
router.post('/sessions', (req, res) => {
|
|
963
|
+
const session = sessionEngine.create(req.body);
|
|
964
|
+
res.json(session);
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* List sessions
|
|
969
|
+
*/
|
|
970
|
+
router.get('/sessions', (req, res) => {
|
|
971
|
+
const sessions = sessionEngine.list({
|
|
972
|
+
agentId: req.query.agentId,
|
|
973
|
+
siteId: req.query.siteId,
|
|
974
|
+
state: req.query.state,
|
|
975
|
+
}, parseInt(req.query.limit) || 50);
|
|
976
|
+
res.json({ sessions, total: sessions.length });
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Get session
|
|
981
|
+
*/
|
|
982
|
+
router.get('/sessions/:sessionId', (req, res) => {
|
|
983
|
+
const session = sessionEngine.get(req.params.sessionId);
|
|
984
|
+
if (!session) return res.status(404).json({ error: 'Session not found or expired' });
|
|
985
|
+
res.json(session);
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Export session
|
|
990
|
+
*/
|
|
991
|
+
router.get('/sessions/:sessionId/export', (req, res) => {
|
|
992
|
+
const data = sessionEngine.export(req.params.sessionId);
|
|
993
|
+
if (!data) return res.status(404).json({ error: 'Session not found' });
|
|
994
|
+
res.json(data);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Import session
|
|
999
|
+
*/
|
|
1000
|
+
router.post('/sessions/import', (req, res) => {
|
|
1001
|
+
const session = sessionEngine.import(req.body);
|
|
1002
|
+
res.json(session);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Set cookies
|
|
1007
|
+
*/
|
|
1008
|
+
router.post('/sessions/:sessionId/cookies', (req, res) => {
|
|
1009
|
+
sessionEngine.setCookies(req.params.sessionId, req.body.cookies || []);
|
|
1010
|
+
res.json({ success: true });
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Get cookies
|
|
1015
|
+
*/
|
|
1016
|
+
router.get('/sessions/:sessionId/cookies', (req, res) => {
|
|
1017
|
+
const cookies = sessionEngine.getCookies(req.params.sessionId, req.query.domain);
|
|
1018
|
+
res.json({ cookies });
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Set storage
|
|
1023
|
+
*/
|
|
1024
|
+
router.post('/sessions/:sessionId/storage', (req, res) => {
|
|
1025
|
+
const { key, value, type } = req.body;
|
|
1026
|
+
sessionEngine.setStorage(req.params.sessionId, key, value, type);
|
|
1027
|
+
res.json({ success: true });
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Destroy session
|
|
1032
|
+
*/
|
|
1033
|
+
router.delete('/sessions/:sessionId', (req, res) => {
|
|
1034
|
+
sessionEngine.destroy(req.params.sessionId);
|
|
1035
|
+
res.json({ success: true });
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1039
|
+
// FAILURE ANALYSIS
|
|
1040
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Query failures
|
|
1044
|
+
*/
|
|
1045
|
+
router.get('/failures', (req, res) => {
|
|
1046
|
+
const failures = failureAnalyzer.query({
|
|
1047
|
+
classification: req.query.classification,
|
|
1048
|
+
severity: req.query.severity,
|
|
1049
|
+
agentId: req.query.agentId,
|
|
1050
|
+
taskId: req.query.taskId,
|
|
1051
|
+
retryable: req.query.retryable === 'true' ? true : req.query.retryable === 'false' ? false : undefined,
|
|
1052
|
+
since: parseInt(req.query.since) || undefined,
|
|
1053
|
+
}, parseInt(req.query.limit) || 50);
|
|
1054
|
+
res.json({ failures, total: failures.length });
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Get failure
|
|
1059
|
+
*/
|
|
1060
|
+
router.get('/failures/:failureId', (req, res) => {
|
|
1061
|
+
const failure = failureAnalyzer.getFailure(req.params.failureId);
|
|
1062
|
+
if (!failure) return res.status(404).json({ error: 'Failure not found' });
|
|
1063
|
+
res.json(failure);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Get failure patterns
|
|
1068
|
+
*/
|
|
1069
|
+
router.get('/failures/analysis/patterns', (req, res) => {
|
|
1070
|
+
res.json({ patterns: failureAnalyzer.getPatterns() });
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Get failure summary
|
|
1075
|
+
*/
|
|
1076
|
+
router.get('/failures/analysis/summary', (req, res) => {
|
|
1077
|
+
res.json(failureAnalyzer.getSummary(parseInt(req.query.since) || 0));
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Classify a failure manually
|
|
1082
|
+
*/
|
|
1083
|
+
router.post('/failures/classify', (req, res) => {
|
|
1084
|
+
const { error, context } = req.body;
|
|
1085
|
+
if (!error) return res.status(400).json({ error: 'error object required' });
|
|
1086
|
+
const classification = failureAnalyzer.classify(error, context || {});
|
|
1087
|
+
res.json(classification);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1091
|
+
// CERTIFICATION
|
|
1092
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Verify a site
|
|
1096
|
+
*/
|
|
1097
|
+
router.post('/certification/verify', async (req, res) => {
|
|
1098
|
+
try {
|
|
1099
|
+
const { domain, probeData } = req.body;
|
|
1100
|
+
if (!domain) return res.status(400).json({ error: 'domain required' });
|
|
1101
|
+
const result = await certificationEngine.verify(domain, probeData || {});
|
|
1102
|
+
res.json(result);
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
res.status(500).json({ error: err.message });
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Get certificate
|
|
1110
|
+
*/
|
|
1111
|
+
router.get('/certification/:domain', (req, res) => {
|
|
1112
|
+
const cert = certificationEngine.getCertificate(req.params.domain);
|
|
1113
|
+
if (!cert) return res.status(404).json({ error: 'No active certificate for this domain' });
|
|
1114
|
+
res.json(cert);
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* List certificates
|
|
1119
|
+
*/
|
|
1120
|
+
router.get('/certification', (req, res) => {
|
|
1121
|
+
const certs = certificationEngine.listCertificates({
|
|
1122
|
+
level: req.query.level,
|
|
1123
|
+
minScore: parseInt(req.query.minScore) || undefined,
|
|
1124
|
+
}, parseInt(req.query.limit) || 50);
|
|
1125
|
+
res.json({ certificates: certs, total: certs.length });
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Revoke certificate
|
|
1130
|
+
*/
|
|
1131
|
+
router.delete('/certification/:domain', (req, res) => {
|
|
1132
|
+
certificationEngine.revoke(req.params.domain);
|
|
1133
|
+
res.json({ success: true });
|
|
1134
|
+
});
|
|
1135
|
+
|
|
725
1136
|
module.exports = router;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic Replay Engine
|
|
5
|
+
*
|
|
6
|
+
* Records all task inputs/outputs/side-effects for deterministic replay.
|
|
7
|
+
* Enables debugging, testing, and verification of agent workflows.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { bus } = require('../runtime/event-bus');
|
|
12
|
+
|
|
13
|
+
class ReplayEngine {
|
|
14
|
+
constructor() {
|
|
15
|
+
this._recordings = new Map(); // taskId → Recording
|
|
16
|
+
this._maxRecordings = 5000;
|
|
17
|
+
this._recordingEnabled = true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Start recording a task execution
|
|
22
|
+
*/
|
|
23
|
+
startRecording(taskId, input) {
|
|
24
|
+
if (!this._recordingEnabled) return null;
|
|
25
|
+
|
|
26
|
+
const recording = {
|
|
27
|
+
id: `rec_${crypto.randomBytes(8).toString('hex')}`,
|
|
28
|
+
taskId,
|
|
29
|
+
input: this._deepClone(input),
|
|
30
|
+
steps: [],
|
|
31
|
+
sideEffects: [],
|
|
32
|
+
startedAt: Date.now(),
|
|
33
|
+
completedAt: null,
|
|
34
|
+
output: null,
|
|
35
|
+
error: null,
|
|
36
|
+
checksum: null,
|
|
37
|
+
replayable: true,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
this._recordings.set(taskId, recording);
|
|
41
|
+
this._evict();
|
|
42
|
+
return recording.id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Record a step in the execution
|
|
47
|
+
*/
|
|
48
|
+
recordStep(taskId, step) {
|
|
49
|
+
const rec = this._recordings.get(taskId);
|
|
50
|
+
if (!rec) return;
|
|
51
|
+
|
|
52
|
+
rec.steps.push({
|
|
53
|
+
index: rec.steps.length,
|
|
54
|
+
type: step.type,
|
|
55
|
+
action: step.action,
|
|
56
|
+
input: this._deepClone(step.input),
|
|
57
|
+
output: this._deepClone(step.output),
|
|
58
|
+
duration: step.duration || 0,
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Record a side effect (network call, DOM mutation, storage write, etc.)
|
|
65
|
+
*/
|
|
66
|
+
recordSideEffect(taskId, effect) {
|
|
67
|
+
const rec = this._recordings.get(taskId);
|
|
68
|
+
if (!rec) return;
|
|
69
|
+
|
|
70
|
+
rec.sideEffects.push({
|
|
71
|
+
index: rec.sideEffects.length,
|
|
72
|
+
type: effect.type, // 'network', 'dom', 'storage', 'event'
|
|
73
|
+
target: effect.target,
|
|
74
|
+
data: this._deepClone(effect.data),
|
|
75
|
+
timestamp: Date.now(),
|
|
76
|
+
reversible: effect.reversible !== false,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Complete a recording
|
|
82
|
+
*/
|
|
83
|
+
completeRecording(taskId, output, error = null) {
|
|
84
|
+
const rec = this._recordings.get(taskId);
|
|
85
|
+
if (!rec) return null;
|
|
86
|
+
|
|
87
|
+
rec.completedAt = Date.now();
|
|
88
|
+
rec.output = this._deepClone(output);
|
|
89
|
+
rec.error = error ? { message: error.message, code: error.code } : null;
|
|
90
|
+
rec.checksum = this._computeChecksum(rec);
|
|
91
|
+
|
|
92
|
+
bus.emit('replay.recording.complete', { taskId, recordingId: rec.id, steps: rec.steps.length });
|
|
93
|
+
return rec;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Replay a recorded task
|
|
98
|
+
* Returns the replay plan (steps to execute) with recorded outputs for verification
|
|
99
|
+
*/
|
|
100
|
+
async replay(taskId, options = {}) {
|
|
101
|
+
const rec = this._recordings.get(taskId);
|
|
102
|
+
if (!rec) throw new Error(`No recording found for task ${taskId}`);
|
|
103
|
+
if (!rec.completedAt) throw new Error('Recording not yet complete');
|
|
104
|
+
|
|
105
|
+
const replayResult = {
|
|
106
|
+
recordingId: rec.id,
|
|
107
|
+
taskId,
|
|
108
|
+
originalInput: rec.input,
|
|
109
|
+
originalOutput: rec.output,
|
|
110
|
+
steps: [],
|
|
111
|
+
match: true,
|
|
112
|
+
verificationMode: options.verify !== false,
|
|
113
|
+
replayedAt: Date.now(),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// In verification mode, run each step and compare outputs
|
|
117
|
+
if (options.executor && options.verify !== false) {
|
|
118
|
+
for (const step of rec.steps) {
|
|
119
|
+
try {
|
|
120
|
+
const replayOutput = await options.executor(step);
|
|
121
|
+
const outputMatch = this._deepEqual(step.output, replayOutput);
|
|
122
|
+
|
|
123
|
+
replayResult.steps.push({
|
|
124
|
+
index: step.index,
|
|
125
|
+
action: step.action,
|
|
126
|
+
originalOutput: step.output,
|
|
127
|
+
replayOutput,
|
|
128
|
+
match: outputMatch,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!outputMatch) {
|
|
132
|
+
replayResult.match = false;
|
|
133
|
+
if (!options.continueOnMismatch) break;
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
replayResult.steps.push({
|
|
137
|
+
index: step.index,
|
|
138
|
+
action: step.action,
|
|
139
|
+
error: err.message,
|
|
140
|
+
match: false,
|
|
141
|
+
});
|
|
142
|
+
replayResult.match = false;
|
|
143
|
+
if (!options.continueOnMismatch) break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
// Dry-run mode: just return the recorded steps
|
|
148
|
+
replayResult.steps = rec.steps.map(s => ({
|
|
149
|
+
index: s.index,
|
|
150
|
+
action: s.action,
|
|
151
|
+
input: s.input,
|
|
152
|
+
output: s.output,
|
|
153
|
+
duration: s.duration,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
bus.emit('replay.completed', { taskId, match: replayResult.match });
|
|
158
|
+
return replayResult;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get recording
|
|
163
|
+
*/
|
|
164
|
+
getRecording(taskId) {
|
|
165
|
+
return this._recordings.get(taskId) || null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* List recordings
|
|
170
|
+
*/
|
|
171
|
+
listRecordings(limit = 50) {
|
|
172
|
+
const all = Array.from(this._recordings.values());
|
|
173
|
+
return all.slice(-limit).reverse().map(r => ({
|
|
174
|
+
id: r.id,
|
|
175
|
+
taskId: r.taskId,
|
|
176
|
+
steps: r.steps.length,
|
|
177
|
+
sideEffects: r.sideEffects.length,
|
|
178
|
+
startedAt: r.startedAt,
|
|
179
|
+
completedAt: r.completedAt,
|
|
180
|
+
hasError: !!r.error,
|
|
181
|
+
checksum: r.checksum,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Compare two recordings
|
|
187
|
+
*/
|
|
188
|
+
diff(taskId1, taskId2) {
|
|
189
|
+
const r1 = this._recordings.get(taskId1);
|
|
190
|
+
const r2 = this._recordings.get(taskId2);
|
|
191
|
+
if (!r1 || !r2) return null;
|
|
192
|
+
|
|
193
|
+
const diffs = [];
|
|
194
|
+
const maxSteps = Math.max(r1.steps.length, r2.steps.length);
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
197
|
+
const s1 = r1.steps[i];
|
|
198
|
+
const s2 = r2.steps[i];
|
|
199
|
+
|
|
200
|
+
if (!s1 || !s2) {
|
|
201
|
+
diffs.push({ index: i, type: 'missing', in: s1 ? 'recording2' : 'recording1' });
|
|
202
|
+
} else if (!this._deepEqual(s1.output, s2.output)) {
|
|
203
|
+
diffs.push({ index: i, type: 'output_mismatch', action: s1.action, output1: s1.output, output2: s2.output });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
match: diffs.length === 0,
|
|
209
|
+
inputMatch: this._deepEqual(r1.input, r2.input),
|
|
210
|
+
outputMatch: this._deepEqual(r1.output, r2.output),
|
|
211
|
+
diffs,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Enable/disable recording
|
|
217
|
+
*/
|
|
218
|
+
setEnabled(enabled) {
|
|
219
|
+
this._recordingEnabled = enabled;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
getStats() {
|
|
223
|
+
return {
|
|
224
|
+
totalRecordings: this._recordings.size,
|
|
225
|
+
enabled: this._recordingEnabled,
|
|
226
|
+
maxRecordings: this._maxRecordings,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Internal ──
|
|
231
|
+
|
|
232
|
+
_computeChecksum(rec) {
|
|
233
|
+
const data = JSON.stringify({
|
|
234
|
+
input: rec.input,
|
|
235
|
+
steps: rec.steps.map(s => ({ action: s.action, output: s.output })),
|
|
236
|
+
output: rec.output,
|
|
237
|
+
});
|
|
238
|
+
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
_deepClone(obj) {
|
|
242
|
+
if (obj === undefined || obj === null) return obj;
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(JSON.stringify(obj));
|
|
245
|
+
} catch {
|
|
246
|
+
return obj;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_deepEqual(a, b) {
|
|
251
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
_evict() {
|
|
255
|
+
if (this._recordings.size <= this._maxRecordings) return;
|
|
256
|
+
const keys = Array.from(this._recordings.keys());
|
|
257
|
+
const toRemove = keys.slice(0, keys.length - this._maxRecordings);
|
|
258
|
+
for (const k of toRemove) this._recordings.delete(k);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const replayEngine = new ReplayEngine();
|
|
263
|
+
|
|
264
|
+
module.exports = { ReplayEngine, replayEngine };
|