wogiflow 1.0.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/.workflow/agents/reviewer.md +81 -0
- package/.workflow/agents/security.md +94 -0
- package/.workflow/agents/story-writer.md +58 -0
- package/.workflow/bridges/base-bridge.js +395 -0
- package/.workflow/bridges/claude-bridge.js +434 -0
- package/.workflow/bridges/index.js +130 -0
- package/.workflow/lib/assumption-detector.js +481 -0
- package/.workflow/lib/config-substitution.js +371 -0
- package/.workflow/lib/failure-categories.js +478 -0
- package/.workflow/state/app-map.md.template +15 -0
- package/.workflow/state/architecture.md.template +24 -0
- package/.workflow/state/component-index.json.template +5 -0
- package/.workflow/state/decisions.md.template +15 -0
- package/.workflow/state/feedback-patterns.md.template +9 -0
- package/.workflow/state/knowledge-sync.json.template +6 -0
- package/.workflow/state/progress.md.template +14 -0
- package/.workflow/state/ready.json.template +7 -0
- package/.workflow/state/request-log.md.template +14 -0
- package/.workflow/state/session-state.json.template +11 -0
- package/.workflow/state/stack.md.template +33 -0
- package/.workflow/state/testing.md.template +36 -0
- package/.workflow/templates/claude-md.hbs +257 -0
- package/.workflow/templates/correction-report.md +67 -0
- package/.workflow/templates/gemini-md.hbs +52 -0
- package/README.md +1802 -0
- package/bin/flow +205 -0
- package/lib/index.js +33 -0
- package/lib/installer.js +467 -0
- package/lib/release-channel.js +269 -0
- package/lib/skill-registry.js +526 -0
- package/lib/upgrader.js +401 -0
- package/lib/utils.js +305 -0
- package/package.json +64 -0
- package/scripts/flow +985 -0
- package/scripts/flow-adaptive-learning.js +1259 -0
- package/scripts/flow-aggregate.js +488 -0
- package/scripts/flow-archive +133 -0
- package/scripts/flow-auto-context.js +1015 -0
- package/scripts/flow-auto-learn.js +615 -0
- package/scripts/flow-bridge.js +223 -0
- package/scripts/flow-browser-suggest.js +316 -0
- package/scripts/flow-bug.js +247 -0
- package/scripts/flow-cascade.js +711 -0
- package/scripts/flow-changelog +85 -0
- package/scripts/flow-checkpoint.js +483 -0
- package/scripts/flow-cli.js +403 -0
- package/scripts/flow-code-intelligence.js +760 -0
- package/scripts/flow-complexity.js +502 -0
- package/scripts/flow-config-set.js +152 -0
- package/scripts/flow-constants.js +157 -0
- package/scripts/flow-context +152 -0
- package/scripts/flow-context-init.js +482 -0
- package/scripts/flow-context-monitor.js +384 -0
- package/scripts/flow-context-scoring.js +886 -0
- package/scripts/flow-correct.js +458 -0
- package/scripts/flow-damage-control.js +985 -0
- package/scripts/flow-deps +101 -0
- package/scripts/flow-diff.js +700 -0
- package/scripts/flow-done +151 -0
- package/scripts/flow-done.js +489 -0
- package/scripts/flow-durable-session.js +1541 -0
- package/scripts/flow-entropy-monitor.js +345 -0
- package/scripts/flow-export-profile +349 -0
- package/scripts/flow-export-scanner.js +1046 -0
- package/scripts/flow-figma-confirm.js +400 -0
- package/scripts/flow-figma-extract.js +496 -0
- package/scripts/flow-figma-generate.js +683 -0
- package/scripts/flow-figma-index.js +909 -0
- package/scripts/flow-figma-match.js +617 -0
- package/scripts/flow-figma-mcp-server.js +518 -0
- package/scripts/flow-figma-pipeline.js +414 -0
- package/scripts/flow-file-ops.js +301 -0
- package/scripts/flow-gate-confidence.js +825 -0
- package/scripts/flow-guided-edit.js +659 -0
- package/scripts/flow-health +185 -0
- package/scripts/flow-health.js +413 -0
- package/scripts/flow-hooks.js +556 -0
- package/scripts/flow-http-client.js +249 -0
- package/scripts/flow-hybrid-detect.js +167 -0
- package/scripts/flow-hybrid-interactive.js +591 -0
- package/scripts/flow-hybrid-test.js +152 -0
- package/scripts/flow-import-profile +439 -0
- package/scripts/flow-init +253 -0
- package/scripts/flow-instruction-richness.js +827 -0
- package/scripts/flow-jira-integration.js +579 -0
- package/scripts/flow-knowledge-router.js +522 -0
- package/scripts/flow-knowledge-sync.js +589 -0
- package/scripts/flow-linear-integration.js +631 -0
- package/scripts/flow-links.js +774 -0
- package/scripts/flow-log-manager.js +559 -0
- package/scripts/flow-loop-enforcer.js +1246 -0
- package/scripts/flow-loop-retry-learning.js +630 -0
- package/scripts/flow-lsp.js +923 -0
- package/scripts/flow-map-index +348 -0
- package/scripts/flow-map-sync +201 -0
- package/scripts/flow-memory-blocks.js +668 -0
- package/scripts/flow-memory-compactor.js +350 -0
- package/scripts/flow-memory-db.js +1110 -0
- package/scripts/flow-memory-sync.js +484 -0
- package/scripts/flow-metrics.js +353 -0
- package/scripts/flow-migrate-ids.js +370 -0
- package/scripts/flow-model-adapter.js +802 -0
- package/scripts/flow-model-router.js +884 -0
- package/scripts/flow-models.js +1231 -0
- package/scripts/flow-morning.js +517 -0
- package/scripts/flow-multi-approach.js +660 -0
- package/scripts/flow-new-feature +86 -0
- package/scripts/flow-onboard +1042 -0
- package/scripts/flow-orchestrate-llm.js +459 -0
- package/scripts/flow-orchestrate.js +3592 -0
- package/scripts/flow-output.js +123 -0
- package/scripts/flow-parallel-detector.js +399 -0
- package/scripts/flow-parallel-dispatch.js +987 -0
- package/scripts/flow-parallel.js +428 -0
- package/scripts/flow-pattern-enforcer.js +600 -0
- package/scripts/flow-prd-manager.js +282 -0
- package/scripts/flow-progress.js +323 -0
- package/scripts/flow-project-analyzer.js +975 -0
- package/scripts/flow-prompt-composer.js +487 -0
- package/scripts/flow-providers.js +1381 -0
- package/scripts/flow-queue.js +308 -0
- package/scripts/flow-ready +82 -0
- package/scripts/flow-ready.js +189 -0
- package/scripts/flow-regression.js +396 -0
- package/scripts/flow-response-parser.js +450 -0
- package/scripts/flow-resume.js +284 -0
- package/scripts/flow-rules-sync.js +439 -0
- package/scripts/flow-run-trace.js +718 -0
- package/scripts/flow-safety.js +587 -0
- package/scripts/flow-search +104 -0
- package/scripts/flow-security.js +481 -0
- package/scripts/flow-session-end +106 -0
- package/scripts/flow-session-end.js +437 -0
- package/scripts/flow-session-state.js +671 -0
- package/scripts/flow-setup-hooks +216 -0
- package/scripts/flow-setup-hooks.js +377 -0
- package/scripts/flow-skill-create.js +329 -0
- package/scripts/flow-skill-creator.js +572 -0
- package/scripts/flow-skill-generator.js +1046 -0
- package/scripts/flow-skill-learn.js +880 -0
- package/scripts/flow-skill-matcher.js +578 -0
- package/scripts/flow-spec-generator.js +820 -0
- package/scripts/flow-stack-wizard.js +895 -0
- package/scripts/flow-standup +162 -0
- package/scripts/flow-start +74 -0
- package/scripts/flow-start.js +235 -0
- package/scripts/flow-status +110 -0
- package/scripts/flow-status.js +301 -0
- package/scripts/flow-step-browser.js +83 -0
- package/scripts/flow-step-changelog.js +217 -0
- package/scripts/flow-step-comments.js +306 -0
- package/scripts/flow-step-complexity.js +234 -0
- package/scripts/flow-step-coverage.js +218 -0
- package/scripts/flow-step-knowledge.js +193 -0
- package/scripts/flow-step-pr-tests.js +364 -0
- package/scripts/flow-step-regression.js +89 -0
- package/scripts/flow-step-review.js +516 -0
- package/scripts/flow-step-security.js +162 -0
- package/scripts/flow-step-silent-failures.js +290 -0
- package/scripts/flow-step-simplifier.js +346 -0
- package/scripts/flow-story +105 -0
- package/scripts/flow-story.js +500 -0
- package/scripts/flow-suspend.js +252 -0
- package/scripts/flow-sync-daemon.js +654 -0
- package/scripts/flow-task-analyzer.js +606 -0
- package/scripts/flow-team-dashboard.js +748 -0
- package/scripts/flow-team-sync.js +752 -0
- package/scripts/flow-team.js +977 -0
- package/scripts/flow-tech-options.js +528 -0
- package/scripts/flow-templates.js +812 -0
- package/scripts/flow-tiered-learning.js +728 -0
- package/scripts/flow-trace +204 -0
- package/scripts/flow-transcript-chunking.js +1106 -0
- package/scripts/flow-transcript-digest.js +7918 -0
- package/scripts/flow-transcript-language.js +465 -0
- package/scripts/flow-transcript-parsing.js +1085 -0
- package/scripts/flow-transcript-stories.js +2194 -0
- package/scripts/flow-update-map +224 -0
- package/scripts/flow-utils.js +2242 -0
- package/scripts/flow-verification.js +644 -0
- package/scripts/flow-verify.js +1177 -0
- package/scripts/flow-voice-input.js +638 -0
- package/scripts/flow-watch +168 -0
- package/scripts/flow-workflow-steps.js +521 -0
- package/scripts/flow-workflow.js +1029 -0
- package/scripts/flow-worktree.js +489 -0
- package/scripts/hooks/adapters/base-adapter.js +102 -0
- package/scripts/hooks/adapters/claude-code.js +359 -0
- package/scripts/hooks/adapters/index.js +79 -0
- package/scripts/hooks/core/component-check.js +341 -0
- package/scripts/hooks/core/index.js +35 -0
- package/scripts/hooks/core/loop-check.js +241 -0
- package/scripts/hooks/core/session-context.js +294 -0
- package/scripts/hooks/core/task-gate.js +177 -0
- package/scripts/hooks/core/validation.js +230 -0
- package/scripts/hooks/entry/claude-code/post-tool-use.js +65 -0
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +89 -0
- package/scripts/hooks/entry/claude-code/session-end.js +87 -0
- package/scripts/hooks/entry/claude-code/session-start.js +46 -0
- package/scripts/hooks/entry/claude-code/stop.js +43 -0
- package/scripts/postinstall.js +139 -0
- package/templates/browser-test-flow.json +56 -0
- package/templates/bug-report.md +43 -0
- package/templates/component-detail.md +42 -0
- package/templates/component.stories.tsx +49 -0
- package/templates/context/constraints.md +83 -0
- package/templates/context/conventions.md +177 -0
- package/templates/context/stack.md +60 -0
- package/templates/correction-report.md +90 -0
- package/templates/feature-proposal.md +35 -0
- package/templates/hybrid/_base.md +254 -0
- package/templates/hybrid/_patterns.md +45 -0
- package/templates/hybrid/create-component.md +127 -0
- package/templates/hybrid/create-file.md +56 -0
- package/templates/hybrid/create-hook.md +145 -0
- package/templates/hybrid/create-service.md +70 -0
- package/templates/hybrid/fix-bug.md +33 -0
- package/templates/hybrid/modify-file.md +55 -0
- package/templates/story.md +68 -0
- package/templates/task.json +56 -0
- package/templates/trace.md +69 -0
|
@@ -0,0 +1,1541 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Durable Session Manager
|
|
5
|
+
*
|
|
6
|
+
* Unified step tracking that survives crashes/context resets.
|
|
7
|
+
* Replaces both loop-session.json and hybrid-session.json with
|
|
8
|
+
* a single durable-session.json.
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Step-based execution for all task types
|
|
12
|
+
* - Resume from exact step after crash
|
|
13
|
+
* - Skip completed steps automatically
|
|
14
|
+
* - Suspension support (time, poll, manual, file-based)
|
|
15
|
+
* - Backward compatibility with existing APIs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { execSync } = require('child_process');
|
|
21
|
+
const { getConfig, getProjectRoot, MAX_SESSION_HISTORY, withLock, writeJson, ensureDir } = require('./flow-utils');
|
|
22
|
+
const { validateCommand } = require('./flow-workflow');
|
|
23
|
+
const { validatePathWithinProject } = require('./flow-security');
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Constants
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
const SESSION_VERSION = '2.0';
|
|
30
|
+
const SESSION_FILE = 'durable-session.json';
|
|
31
|
+
const HISTORY_FILE = 'durable-history.json';
|
|
32
|
+
const LEGACY_HYBRID_FILE = 'hybrid-session.json'; // Deprecated, cleaned up on new session
|
|
33
|
+
// MAX_HISTORY imported from flow-utils as MAX_SESSION_HISTORY
|
|
34
|
+
|
|
35
|
+
const STEP_STATUS = {
|
|
36
|
+
PENDING: 'pending',
|
|
37
|
+
IN_PROGRESS: 'in_progress',
|
|
38
|
+
COMPLETED: 'completed',
|
|
39
|
+
FAILED: 'failed',
|
|
40
|
+
SKIPPED: 'skipped',
|
|
41
|
+
SUSPENDED: 'suspended'
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const STEP_TYPE = {
|
|
45
|
+
ACCEPTANCE_CRITERIA: 'acceptance-criteria',
|
|
46
|
+
HYBRID_EXECUTION: 'hybrid-execution',
|
|
47
|
+
QUALITY_GATE: 'quality-gate',
|
|
48
|
+
CUSTOM: 'custom'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const SUSPENSION_TYPE = {
|
|
52
|
+
CI_CD: 'ci-cd',
|
|
53
|
+
SCHEDULED: 'scheduled',
|
|
54
|
+
RATE_LIMIT: 'rate-limit',
|
|
55
|
+
HUMAN_REVIEW: 'human-review',
|
|
56
|
+
EXTERNAL_EVENT: 'external-event',
|
|
57
|
+
LONG_RUNNING: 'long-running'
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const RESUME_CONDITION = {
|
|
61
|
+
TIME: 'time',
|
|
62
|
+
POLL: 'poll',
|
|
63
|
+
MANUAL: 'manual',
|
|
64
|
+
FILE: 'file'
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// Path Helpers
|
|
69
|
+
// ============================================================================
|
|
70
|
+
|
|
71
|
+
function getSessionPath() {
|
|
72
|
+
const projectRoot = getProjectRoot();
|
|
73
|
+
return path.join(projectRoot, '.workflow', 'state', SESSION_FILE);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getHistoryPath() {
|
|
77
|
+
const projectRoot = getProjectRoot();
|
|
78
|
+
return path.join(projectRoot, '.workflow', 'state', HISTORY_FILE);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getLegacyHybridPath() {
|
|
82
|
+
const projectRoot = getProjectRoot();
|
|
83
|
+
return path.join(projectRoot, '.workflow', 'state', LEGACY_HYBRID_FILE);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Clean up legacy hybrid-session.json if it exists
|
|
88
|
+
* Called when creating a new durable session to prevent orphaned state
|
|
89
|
+
*/
|
|
90
|
+
function cleanupLegacyHybridSession() {
|
|
91
|
+
const legacyPath = getLegacyHybridPath();
|
|
92
|
+
if (fs.existsSync(legacyPath)) {
|
|
93
|
+
try {
|
|
94
|
+
fs.unlinkSync(legacyPath);
|
|
95
|
+
console.log('[Migration] Removed legacy hybrid-session.json - now using durable-session.json');
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// Non-fatal - just log and continue
|
|
98
|
+
console.warn(`[Warning] Could not remove legacy hybrid-session.json: ${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// Core Session Management
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a new durable session
|
|
109
|
+
* @param {string} taskId - Task identifier (e.g., "TASK-042")
|
|
110
|
+
* @param {string} taskType - Type: "task", "loop", "bulk"
|
|
111
|
+
* @param {Array} steps - Array of step definitions
|
|
112
|
+
* @returns {Object} Created session
|
|
113
|
+
*/
|
|
114
|
+
function createDurableSession(taskId, taskType, steps = []) {
|
|
115
|
+
const sessionPath = getSessionPath();
|
|
116
|
+
|
|
117
|
+
// Check if session already exists for this task
|
|
118
|
+
const existing = loadDurableSession();
|
|
119
|
+
if (existing && existing.taskId === taskId) {
|
|
120
|
+
// Return existing session for resume
|
|
121
|
+
return existing;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Clean up legacy hybrid-session.json if present (migration to v2.0)
|
|
125
|
+
cleanupLegacyHybridSession();
|
|
126
|
+
|
|
127
|
+
const session = createSessionObject(taskId, taskType, steps);
|
|
128
|
+
saveDurableSession(session);
|
|
129
|
+
return session;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a new durable session with file locking (async version)
|
|
134
|
+
* SECURITY: Prevents race conditions when multiple processes try to create sessions
|
|
135
|
+
*
|
|
136
|
+
* @param {string} taskId - Task identifier
|
|
137
|
+
* @param {string} taskType - Type: "task", "loop", "bulk"
|
|
138
|
+
* @param {Array} steps - Array of step definitions
|
|
139
|
+
* @returns {Promise<Object>} Created or existing session
|
|
140
|
+
*/
|
|
141
|
+
async function createDurableSessionAsync(taskId, taskType, steps = []) {
|
|
142
|
+
const sessionPath = getSessionPath();
|
|
143
|
+
|
|
144
|
+
return withLock(sessionPath, () => {
|
|
145
|
+
// Check if session already exists for this task (inside lock)
|
|
146
|
+
const existing = loadDurableSession();
|
|
147
|
+
if (existing && existing.taskId === taskId) {
|
|
148
|
+
// Return existing session for resume
|
|
149
|
+
return existing;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Clean up legacy hybrid-session.json if present (migration to v2.0)
|
|
153
|
+
cleanupLegacyHybridSession();
|
|
154
|
+
|
|
155
|
+
const session = createSessionObject(taskId, taskType, steps);
|
|
156
|
+
saveDurableSession(session);
|
|
157
|
+
return session;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a session object (internal helper)
|
|
163
|
+
*/
|
|
164
|
+
function createSessionObject(taskId, taskType, steps = []) {
|
|
165
|
+
return {
|
|
166
|
+
version: SESSION_VERSION,
|
|
167
|
+
sessionId: `sess-${Date.now()}`,
|
|
168
|
+
taskId,
|
|
169
|
+
taskType,
|
|
170
|
+
startedAt: new Date().toISOString(),
|
|
171
|
+
updatedAt: new Date().toISOString(),
|
|
172
|
+
|
|
173
|
+
// Cache config once to avoid repeated access in loop
|
|
174
|
+
steps: (() => {
|
|
175
|
+
const config = getConfig();
|
|
176
|
+
const defaultMaxAttempts = config.durableSteps?.defaultMaxAttempts || 5;
|
|
177
|
+
return steps.map((step, index) => normalizeStep(step, index, defaultMaxAttempts));
|
|
178
|
+
})(),
|
|
179
|
+
|
|
180
|
+
execution: {
|
|
181
|
+
currentStepIndex: 0,
|
|
182
|
+
iteration: 0,
|
|
183
|
+
totalRetries: 0,
|
|
184
|
+
checkpointsCreated: 0
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
suspension: null,
|
|
188
|
+
|
|
189
|
+
metrics: {
|
|
190
|
+
stepsCompleted: 0,
|
|
191
|
+
stepsFailed: 0,
|
|
192
|
+
stepsSkipped: 0,
|
|
193
|
+
tokensSaved: 0
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
// Task queue for multi-task execution (v2.1)
|
|
197
|
+
taskQueue: {
|
|
198
|
+
enabled: false,
|
|
199
|
+
tasks: [], // Array of task IDs to process
|
|
200
|
+
currentIndex: 0, // Current position in queue
|
|
201
|
+
source: null, // How queue was created: "bulk", "natural", "manual"
|
|
202
|
+
queuedAt: null,
|
|
203
|
+
completedTasks: [] // Track completed task IDs
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Normalize a step to the standard schema
|
|
210
|
+
* @param {string|Object} step - Step definition
|
|
211
|
+
* @param {number} index - Step index
|
|
212
|
+
* @param {number} defaultMaxAttempts - Default max attempts from config (passed to avoid repeated getConfig calls)
|
|
213
|
+
*/
|
|
214
|
+
function normalizeStep(step, index, defaultMaxAttempts = 5) {
|
|
215
|
+
// Handle string input (backward compat with acceptance criteria)
|
|
216
|
+
if (typeof step === 'string') {
|
|
217
|
+
return {
|
|
218
|
+
id: `step-${String(index + 1).padStart(3, '0')}`,
|
|
219
|
+
type: STEP_TYPE.ACCEPTANCE_CRITERIA,
|
|
220
|
+
description: step,
|
|
221
|
+
status: STEP_STATUS.PENDING,
|
|
222
|
+
priority: index + 1,
|
|
223
|
+
startedAt: null,
|
|
224
|
+
completedAt: null,
|
|
225
|
+
attempts: 0,
|
|
226
|
+
maxAttempts: defaultMaxAttempts,
|
|
227
|
+
lastAttemptAt: null,
|
|
228
|
+
verificationProof: null,
|
|
229
|
+
error: null,
|
|
230
|
+
metadata: {}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Handle object input
|
|
235
|
+
return {
|
|
236
|
+
id: step.id || `step-${String(index + 1).padStart(3, '0')}`,
|
|
237
|
+
type: step.type || STEP_TYPE.CUSTOM,
|
|
238
|
+
description: step.description || step.action || '',
|
|
239
|
+
status: step.status || STEP_STATUS.PENDING,
|
|
240
|
+
priority: step.priority || index + 1,
|
|
241
|
+
startedAt: step.startedAt || null,
|
|
242
|
+
completedAt: step.completedAt || null,
|
|
243
|
+
attempts: step.attempts || 0,
|
|
244
|
+
maxAttempts: step.maxAttempts || defaultMaxAttempts,
|
|
245
|
+
lastAttemptAt: step.lastAttemptAt || null,
|
|
246
|
+
verificationProof: step.verificationProof || null,
|
|
247
|
+
error: step.error || null,
|
|
248
|
+
metadata: step.metadata || {}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Load the current durable session
|
|
254
|
+
* @returns {Object|null} Session or null if none exists
|
|
255
|
+
*/
|
|
256
|
+
function loadDurableSession() {
|
|
257
|
+
const sessionPath = getSessionPath();
|
|
258
|
+
|
|
259
|
+
if (!fs.existsSync(sessionPath)) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const content = fs.readFileSync(sessionPath, 'utf-8');
|
|
265
|
+
const session = JSON.parse(content);
|
|
266
|
+
|
|
267
|
+
// Validate session structure
|
|
268
|
+
if (!session || typeof session !== 'object') {
|
|
269
|
+
if (process.env.DEBUG) {
|
|
270
|
+
console.warn('[DEBUG] Invalid session: not an object');
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Ensure steps array exists
|
|
276
|
+
if (!Array.isArray(session.steps)) {
|
|
277
|
+
session.steps = [];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Ensure execution object exists
|
|
281
|
+
if (!session.execution || typeof session.execution !== 'object') {
|
|
282
|
+
session.execution = {
|
|
283
|
+
currentStepIndex: 0,
|
|
284
|
+
iteration: 0,
|
|
285
|
+
totalRetries: 0,
|
|
286
|
+
checkpointsCreated: 0
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Ensure metrics object exists
|
|
291
|
+
if (!session.metrics || typeof session.metrics !== 'object') {
|
|
292
|
+
session.metrics = {
|
|
293
|
+
stepsCompleted: 0,
|
|
294
|
+
stepsFailed: 0,
|
|
295
|
+
stepsSkipped: 0,
|
|
296
|
+
tokensSaved: 0
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return session;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (process.env.DEBUG) {
|
|
303
|
+
console.warn(`[DEBUG] Could not parse durable session: ${error.message}`);
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Save the durable session
|
|
311
|
+
* @param {Object} session - Session to save
|
|
312
|
+
*/
|
|
313
|
+
function saveDurableSession(session) {
|
|
314
|
+
const sessionPath = getSessionPath();
|
|
315
|
+
|
|
316
|
+
// Ensure directory exists
|
|
317
|
+
ensureDir(path.dirname(sessionPath));
|
|
318
|
+
|
|
319
|
+
session.updatedAt = new Date().toISOString();
|
|
320
|
+
// Use atomic writeJson to prevent data corruption on concurrent access
|
|
321
|
+
writeJson(sessionPath, session);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Archive the session to history and remove active session
|
|
326
|
+
* @param {string} status - Final status: "completed", "failed", "cancelled"
|
|
327
|
+
* @returns {Object|null} Archived session
|
|
328
|
+
*/
|
|
329
|
+
function archiveDurableSession(status = 'completed') {
|
|
330
|
+
const session = loadDurableSession();
|
|
331
|
+
if (!session) return null;
|
|
332
|
+
|
|
333
|
+
// Finalize session
|
|
334
|
+
session.status = status;
|
|
335
|
+
session.endedAt = new Date().toISOString();
|
|
336
|
+
|
|
337
|
+
// Calculate final metrics
|
|
338
|
+
session.metrics.stepsCompleted = session.steps.filter(s => s.status === STEP_STATUS.COMPLETED).length;
|
|
339
|
+
session.metrics.stepsFailed = session.steps.filter(s => s.status === STEP_STATUS.FAILED).length;
|
|
340
|
+
session.metrics.stepsSkipped = session.steps.filter(s => s.status === STEP_STATUS.SKIPPED).length;
|
|
341
|
+
|
|
342
|
+
// Load and update history
|
|
343
|
+
const historyPath = getHistoryPath();
|
|
344
|
+
let history = [];
|
|
345
|
+
|
|
346
|
+
if (fs.existsSync(historyPath)) {
|
|
347
|
+
try {
|
|
348
|
+
history = JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
|
|
349
|
+
} catch {
|
|
350
|
+
history = [];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
history.push(session);
|
|
355
|
+
|
|
356
|
+
// Keep only last N sessions
|
|
357
|
+
if (history.length > MAX_SESSION_HISTORY) {
|
|
358
|
+
history = history.slice(-MAX_SESSION_HISTORY);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Use atomic writeJson for history file
|
|
362
|
+
writeJson(historyPath, history);
|
|
363
|
+
|
|
364
|
+
// Remove active session
|
|
365
|
+
const sessionPath = getSessionPath();
|
|
366
|
+
if (fs.existsSync(sessionPath)) {
|
|
367
|
+
fs.unlinkSync(sessionPath);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Trigger loop retry learning analysis for completed sessions
|
|
371
|
+
const config = getConfig();
|
|
372
|
+
if (config.skillLearning?.learnFromLoopRetries !== false && status === 'completed') {
|
|
373
|
+
try {
|
|
374
|
+
const { analyzeCompletedSession } = require('./flow-loop-retry-learning');
|
|
375
|
+
analyzeCompletedSession(session);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
// Silent fail - learning is non-critical
|
|
378
|
+
if (process.env.DEBUG) {
|
|
379
|
+
console.warn('[DEBUG] Loop retry learning failed:', err.message);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return session;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ============================================================================
|
|
388
|
+
// Step Management
|
|
389
|
+
// ============================================================================
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Get the next pending step
|
|
393
|
+
* @param {Object} session - Current session
|
|
394
|
+
* @returns {Object|null} Next pending step or null
|
|
395
|
+
*/
|
|
396
|
+
function getNextPendingStep(session) {
|
|
397
|
+
if (!session) session = loadDurableSession();
|
|
398
|
+
if (!session) return null;
|
|
399
|
+
|
|
400
|
+
return session.steps.find(s =>
|
|
401
|
+
s.status === STEP_STATUS.PENDING ||
|
|
402
|
+
s.status === STEP_STATUS.FAILED
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Get step by ID
|
|
408
|
+
* @param {string} stepId - Step ID
|
|
409
|
+
* @returns {Object|null} Step or null
|
|
410
|
+
*/
|
|
411
|
+
function getStep(stepId) {
|
|
412
|
+
const session = loadDurableSession();
|
|
413
|
+
if (!session) return null;
|
|
414
|
+
|
|
415
|
+
return session.steps.find(s => s.id === stepId);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Mark a step as started (in_progress)
|
|
420
|
+
* @param {string} stepId - Step ID
|
|
421
|
+
* @returns {Object|null} Updated session
|
|
422
|
+
*/
|
|
423
|
+
function markStepStarted(stepId) {
|
|
424
|
+
const session = loadDurableSession();
|
|
425
|
+
if (!session) return null;
|
|
426
|
+
|
|
427
|
+
const step = session.steps.find(s => s.id === stepId);
|
|
428
|
+
if (!step) return null;
|
|
429
|
+
|
|
430
|
+
step.status = STEP_STATUS.IN_PROGRESS;
|
|
431
|
+
step.startedAt = new Date().toISOString();
|
|
432
|
+
step.attempts++;
|
|
433
|
+
step.lastAttemptAt = new Date().toISOString();
|
|
434
|
+
|
|
435
|
+
// Update current step index
|
|
436
|
+
const stepIndex = session.steps.findIndex(s => s.id === stepId);
|
|
437
|
+
session.execution.currentStepIndex = stepIndex;
|
|
438
|
+
|
|
439
|
+
saveDurableSession(session);
|
|
440
|
+
return session;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Mark a step as completed
|
|
445
|
+
* @param {string} stepId - Step ID
|
|
446
|
+
* @param {string|Object} verificationProof - Proof of completion
|
|
447
|
+
* @returns {Object|null} Updated session
|
|
448
|
+
*/
|
|
449
|
+
function markStepCompleted(stepId, verificationProof = null) {
|
|
450
|
+
const session = loadDurableSession();
|
|
451
|
+
if (!session) return null;
|
|
452
|
+
|
|
453
|
+
const step = session.steps.find(s => s.id === stepId);
|
|
454
|
+
if (!step) return null;
|
|
455
|
+
|
|
456
|
+
step.status = STEP_STATUS.COMPLETED;
|
|
457
|
+
step.completedAt = new Date().toISOString();
|
|
458
|
+
step.verificationProof = verificationProof;
|
|
459
|
+
step.error = null;
|
|
460
|
+
|
|
461
|
+
session.metrics.stepsCompleted++;
|
|
462
|
+
|
|
463
|
+
saveDurableSession(session);
|
|
464
|
+
return session;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Mark a step as failed
|
|
469
|
+
* @param {string} stepId - Step ID
|
|
470
|
+
* @param {string|Object} error - Error details
|
|
471
|
+
* @returns {Object|null} Updated session
|
|
472
|
+
*/
|
|
473
|
+
function markStepFailed(stepId, error = null) {
|
|
474
|
+
const session = loadDurableSession();
|
|
475
|
+
if (!session) return null;
|
|
476
|
+
|
|
477
|
+
const step = session.steps.find(s => s.id === stepId);
|
|
478
|
+
if (!step) return null;
|
|
479
|
+
|
|
480
|
+
step.status = STEP_STATUS.FAILED;
|
|
481
|
+
step.error = error;
|
|
482
|
+
|
|
483
|
+
session.metrics.stepsFailed++;
|
|
484
|
+
session.execution.totalRetries++;
|
|
485
|
+
|
|
486
|
+
saveDurableSession(session);
|
|
487
|
+
return session;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Mark a step as skipped
|
|
492
|
+
* @param {string} stepId - Step ID
|
|
493
|
+
* @param {string} reason - Reason for skipping
|
|
494
|
+
* @returns {Object|null} Updated session
|
|
495
|
+
*/
|
|
496
|
+
function markStepSkipped(stepId, reason = null) {
|
|
497
|
+
const session = loadDurableSession();
|
|
498
|
+
if (!session) return null;
|
|
499
|
+
|
|
500
|
+
const step = session.steps.find(s => s.id === stepId);
|
|
501
|
+
if (!step) return null;
|
|
502
|
+
|
|
503
|
+
step.status = STEP_STATUS.SKIPPED;
|
|
504
|
+
step.completedAt = new Date().toISOString();
|
|
505
|
+
step.verificationProof = reason ? `Skipped: ${reason}` : 'Skipped by user';
|
|
506
|
+
|
|
507
|
+
session.metrics.stepsSkipped++;
|
|
508
|
+
|
|
509
|
+
saveDurableSession(session);
|
|
510
|
+
return session;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Add new steps to an existing session
|
|
515
|
+
* @param {Array} newSteps - Steps to add
|
|
516
|
+
* @returns {Object|null} Updated session
|
|
517
|
+
*/
|
|
518
|
+
function addSteps(newSteps) {
|
|
519
|
+
const session = loadDurableSession();
|
|
520
|
+
if (!session) return null;
|
|
521
|
+
|
|
522
|
+
const startIndex = session.steps.length;
|
|
523
|
+
// Cache config once to avoid repeated access in loop
|
|
524
|
+
const config = getConfig();
|
|
525
|
+
const defaultMaxAttempts = config.durableSteps?.defaultMaxAttempts || 5;
|
|
526
|
+
const normalizedSteps = newSteps.map((s, i) => normalizeStep(s, startIndex + i, defaultMaxAttempts));
|
|
527
|
+
|
|
528
|
+
session.steps.push(...normalizedSteps);
|
|
529
|
+
|
|
530
|
+
saveDurableSession(session);
|
|
531
|
+
return session;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ============================================================================
|
|
535
|
+
// Resume Support
|
|
536
|
+
// ============================================================================
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Check if session can be resumed from a specific step
|
|
540
|
+
* @param {Object} session - Session to check
|
|
541
|
+
* @returns {Object} Resume info: { canResume, fromStep, completedCount }
|
|
542
|
+
*/
|
|
543
|
+
function canResumeFromStep(session) {
|
|
544
|
+
if (!session) session = loadDurableSession();
|
|
545
|
+
if (!session) {
|
|
546
|
+
return { canResume: false, reason: 'no-session' };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Check if suspended
|
|
550
|
+
if (session.suspension) {
|
|
551
|
+
const resumeCheck = checkResumeCondition(session.suspension);
|
|
552
|
+
if (!resumeCheck.canResume) {
|
|
553
|
+
return {
|
|
554
|
+
canResume: false,
|
|
555
|
+
reason: 'suspended',
|
|
556
|
+
suspension: session.suspension,
|
|
557
|
+
conditionStatus: resumeCheck
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const completed = session.steps.filter(s => s.status === STEP_STATUS.COMPLETED);
|
|
563
|
+
const pending = session.steps.filter(s =>
|
|
564
|
+
s.status === STEP_STATUS.PENDING ||
|
|
565
|
+
s.status === STEP_STATUS.FAILED ||
|
|
566
|
+
s.status === STEP_STATUS.IN_PROGRESS
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
if (pending.length === 0) {
|
|
570
|
+
return { canResume: false, reason: 'all-complete', completedCount: completed.length };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const nextStep = pending[0];
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
canResume: true,
|
|
577
|
+
fromStep: nextStep,
|
|
578
|
+
completedCount: completed.length,
|
|
579
|
+
totalSteps: session.steps.length,
|
|
580
|
+
pendingCount: pending.length
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Get context for resuming a session
|
|
586
|
+
* @param {Object} session - Session to get context for
|
|
587
|
+
* @returns {Object} Resume context
|
|
588
|
+
*/
|
|
589
|
+
function getResumeContext(session) {
|
|
590
|
+
if (!session) session = loadDurableSession();
|
|
591
|
+
if (!session) return null;
|
|
592
|
+
|
|
593
|
+
const resumeInfo = canResumeFromStep(session);
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
taskId: session.taskId,
|
|
597
|
+
taskType: session.taskType,
|
|
598
|
+
sessionId: session.sessionId,
|
|
599
|
+
startedAt: session.startedAt,
|
|
600
|
+
...resumeInfo,
|
|
601
|
+
iteration: session.execution.iteration,
|
|
602
|
+
retries: session.execution.totalRetries,
|
|
603
|
+
metrics: session.metrics,
|
|
604
|
+
suspension: session.suspension
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Skip all completed steps and return remaining work
|
|
610
|
+
* @param {Object} session - Session to process
|
|
611
|
+
* @returns {Array} Remaining steps to execute
|
|
612
|
+
*/
|
|
613
|
+
function getRemainingSteps(session) {
|
|
614
|
+
if (!session) session = loadDurableSession();
|
|
615
|
+
if (!session) return [];
|
|
616
|
+
|
|
617
|
+
return session.steps.filter(s =>
|
|
618
|
+
s.status === STEP_STATUS.PENDING ||
|
|
619
|
+
s.status === STEP_STATUS.FAILED ||
|
|
620
|
+
s.status === STEP_STATUS.IN_PROGRESS
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ============================================================================
|
|
625
|
+
// Execution Tracking
|
|
626
|
+
// ============================================================================
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Increment iteration counter
|
|
630
|
+
* @returns {Object|null} Updated session
|
|
631
|
+
*/
|
|
632
|
+
function incrementIteration() {
|
|
633
|
+
const session = loadDurableSession();
|
|
634
|
+
if (!session) return null;
|
|
635
|
+
|
|
636
|
+
session.execution.iteration++;
|
|
637
|
+
|
|
638
|
+
saveDurableSession(session);
|
|
639
|
+
return session;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Update tokens saved (for hybrid mode tracking)
|
|
644
|
+
* @param {number} tokens - Tokens saved
|
|
645
|
+
* @returns {Object|null} Updated session
|
|
646
|
+
*/
|
|
647
|
+
function addTokensSaved(tokens) {
|
|
648
|
+
const session = loadDurableSession();
|
|
649
|
+
if (!session) return null;
|
|
650
|
+
|
|
651
|
+
session.metrics.tokensSaved += tokens;
|
|
652
|
+
|
|
653
|
+
saveDurableSession(session);
|
|
654
|
+
return session;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Check if all steps are complete
|
|
659
|
+
* @returns {Object} Completion status
|
|
660
|
+
*/
|
|
661
|
+
function checkCompletion() {
|
|
662
|
+
const session = loadDurableSession();
|
|
663
|
+
if (!session) {
|
|
664
|
+
return { complete: true, reason: 'no-session' };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const config = getConfig();
|
|
668
|
+
|
|
669
|
+
const pending = session.steps.filter(s => s.status === STEP_STATUS.PENDING);
|
|
670
|
+
const failed = session.steps.filter(s => s.status === STEP_STATUS.FAILED);
|
|
671
|
+
const completed = session.steps.filter(s => s.status === STEP_STATUS.COMPLETED);
|
|
672
|
+
const skipped = session.steps.filter(s => s.status === STEP_STATUS.SKIPPED);
|
|
673
|
+
const inProgress = session.steps.filter(s => s.status === STEP_STATUS.IN_PROGRESS);
|
|
674
|
+
const suspended = session.steps.filter(s => s.status === STEP_STATUS.SUSPENDED);
|
|
675
|
+
|
|
676
|
+
// Session is suspended - not complete, waiting for resume
|
|
677
|
+
if (suspended.length > 0) {
|
|
678
|
+
return {
|
|
679
|
+
complete: false,
|
|
680
|
+
suspended: true,
|
|
681
|
+
suspendedSteps: suspended.length,
|
|
682
|
+
reason: 'session-suspended',
|
|
683
|
+
summary: `Session suspended with ${suspended.length} step(s) waiting to resume`
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// All done?
|
|
688
|
+
if (pending.length === 0 && failed.length === 0 && inProgress.length === 0) {
|
|
689
|
+
return {
|
|
690
|
+
complete: true,
|
|
691
|
+
reason: 'all-complete',
|
|
692
|
+
summary: `All ${completed.length} steps completed${skipped.length > 0 ? ` (${skipped.length} skipped)` : ''}`
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Max retries?
|
|
697
|
+
const maxRetries = config.durableSteps?.maxRetries || config.loops?.maxRetries || 5;
|
|
698
|
+
if (session.execution.totalRetries >= maxRetries) {
|
|
699
|
+
return {
|
|
700
|
+
complete: true,
|
|
701
|
+
reason: 'max-retries',
|
|
702
|
+
forced: true,
|
|
703
|
+
summary: `Max retries (${maxRetries}) reached. ${failed.length} steps still failing.`
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Max iterations?
|
|
708
|
+
const maxIterations = config.durableSteps?.maxIterations || config.loops?.maxIterations || 20;
|
|
709
|
+
if (session.execution.iteration >= maxIterations) {
|
|
710
|
+
return {
|
|
711
|
+
complete: true,
|
|
712
|
+
reason: 'max-iterations',
|
|
713
|
+
forced: true,
|
|
714
|
+
summary: `Max iterations (${maxIterations}) reached.`
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// SECURITY: Max duration check to prevent indefinitely running sessions
|
|
719
|
+
const maxDurationMs = (config.durableSteps?.maxDurationMinutes || 120) * 60 * 1000; // Default 2 hours
|
|
720
|
+
const sessionDuration = Date.now() - new Date(session.startedAt).getTime();
|
|
721
|
+
if (sessionDuration >= maxDurationMs) {
|
|
722
|
+
return {
|
|
723
|
+
complete: true,
|
|
724
|
+
reason: 'max-duration',
|
|
725
|
+
forced: true,
|
|
726
|
+
summary: `Max session duration (${config.durableSteps?.maxDurationMinutes || 120} minutes) reached.`
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
complete: false,
|
|
732
|
+
pending: pending.length,
|
|
733
|
+
failed: failed.length,
|
|
734
|
+
inProgress: inProgress.length,
|
|
735
|
+
completed: completed.length,
|
|
736
|
+
skipped: skipped.length,
|
|
737
|
+
suspended: suspended.length
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ============================================================================
|
|
742
|
+
// Suspension Support
|
|
743
|
+
// ============================================================================
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Suspend the current session
|
|
747
|
+
* @param {Object} suspensionConfig - Suspension configuration
|
|
748
|
+
* @returns {Object|null} Updated session
|
|
749
|
+
*/
|
|
750
|
+
function suspendSession(suspensionConfig) {
|
|
751
|
+
const session = loadDurableSession();
|
|
752
|
+
if (!session) return null;
|
|
753
|
+
|
|
754
|
+
// Find current step
|
|
755
|
+
const currentStep = session.steps.find(s => s.status === STEP_STATUS.IN_PROGRESS);
|
|
756
|
+
|
|
757
|
+
session.suspension = {
|
|
758
|
+
type: suspensionConfig.type,
|
|
759
|
+
reason: suspensionConfig.reason || `Suspended: ${suspensionConfig.type}`,
|
|
760
|
+
suspendedAt: new Date().toISOString(),
|
|
761
|
+
suspendedAtStep: currentStep?.id || null,
|
|
762
|
+
resumeCondition: suspensionConfig.resumeCondition,
|
|
763
|
+
notifications: suspensionConfig.notifications || {
|
|
764
|
+
onSuspend: true,
|
|
765
|
+
onResume: true,
|
|
766
|
+
reminderAfterHours: 24
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
// Mark current step as suspended
|
|
771
|
+
if (currentStep) {
|
|
772
|
+
currentStep.status = STEP_STATUS.SUSPENDED;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
saveDurableSession(session);
|
|
776
|
+
return session;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Check if session is suspended
|
|
781
|
+
* @returns {boolean}
|
|
782
|
+
*/
|
|
783
|
+
function isSuspended() {
|
|
784
|
+
const session = loadDurableSession();
|
|
785
|
+
return session?.suspension !== null;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Get suspension status
|
|
790
|
+
* @returns {Object|null} Suspension details or null
|
|
791
|
+
*/
|
|
792
|
+
function getSuspensionStatus() {
|
|
793
|
+
const session = loadDurableSession();
|
|
794
|
+
if (!session || !session.suspension) return null;
|
|
795
|
+
|
|
796
|
+
const resumeCheck = checkResumeCondition(session.suspension);
|
|
797
|
+
|
|
798
|
+
return {
|
|
799
|
+
...session.suspension,
|
|
800
|
+
canResume: resumeCheck.canResume,
|
|
801
|
+
resumeReason: resumeCheck.reason,
|
|
802
|
+
taskId: session.taskId
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Check if resume condition is met
|
|
808
|
+
* @param {Object} suspension - Suspension object
|
|
809
|
+
* @returns {Object} { canResume: boolean, reason: string }
|
|
810
|
+
*/
|
|
811
|
+
function checkResumeCondition(suspension) {
|
|
812
|
+
if (!suspension || !suspension.resumeCondition) {
|
|
813
|
+
return { canResume: true, reason: 'no-condition' };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const condition = suspension.resumeCondition;
|
|
817
|
+
|
|
818
|
+
switch (condition.type) {
|
|
819
|
+
case RESUME_CONDITION.TIME:
|
|
820
|
+
return checkTimeCondition(condition.time);
|
|
821
|
+
|
|
822
|
+
case RESUME_CONDITION.POLL:
|
|
823
|
+
return checkPollCondition(condition.poll);
|
|
824
|
+
|
|
825
|
+
case RESUME_CONDITION.MANUAL:
|
|
826
|
+
return checkManualCondition(condition.manual);
|
|
827
|
+
|
|
828
|
+
case RESUME_CONDITION.FILE:
|
|
829
|
+
return checkFileCondition(condition.file);
|
|
830
|
+
|
|
831
|
+
default:
|
|
832
|
+
return { canResume: false, reason: `Unknown condition type: ${condition.type}` };
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Check time-based resume condition
|
|
838
|
+
*/
|
|
839
|
+
function checkTimeCondition(config) {
|
|
840
|
+
if (!config || !config.resumeAfter) {
|
|
841
|
+
return { canResume: true, reason: 'no-time-set' };
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const resumeTime = new Date(config.resumeAfter);
|
|
845
|
+
const now = new Date();
|
|
846
|
+
|
|
847
|
+
if (now >= resumeTime) {
|
|
848
|
+
return { canResume: true, reason: 'time-elapsed' };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const remaining = Math.ceil((resumeTime - now) / 1000);
|
|
852
|
+
return {
|
|
853
|
+
canResume: false,
|
|
854
|
+
reason: 'waiting-for-time',
|
|
855
|
+
remainingSeconds: remaining,
|
|
856
|
+
resumeAt: config.resumeAfter
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Check poll-based resume condition (e.g., CI/CD)
|
|
862
|
+
*
|
|
863
|
+
* SECURITY: Commands are validated before execution to prevent injection.
|
|
864
|
+
* Only safe commands (no dangerous patterns) are allowed.
|
|
865
|
+
*/
|
|
866
|
+
function checkPollCondition(config) {
|
|
867
|
+
if (!config || !config.command) {
|
|
868
|
+
return { canResume: false, reason: 'no-poll-command' };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// SECURITY: Validate command before execution
|
|
872
|
+
const validation = validateCommand(config.command);
|
|
873
|
+
if (validation.blocked) {
|
|
874
|
+
return {
|
|
875
|
+
canResume: false,
|
|
876
|
+
reason: 'poll-command-blocked',
|
|
877
|
+
error: `SECURITY: ${validation.reason}`
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (!validation.safe) {
|
|
882
|
+
return {
|
|
883
|
+
canResume: false,
|
|
884
|
+
reason: 'poll-command-unsafe',
|
|
885
|
+
error: `SECURITY: Command validation failed - ${validation.reason}`
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
try {
|
|
890
|
+
const result = execSync(config.command, {
|
|
891
|
+
encoding: 'utf-8',
|
|
892
|
+
timeout: 30000,
|
|
893
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
894
|
+
}).trim();
|
|
895
|
+
|
|
896
|
+
if (result === config.expectedValue) {
|
|
897
|
+
return { canResume: true, reason: 'poll-condition-met', value: result };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return {
|
|
901
|
+
canResume: false,
|
|
902
|
+
reason: 'poll-condition-not-met',
|
|
903
|
+
currentValue: result,
|
|
904
|
+
expectedValue: config.expectedValue
|
|
905
|
+
};
|
|
906
|
+
} catch (error) {
|
|
907
|
+
return {
|
|
908
|
+
canResume: false,
|
|
909
|
+
reason: 'poll-command-failed',
|
|
910
|
+
error: error.message
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Check manual approval condition
|
|
917
|
+
*/
|
|
918
|
+
function checkManualCondition(config) {
|
|
919
|
+
if (!config) {
|
|
920
|
+
return { canResume: false, reason: 'no-manual-config' };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (config.approvedAt && config.approvedBy) {
|
|
924
|
+
return { canResume: true, reason: 'manually-approved' };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
return {
|
|
928
|
+
canResume: false,
|
|
929
|
+
reason: 'awaiting-approval',
|
|
930
|
+
prompt: config.prompt
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Check file-based resume condition
|
|
936
|
+
*/
|
|
937
|
+
function checkFileCondition(config) {
|
|
938
|
+
if (!config || !config.watchPath) {
|
|
939
|
+
return { canResume: false, reason: 'no-file-path' };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const projectRoot = getProjectRoot();
|
|
943
|
+
const filePath = path.isAbsolute(config.watchPath)
|
|
944
|
+
? config.watchPath
|
|
945
|
+
: path.join(projectRoot, config.watchPath);
|
|
946
|
+
|
|
947
|
+
// Security: Validate path is within project root to prevent path traversal
|
|
948
|
+
if (!validatePathWithinProject(filePath, projectRoot)) {
|
|
949
|
+
return {
|
|
950
|
+
canResume: false,
|
|
951
|
+
reason: 'path-traversal-blocked',
|
|
952
|
+
error: 'Watch path must be within project directory'
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (!fs.existsSync(filePath)) {
|
|
957
|
+
return {
|
|
958
|
+
canResume: false,
|
|
959
|
+
reason: 'file-not-found',
|
|
960
|
+
watchPath: config.watchPath
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// If expected content specified, check it
|
|
965
|
+
if (config.expectedContent) {
|
|
966
|
+
try {
|
|
967
|
+
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
968
|
+
const matches = deepEqual(content, config.expectedContent);
|
|
969
|
+
|
|
970
|
+
if (matches) {
|
|
971
|
+
return { canResume: true, reason: 'file-content-matches' };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
return {
|
|
975
|
+
canResume: false,
|
|
976
|
+
reason: 'file-content-mismatch',
|
|
977
|
+
expected: config.expectedContent,
|
|
978
|
+
actual: content
|
|
979
|
+
};
|
|
980
|
+
} catch (error) {
|
|
981
|
+
return {
|
|
982
|
+
canResume: false,
|
|
983
|
+
reason: 'file-parse-error',
|
|
984
|
+
error: error.message
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Just check existence
|
|
990
|
+
return { canResume: true, reason: 'file-exists' };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Resume a suspended session
|
|
995
|
+
* @param {Object} options - Resume options
|
|
996
|
+
* @returns {Object|null} Updated session
|
|
997
|
+
*/
|
|
998
|
+
function resumeSession(options = {}) {
|
|
999
|
+
const session = loadDurableSession();
|
|
1000
|
+
if (!session || !session.suspension) return null;
|
|
1001
|
+
|
|
1002
|
+
// Update manual approval if provided
|
|
1003
|
+
if (options.approve && session.suspension.resumeCondition?.type === RESUME_CONDITION.MANUAL) {
|
|
1004
|
+
session.suspension.resumeCondition.manual.approvedAt = new Date().toISOString();
|
|
1005
|
+
session.suspension.resumeCondition.manual.approvedBy = options.approvedBy || 'user';
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Check if can resume
|
|
1009
|
+
const resumeCheck = checkResumeCondition(session.suspension);
|
|
1010
|
+
if (!resumeCheck.canResume && !options.force) {
|
|
1011
|
+
return {
|
|
1012
|
+
error: 'Cannot resume yet',
|
|
1013
|
+
...resumeCheck
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Clear suspension
|
|
1018
|
+
const suspendedStepId = session.suspension.suspendedAtStep;
|
|
1019
|
+
session.suspension = null;
|
|
1020
|
+
|
|
1021
|
+
// Resume suspended step
|
|
1022
|
+
if (suspendedStepId) {
|
|
1023
|
+
const step = session.steps.find(s => s.id === suspendedStepId);
|
|
1024
|
+
if (step && step.status === STEP_STATUS.SUSPENDED) {
|
|
1025
|
+
step.status = STEP_STATUS.PENDING;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
saveDurableSession(session);
|
|
1030
|
+
return session;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ============================================================================
|
|
1034
|
+
// Backward Compatibility - Loop Enforcer API
|
|
1035
|
+
// ============================================================================
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Start a loop (backward compat with flow-loop-enforcer)
|
|
1039
|
+
* @deprecated Use createDurableSession instead
|
|
1040
|
+
*/
|
|
1041
|
+
function startLoop(taskId, acceptanceCriteria) {
|
|
1042
|
+
return createDurableSession(taskId, 'task', acceptanceCriteria);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Get active loop (backward compat)
|
|
1047
|
+
* @deprecated Use loadDurableSession instead
|
|
1048
|
+
*/
|
|
1049
|
+
function getActiveLoop() {
|
|
1050
|
+
const session = loadDurableSession();
|
|
1051
|
+
if (!session) return null;
|
|
1052
|
+
|
|
1053
|
+
// Convert to old format for compatibility
|
|
1054
|
+
// Map step-NNN back to AC-N format for backward compatibility
|
|
1055
|
+
return {
|
|
1056
|
+
taskId: session.taskId,
|
|
1057
|
+
startedAt: session.startedAt,
|
|
1058
|
+
acceptanceCriteria: session.steps.map((s, index) => ({
|
|
1059
|
+
// Convert step-NNN to AC-N format for backward compat
|
|
1060
|
+
id: s.id.startsWith('step-') ? `AC-${index + 1}` : s.id,
|
|
1061
|
+
description: s.description,
|
|
1062
|
+
status: s.status === STEP_STATUS.COMPLETED ? 'completed' :
|
|
1063
|
+
s.status === STEP_STATUS.FAILED ? 'failed' :
|
|
1064
|
+
s.status === STEP_STATUS.SKIPPED ? 'skipped' : 'pending',
|
|
1065
|
+
attempts: s.attempts,
|
|
1066
|
+
lastAttempt: s.lastAttemptAt,
|
|
1067
|
+
verificationResult: s.verificationProof
|
|
1068
|
+
})),
|
|
1069
|
+
iteration: session.execution.iteration,
|
|
1070
|
+
retries: session.execution.totalRetries,
|
|
1071
|
+
status: session.suspension ? 'suspended' : 'in_progress'
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Update criterion (backward compat)
|
|
1077
|
+
* @deprecated Use markStepCompleted/markStepFailed instead
|
|
1078
|
+
*/
|
|
1079
|
+
function updateCriterion(criterionId, status, verificationResult = null) {
|
|
1080
|
+
// Convert AC-N format to step-NNN format for backward compatibility
|
|
1081
|
+
const stepId = criterionId.startsWith('AC-')
|
|
1082
|
+
? `step-${criterionId.replace('AC-', '').padStart(3, '0')}`
|
|
1083
|
+
: criterionId;
|
|
1084
|
+
|
|
1085
|
+
if (status === 'completed') {
|
|
1086
|
+
return markStepCompleted(stepId, verificationResult);
|
|
1087
|
+
} else if (status === 'failed') {
|
|
1088
|
+
return markStepFailed(stepId, verificationResult);
|
|
1089
|
+
} else if (status === 'skipped') {
|
|
1090
|
+
return markStepSkipped(stepId, verificationResult);
|
|
1091
|
+
}
|
|
1092
|
+
return null;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Can exit loop (backward compat)
|
|
1097
|
+
* @deprecated Use checkCompletion instead
|
|
1098
|
+
*/
|
|
1099
|
+
function canExitLoop() {
|
|
1100
|
+
const completion = checkCompletion();
|
|
1101
|
+
|
|
1102
|
+
return {
|
|
1103
|
+
canExit: completion.complete,
|
|
1104
|
+
reason: completion.reason,
|
|
1105
|
+
summary: completion.summary,
|
|
1106
|
+
pending: completion.pending,
|
|
1107
|
+
failed: completion.failed,
|
|
1108
|
+
completed: completion.completed,
|
|
1109
|
+
skipped: completion.skipped
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* End loop (backward compat)
|
|
1115
|
+
* @deprecated Use archiveDurableSession instead
|
|
1116
|
+
*/
|
|
1117
|
+
function endLoop(status = 'completed') {
|
|
1118
|
+
return archiveDurableSession(status);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ============================================================================
|
|
1122
|
+
// Backward Compatibility - Hybrid Session API
|
|
1123
|
+
// ============================================================================
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Get hybrid session format (backward compat)
|
|
1127
|
+
* @deprecated Use loadDurableSession instead
|
|
1128
|
+
*/
|
|
1129
|
+
function getHybridSession() {
|
|
1130
|
+
const session = loadDurableSession();
|
|
1131
|
+
if (!session) return null;
|
|
1132
|
+
|
|
1133
|
+
return {
|
|
1134
|
+
sessionId: session.sessionId,
|
|
1135
|
+
startedAt: session.startedAt,
|
|
1136
|
+
autoExecute: false,
|
|
1137
|
+
currentPlan: null,
|
|
1138
|
+
executedSteps: session.steps
|
|
1139
|
+
.filter(s => s.status === STEP_STATUS.COMPLETED)
|
|
1140
|
+
.map(s => s.id),
|
|
1141
|
+
failedSteps: session.steps
|
|
1142
|
+
.filter(s => s.status === STEP_STATUS.FAILED)
|
|
1143
|
+
.map(s => s.id),
|
|
1144
|
+
pendingSteps: session.steps
|
|
1145
|
+
.filter(s => s.status === STEP_STATUS.PENDING || s.status === STEP_STATUS.IN_PROGRESS)
|
|
1146
|
+
.map(s => s.id),
|
|
1147
|
+
totalTokensSaved: session.metrics.tokensSaved
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// ============================================================================
|
|
1152
|
+
// Utilities
|
|
1153
|
+
// ============================================================================
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Deep equality check for objects
|
|
1157
|
+
*/
|
|
1158
|
+
function deepEqual(obj1, obj2) {
|
|
1159
|
+
if (obj1 === obj2) return true;
|
|
1160
|
+
if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return false;
|
|
1161
|
+
if (obj1 === null || obj2 === null) return false;
|
|
1162
|
+
|
|
1163
|
+
const keys1 = Object.keys(obj1);
|
|
1164
|
+
const keys2 = Object.keys(obj2);
|
|
1165
|
+
|
|
1166
|
+
if (keys1.length !== keys2.length) return false;
|
|
1167
|
+
|
|
1168
|
+
for (const key of keys1) {
|
|
1169
|
+
if (!keys2.includes(key)) return false;
|
|
1170
|
+
if (!deepEqual(obj1[key], obj2[key])) return false;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
return true;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Get session statistics from history
|
|
1178
|
+
*/
|
|
1179
|
+
function getSessionStats() {
|
|
1180
|
+
const historyPath = getHistoryPath();
|
|
1181
|
+
|
|
1182
|
+
if (!fs.existsSync(historyPath)) {
|
|
1183
|
+
return { totalSessions: 0, completed: 0, failed: 0, cancelled: 0, avgSteps: 0, avgTokensSaved: 0 };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
try {
|
|
1187
|
+
const history = JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
|
|
1188
|
+
const completed = history.filter(h => h.status === 'completed').length;
|
|
1189
|
+
const failed = history.filter(h => h.status === 'failed').length;
|
|
1190
|
+
const avgSteps = history.length > 0
|
|
1191
|
+
? history.reduce((sum, h) => sum + (h.steps?.length || 0), 0) / history.length
|
|
1192
|
+
: 0;
|
|
1193
|
+
|
|
1194
|
+
return {
|
|
1195
|
+
totalSessions: history.length,
|
|
1196
|
+
completed,
|
|
1197
|
+
failed,
|
|
1198
|
+
cancelled: history.length - completed - failed,
|
|
1199
|
+
avgSteps: Math.round(avgSteps * 10) / 10,
|
|
1200
|
+
avgTokensSaved: history.length > 0
|
|
1201
|
+
? Math.round(history.reduce((sum, h) => sum + (h.metrics?.tokensSaved || 0), 0) / history.length)
|
|
1202
|
+
: 0
|
|
1203
|
+
};
|
|
1204
|
+
} catch {
|
|
1205
|
+
return { totalSessions: 0, completed: 0, failed: 0, cancelled: 0, avgSteps: 0, avgTokensSaved: 0 };
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// ============================================================================
|
|
1210
|
+
// Task Queue Management (v2.1)
|
|
1211
|
+
// ============================================================================
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Initialize a task queue for multi-task execution
|
|
1215
|
+
* @param {string[]} taskIds - Array of task IDs to queue
|
|
1216
|
+
* @param {string} source - Source of queue: "bulk", "natural", "manual"
|
|
1217
|
+
* @returns {Object} Updated session
|
|
1218
|
+
*/
|
|
1219
|
+
function initTaskQueue(taskIds, source = 'manual') {
|
|
1220
|
+
const session = loadDurableSession();
|
|
1221
|
+
if (!session) {
|
|
1222
|
+
throw new Error('No active session to initialize queue');
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
session.taskQueue = {
|
|
1226
|
+
enabled: true,
|
|
1227
|
+
tasks: taskIds,
|
|
1228
|
+
currentIndex: 0,
|
|
1229
|
+
source,
|
|
1230
|
+
queuedAt: new Date().toISOString(),
|
|
1231
|
+
completedTasks: []
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
// Ensure first task matches current session
|
|
1235
|
+
if (taskIds[0] && session.taskId !== taskIds[0]) {
|
|
1236
|
+
console.warn(`[Queue] First task ${taskIds[0]} doesn't match current session ${session.taskId}`);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
session.updatedAt = new Date().toISOString();
|
|
1240
|
+
saveDurableSession(session);
|
|
1241
|
+
return session;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Get current queue status
|
|
1246
|
+
* @returns {Object} Queue status: { hasQueue, hasMoreTasks, currentTask, nextTask, remaining, completed }
|
|
1247
|
+
*/
|
|
1248
|
+
function getQueueStatus() {
|
|
1249
|
+
const session = loadDurableSession();
|
|
1250
|
+
if (!session || !session.taskQueue?.enabled) {
|
|
1251
|
+
return {
|
|
1252
|
+
hasQueue: false,
|
|
1253
|
+
hasMoreTasks: false,
|
|
1254
|
+
currentTask: session?.taskId || null,
|
|
1255
|
+
nextTask: null,
|
|
1256
|
+
remaining: 0,
|
|
1257
|
+
completed: 0,
|
|
1258
|
+
total: 0
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const queue = session.taskQueue;
|
|
1263
|
+
const remaining = queue.tasks.length - queue.currentIndex - 1;
|
|
1264
|
+
const nextTask = remaining > 0 ? queue.tasks[queue.currentIndex + 1] : null;
|
|
1265
|
+
|
|
1266
|
+
return {
|
|
1267
|
+
hasQueue: true,
|
|
1268
|
+
hasMoreTasks: remaining > 0,
|
|
1269
|
+
currentTask: queue.tasks[queue.currentIndex],
|
|
1270
|
+
nextTask,
|
|
1271
|
+
remaining,
|
|
1272
|
+
completed: queue.completedTasks.length,
|
|
1273
|
+
total: queue.tasks.length,
|
|
1274
|
+
source: queue.source
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Advance to next task in queue (called when current task completes)
|
|
1280
|
+
* @returns {Object} { advanced, nextTaskId, queueComplete }
|
|
1281
|
+
*/
|
|
1282
|
+
function advanceTaskQueue() {
|
|
1283
|
+
const session = loadDurableSession();
|
|
1284
|
+
if (!session || !session.taskQueue?.enabled) {
|
|
1285
|
+
return { advanced: false, nextTaskId: null, queueComplete: true };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const queue = session.taskQueue;
|
|
1289
|
+
|
|
1290
|
+
// Mark current task as completed
|
|
1291
|
+
const currentTaskId = queue.tasks[queue.currentIndex];
|
|
1292
|
+
if (currentTaskId && !queue.completedTasks.includes(currentTaskId)) {
|
|
1293
|
+
queue.completedTasks.push(currentTaskId);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Check if more tasks
|
|
1297
|
+
if (queue.currentIndex >= queue.tasks.length - 1) {
|
|
1298
|
+
// Queue complete
|
|
1299
|
+
session.updatedAt = new Date().toISOString();
|
|
1300
|
+
saveDurableSession(session);
|
|
1301
|
+
return {
|
|
1302
|
+
advanced: false,
|
|
1303
|
+
nextTaskId: null,
|
|
1304
|
+
queueComplete: true,
|
|
1305
|
+
completedTasks: queue.completedTasks
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Advance to next task
|
|
1310
|
+
queue.currentIndex++;
|
|
1311
|
+
const nextTaskId = queue.tasks[queue.currentIndex];
|
|
1312
|
+
|
|
1313
|
+
session.updatedAt = new Date().toISOString();
|
|
1314
|
+
saveDurableSession(session);
|
|
1315
|
+
|
|
1316
|
+
return {
|
|
1317
|
+
advanced: true,
|
|
1318
|
+
nextTaskId,
|
|
1319
|
+
queueComplete: false,
|
|
1320
|
+
remaining: queue.tasks.length - queue.currentIndex - 1
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Clear the task queue
|
|
1326
|
+
* @returns {boolean} Success
|
|
1327
|
+
*/
|
|
1328
|
+
function clearTaskQueue() {
|
|
1329
|
+
const session = loadDurableSession();
|
|
1330
|
+
if (!session) return false;
|
|
1331
|
+
|
|
1332
|
+
session.taskQueue = {
|
|
1333
|
+
enabled: false,
|
|
1334
|
+
tasks: [],
|
|
1335
|
+
currentIndex: 0,
|
|
1336
|
+
source: null,
|
|
1337
|
+
queuedAt: null,
|
|
1338
|
+
completedTasks: session.taskQueue?.completedTasks || []
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
session.updatedAt = new Date().toISOString();
|
|
1342
|
+
saveDurableSession(session);
|
|
1343
|
+
return true;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Check if should continue to next task (used by stop hook)
|
|
1348
|
+
* @returns {Object} { shouldContinue, nextTaskId, message }
|
|
1349
|
+
*/
|
|
1350
|
+
function checkQueueContinuation() {
|
|
1351
|
+
const config = getConfig();
|
|
1352
|
+
const queueConfig = config.taskQueue || {};
|
|
1353
|
+
|
|
1354
|
+
// Check if queue feature is enabled
|
|
1355
|
+
if (queueConfig.enabled === false) {
|
|
1356
|
+
return { shouldContinue: false, reason: 'queue_disabled' };
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const status = getQueueStatus();
|
|
1360
|
+
|
|
1361
|
+
if (!status.hasQueue) {
|
|
1362
|
+
return { shouldContinue: false, reason: 'no_queue' };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (!status.hasMoreTasks) {
|
|
1366
|
+
return {
|
|
1367
|
+
shouldContinue: false,
|
|
1368
|
+
reason: 'queue_complete',
|
|
1369
|
+
message: `All ${status.total} tasks completed!`,
|
|
1370
|
+
completedTasks: status.completed
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// Check if should pause between tasks
|
|
1375
|
+
if (queueConfig.pauseBetweenTasks) {
|
|
1376
|
+
return {
|
|
1377
|
+
shouldContinue: false,
|
|
1378
|
+
shouldPrompt: true,
|
|
1379
|
+
nextTaskId: status.nextTask,
|
|
1380
|
+
message: `Task complete. Next: ${status.nextTask} (${status.remaining} remaining). Continue?`
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Auto-continue (default)
|
|
1385
|
+
return {
|
|
1386
|
+
shouldContinue: true,
|
|
1387
|
+
nextTaskId: status.nextTask,
|
|
1388
|
+
remaining: status.remaining,
|
|
1389
|
+
message: `Task complete. Auto-continuing to: ${status.nextTask}`
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// ============================================================================
|
|
1394
|
+
// Exports
|
|
1395
|
+
// ============================================================================
|
|
1396
|
+
|
|
1397
|
+
module.exports = {
|
|
1398
|
+
// Constants
|
|
1399
|
+
SESSION_VERSION,
|
|
1400
|
+
STEP_STATUS,
|
|
1401
|
+
STEP_TYPE,
|
|
1402
|
+
SUSPENSION_TYPE,
|
|
1403
|
+
RESUME_CONDITION,
|
|
1404
|
+
|
|
1405
|
+
// Core session management
|
|
1406
|
+
createDurableSession,
|
|
1407
|
+
createDurableSessionAsync, // Async version with file locking
|
|
1408
|
+
loadDurableSession,
|
|
1409
|
+
saveDurableSession,
|
|
1410
|
+
archiveDurableSession,
|
|
1411
|
+
|
|
1412
|
+
// Step management
|
|
1413
|
+
getNextPendingStep,
|
|
1414
|
+
getStep,
|
|
1415
|
+
markStepStarted,
|
|
1416
|
+
markStepCompleted,
|
|
1417
|
+
markStepFailed,
|
|
1418
|
+
markStepSkipped,
|
|
1419
|
+
addSteps,
|
|
1420
|
+
getRemainingSteps,
|
|
1421
|
+
|
|
1422
|
+
// Resume support
|
|
1423
|
+
canResumeFromStep,
|
|
1424
|
+
getResumeContext,
|
|
1425
|
+
|
|
1426
|
+
// Execution tracking
|
|
1427
|
+
incrementIteration,
|
|
1428
|
+
addTokensSaved,
|
|
1429
|
+
checkCompletion,
|
|
1430
|
+
|
|
1431
|
+
// Suspension
|
|
1432
|
+
suspendSession,
|
|
1433
|
+
isSuspended,
|
|
1434
|
+
getSuspensionStatus,
|
|
1435
|
+
checkResumeCondition,
|
|
1436
|
+
resumeSession,
|
|
1437
|
+
|
|
1438
|
+
// Backward compatibility - Loop Enforcer
|
|
1439
|
+
startLoop,
|
|
1440
|
+
getActiveLoop,
|
|
1441
|
+
updateCriterion,
|
|
1442
|
+
canExitLoop,
|
|
1443
|
+
endLoop,
|
|
1444
|
+
|
|
1445
|
+
// Backward compatibility - Hybrid Session
|
|
1446
|
+
getHybridSession,
|
|
1447
|
+
|
|
1448
|
+
// Utilities
|
|
1449
|
+
getSessionStats,
|
|
1450
|
+
normalizeStep,
|
|
1451
|
+
|
|
1452
|
+
// Task Queue (v2.1)
|
|
1453
|
+
initTaskQueue,
|
|
1454
|
+
getQueueStatus,
|
|
1455
|
+
advanceTaskQueue,
|
|
1456
|
+
clearTaskQueue,
|
|
1457
|
+
checkQueueContinuation
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1460
|
+
// ============================================================================
|
|
1461
|
+
// CLI Interface
|
|
1462
|
+
// ============================================================================
|
|
1463
|
+
|
|
1464
|
+
if (require.main === module) {
|
|
1465
|
+
const args = process.argv.slice(2);
|
|
1466
|
+
const command = args[0];
|
|
1467
|
+
|
|
1468
|
+
switch (command) {
|
|
1469
|
+
case 'status': {
|
|
1470
|
+
const session = loadDurableSession();
|
|
1471
|
+
if (!session) {
|
|
1472
|
+
console.log('No active durable session');
|
|
1473
|
+
process.exit(0);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const completion = checkCompletion();
|
|
1477
|
+
const suspension = getSuspensionStatus();
|
|
1478
|
+
|
|
1479
|
+
console.log('\nπ Durable Session Status');
|
|
1480
|
+
console.log('β'.repeat(40));
|
|
1481
|
+
console.log(`Task: ${session.taskId}`);
|
|
1482
|
+
console.log(`Type: ${session.taskType}`);
|
|
1483
|
+
console.log(`Started: ${session.startedAt}`);
|
|
1484
|
+
console.log(`Iteration: ${session.execution.iteration}`);
|
|
1485
|
+
console.log(`Retries: ${session.execution.totalRetries}`);
|
|
1486
|
+
console.log('');
|
|
1487
|
+
console.log(`Steps: ${session.steps.length} total`);
|
|
1488
|
+
console.log(` β
Completed: ${session.metrics.stepsCompleted}`);
|
|
1489
|
+
console.log(` β Failed: ${session.metrics.stepsFailed}`);
|
|
1490
|
+
console.log(` βοΈ Skipped: ${session.metrics.stepsSkipped}`);
|
|
1491
|
+
console.log(` β³ Pending: ${completion.pending || 0}`);
|
|
1492
|
+
|
|
1493
|
+
if (suspension) {
|
|
1494
|
+
console.log('');
|
|
1495
|
+
console.log('βΈοΈ SUSPENDED');
|
|
1496
|
+
console.log(` Type: ${suspension.type}`);
|
|
1497
|
+
console.log(` Reason: ${suspension.reason}`);
|
|
1498
|
+
console.log(` Can Resume: ${suspension.canResume ? 'Yes' : 'No'}`);
|
|
1499
|
+
if (!suspension.canResume) {
|
|
1500
|
+
console.log(` Resume Reason: ${suspension.resumeReason}`);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
console.log('β'.repeat(40));
|
|
1505
|
+
break;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
case 'stats': {
|
|
1509
|
+
const stats = getSessionStats();
|
|
1510
|
+
console.log('\nπ Session Statistics');
|
|
1511
|
+
console.log('β'.repeat(40));
|
|
1512
|
+
console.log(`Total Sessions: ${stats.totalSessions}`);
|
|
1513
|
+
console.log(`Completed: ${stats.completed}`);
|
|
1514
|
+
console.log(`Failed: ${stats.failed}`);
|
|
1515
|
+
console.log(`Cancelled: ${stats.cancelled}`);
|
|
1516
|
+
console.log(`Avg Steps: ${stats.avgSteps}`);
|
|
1517
|
+
console.log(`Avg Tokens Saved: ${stats.avgTokensSaved}`);
|
|
1518
|
+
console.log('β'.repeat(40));
|
|
1519
|
+
break;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
case 'clear': {
|
|
1523
|
+
const sessionPath = getSessionPath();
|
|
1524
|
+
if (fs.existsSync(sessionPath)) {
|
|
1525
|
+
fs.unlinkSync(sessionPath);
|
|
1526
|
+
console.log('β
Active session cleared');
|
|
1527
|
+
} else {
|
|
1528
|
+
console.log('No active session to clear');
|
|
1529
|
+
}
|
|
1530
|
+
break;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
default:
|
|
1534
|
+
console.log('Usage: node flow-durable-session.js <command>');
|
|
1535
|
+
console.log('');
|
|
1536
|
+
console.log('Commands:');
|
|
1537
|
+
console.log(' status - Show current session status');
|
|
1538
|
+
console.log(' stats - Show session statistics');
|
|
1539
|
+
console.log(' clear - Clear active session');
|
|
1540
|
+
}
|
|
1541
|
+
}
|