nightytidy 0.3.9 → 0.3.10
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/package.json +1 -1
- package/src/agent/index.js +50 -8
- package/src/agent/run-queue.js +12 -0
- package/src/agent/service.js +82 -22
package/package.json
CHANGED
package/src/agent/index.js
CHANGED
|
@@ -723,6 +723,7 @@ export async function startAgent() {
|
|
|
723
723
|
run: { id: run.id, progress: `${stepIndex + 1}/${totalSteps}`, costSoFar: stepData.cost, elapsedMs: stepData.duration },
|
|
724
724
|
}, project.webhooks);
|
|
725
725
|
stepIndex++;
|
|
726
|
+
runQueue.updateProgress({ ...runProgress });
|
|
726
727
|
} else {
|
|
727
728
|
const errorType = stepParsed.errorType;
|
|
728
729
|
if (errorType === 'rate_limit') {
|
|
@@ -771,6 +772,7 @@ export async function startAgent() {
|
|
|
771
772
|
run: { id: run.id },
|
|
772
773
|
}, project.webhooks);
|
|
773
774
|
stepIndex++;
|
|
775
|
+
runQueue.updateProgress({ ...runProgress });
|
|
774
776
|
}
|
|
775
777
|
}
|
|
776
778
|
|
|
@@ -959,6 +961,7 @@ export async function startAgent() {
|
|
|
959
961
|
project: project.name, projectId: project.id, step: stepData,
|
|
960
962
|
run: { id: interrupted.id, costSoFar: stepData.cost, elapsedMs: stepData.duration },
|
|
961
963
|
}, project.webhooks);
|
|
964
|
+
runQueue.updateProgress({ ...runProgress });
|
|
962
965
|
} else if (stepParsed.errorType === 'rate_limit') {
|
|
963
966
|
const waitMs = stepParsed.retryAfterMs || 120000;
|
|
964
967
|
info(` ⏸ Rate limited — waiting ${Math.round(waitMs / 1000)}s`);
|
|
@@ -975,6 +978,7 @@ export async function startAgent() {
|
|
|
975
978
|
const si = runProgress.stepList.findIndex(s => s.number === stepNum);
|
|
976
979
|
if (si >= 0) Object.assign(runProgress.stepList[si], { status: 'completed' });
|
|
977
980
|
wsServer.broadcast({ type: 'step-completed', runId: interrupted.id, step: { number: stepNum, status: 'completed', duration: 0, cost: 0 }, cost: 0 });
|
|
981
|
+
runQueue.updateProgress({ ...runProgress });
|
|
978
982
|
} else {
|
|
979
983
|
info(` ✗ Step ${stepNum} failed: ${stepParsed.error || 'unknown'}`);
|
|
980
984
|
runProgress.failedCount++;
|
|
@@ -985,6 +989,7 @@ export async function startAgent() {
|
|
|
985
989
|
error: stepParsed.error || stepResult.stderr,
|
|
986
990
|
});
|
|
987
991
|
wsServer.broadcast({ type: 'step-failed', runId: interrupted.id, step: { number: stepNum }, error: stepParsed.error || stepResult.stderr });
|
|
992
|
+
runQueue.updateProgress({ ...runProgress });
|
|
988
993
|
}
|
|
989
994
|
}
|
|
990
995
|
|
|
@@ -1228,16 +1233,53 @@ export async function startAgent() {
|
|
|
1228
1233
|
}
|
|
1229
1234
|
|
|
1230
1235
|
// Also handle case where queue has a "running" entry from a crash
|
|
1231
|
-
// (agent died without graceful shutdown, so markInterrupted was never called)
|
|
1236
|
+
// (agent died without graceful shutdown, so markInterrupted was never called).
|
|
1237
|
+
// Check for progress before discarding — force-kills may have left
|
|
1238
|
+
// lastProgress from per-step updateProgress() calls, or the CLI state
|
|
1239
|
+
// file may still exist in the target project.
|
|
1232
1240
|
const current = runQueue.getCurrent();
|
|
1233
1241
|
if (current && current.status === 'running' && !activeBridge) {
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1242
|
+
const savedProgress = current.lastProgress || {};
|
|
1243
|
+
let completedFromQueue = savedProgress.completedCount || 0;
|
|
1244
|
+
|
|
1245
|
+
// Also check CLI state file as a fallback (belt + suspenders)
|
|
1246
|
+
let completedFromCli = 0;
|
|
1247
|
+
try {
|
|
1248
|
+
const proj = projectManager.getProject(current.projectId);
|
|
1249
|
+
if (proj) {
|
|
1250
|
+
const stateFile = path.join(proj.path, 'nightytidy-run-state.json');
|
|
1251
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
1252
|
+
completedFromCli = (state.completedSteps || []).length;
|
|
1253
|
+
}
|
|
1254
|
+
} catch { /* no state file — fine */ }
|
|
1255
|
+
|
|
1256
|
+
const totalCompleted = Math.max(completedFromQueue, completedFromCli);
|
|
1257
|
+
|
|
1258
|
+
if (totalCompleted === 0) {
|
|
1259
|
+
// No work was done — safe to auto-discard
|
|
1260
|
+
info(`Found orphaned running run: ${current.id} — auto-discarding (0 steps completed)`);
|
|
1261
|
+
runQueue.completeCurrent({ success: false });
|
|
1262
|
+
dispatchWithQueue('run_failed', {
|
|
1263
|
+
projectId: current.projectId,
|
|
1264
|
+
run: { id: current.id },
|
|
1265
|
+
}, []);
|
|
1266
|
+
} else {
|
|
1267
|
+
// Has progress — transition to interrupted so user can resume/finish/discard
|
|
1268
|
+
info(`Found orphaned running run: ${current.id} — transitioning to interrupted (${totalCompleted} steps completed)`);
|
|
1269
|
+
const progress = Object.keys(savedProgress).length > 0
|
|
1270
|
+
? savedProgress
|
|
1271
|
+
: { completedCount: completedFromCli, failedCount: 0, totalCost: 0 };
|
|
1272
|
+
runQueue.markInterrupted(progress);
|
|
1273
|
+
dispatchWithQueue('run_interrupted', {
|
|
1274
|
+
projectId: current.projectId,
|
|
1275
|
+
run: {
|
|
1276
|
+
id: current.id,
|
|
1277
|
+
totalSteps: current.steps?.length || 0,
|
|
1278
|
+
completedSteps: totalCompleted,
|
|
1279
|
+
elapsedMs: Date.now() - (current.startedAt || Date.now()),
|
|
1280
|
+
},
|
|
1281
|
+
}, []);
|
|
1282
|
+
}
|
|
1241
1283
|
}
|
|
1242
1284
|
|
|
1243
1285
|
// Process any queued runs left from a previous session
|
package/src/agent/run-queue.js
CHANGED
|
@@ -56,6 +56,18 @@ export class RunQueue {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Persist run progress to disk so it survives force-kills and crashes.
|
|
61
|
+
* Called after each step completes/fails. The next startup can read
|
|
62
|
+
* current.lastProgress to recover the run.
|
|
63
|
+
*/
|
|
64
|
+
updateProgress(progress) {
|
|
65
|
+
if (this.current) {
|
|
66
|
+
this.current.lastProgress = progress;
|
|
67
|
+
this._save();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
/**
|
|
60
72
|
* Mark the current run as interrupted (agent shutting down mid-run).
|
|
61
73
|
* Preserves progress data so the run can be resumed on restart.
|
package/src/agent/service.js
CHANGED
|
@@ -88,51 +88,111 @@ export function unregisterService() {
|
|
|
88
88
|
// --- Platform implementations ---
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* Windows:
|
|
92
|
-
*
|
|
93
|
-
*
|
|
91
|
+
* Windows: Write a .cmd launcher to ~/.nightytidy/ and register it with
|
|
92
|
+
* Task Scheduler (ONLOGON trigger). Task Scheduler is reliable on Windows 11
|
|
93
|
+
* where VBS scripts in the Startup folder are silently blocked by SmartScreen.
|
|
94
|
+
*
|
|
95
|
+
* The .cmd file uses `start /min` to avoid a visible console window.
|
|
96
|
+
* Falls back to the Startup folder VBS approach if schtasks fails.
|
|
94
97
|
*/
|
|
95
98
|
const STARTUP_DIR = process.platform === 'win32'
|
|
96
99
|
? path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
97
100
|
: '';
|
|
98
101
|
const STARTUP_VBS = path.join(STARTUP_DIR, 'NightyTidy Agent.vbs');
|
|
102
|
+
const AGENT_CMD = process.platform === 'win32'
|
|
103
|
+
? path.join(os.homedir(), '.nightytidy', 'start-agent.cmd')
|
|
104
|
+
: '';
|
|
99
105
|
|
|
100
106
|
function _registerWindows(cmd) {
|
|
101
|
-
//
|
|
107
|
+
// Write a .cmd launcher script to ~/.nightytidy/start-agent.cmd
|
|
108
|
+
// Avoids quoting issues when passing to Task Scheduler or Startup folder.
|
|
109
|
+
const cmdContent = `@echo off\r\nstart /min "" ${cmd}\r\n`;
|
|
110
|
+
const cmdDir = path.dirname(AGENT_CMD);
|
|
111
|
+
fs.mkdirSync(cmdDir, { recursive: true });
|
|
112
|
+
fs.writeFileSync(AGENT_CMD, cmdContent, 'utf-8');
|
|
113
|
+
debug(`Wrote launcher script: ${AGENT_CMD}`);
|
|
114
|
+
|
|
115
|
+
// Primary: Task Scheduler via PowerShell (no admin required for user-level
|
|
116
|
+
// logon triggers). schtasks.exe /sc onlogon needs admin, but PowerShell's
|
|
117
|
+
// Register-ScheduledTask with a LogonTrigger does not.
|
|
118
|
+
try {
|
|
119
|
+
const escapedPath = AGENT_CMD.replace(/'/g, "''");
|
|
120
|
+
const ps = [
|
|
121
|
+
`$action = New-ScheduledTaskAction -Execute '${escapedPath}'`,
|
|
122
|
+
`$trigger = New-ScheduledTaskTrigger -AtLogOn`,
|
|
123
|
+
`$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable`,
|
|
124
|
+
`Register-ScheduledTask -TaskName '${SERVICE_NAME}' -Action $action -Trigger $trigger -Settings $settings -Force`,
|
|
125
|
+
].join('; ');
|
|
126
|
+
execSync(`powershell.exe -NoProfile -Command "${ps}"`, { stdio: 'pipe', timeout: 15000 });
|
|
127
|
+
debug('Registered via Task Scheduler (PowerShell)');
|
|
128
|
+
_removeStartupVbs();
|
|
129
|
+
return;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
debug(`Task Scheduler failed: ${err.message}, trying Startup folder`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Fallback: Startup folder — place .cmd directly (less restricted than .vbs
|
|
135
|
+
// by Windows SmartScreen). The .cmd file runs the agent hidden via start /min.
|
|
102
136
|
try {
|
|
103
|
-
// VBS wrapper runs the agent without showing a console window.
|
|
104
|
-
// In VBS, quotes inside strings are doubled: "" not \"
|
|
105
|
-
const vbsCmd = cmd.replace(/"/g, '""');
|
|
106
|
-
const vbs = `' NightyTidy Agent - auto-start on login\r\nSet ws = CreateObject("WScript.Shell")\r\nws.Run "${vbsCmd}", 0, False\r\n`;
|
|
107
137
|
fs.mkdirSync(STARTUP_DIR, { recursive: true });
|
|
108
|
-
|
|
109
|
-
|
|
138
|
+
const startupCmd = path.join(STARTUP_DIR, 'NightyTidy Agent.cmd');
|
|
139
|
+
fs.copyFileSync(AGENT_CMD, startupCmd);
|
|
140
|
+
debug('Registered via Startup folder (.cmd)');
|
|
141
|
+
_removeStartupVbs();
|
|
110
142
|
return;
|
|
111
143
|
} catch (err) {
|
|
112
|
-
debug(`Startup folder failed: ${err.message}, trying
|
|
144
|
+
debug(`Startup folder .cmd failed: ${err.message}, trying VBS`);
|
|
113
145
|
}
|
|
114
146
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
);
|
|
147
|
+
// Last resort: Startup folder VBS wrapper
|
|
148
|
+
const vbsCmd = cmd.replace(/"/g, '""');
|
|
149
|
+
const vbs = `' NightyTidy Agent - auto-start on login\r\nSet ws = CreateObject("WScript.Shell")\r\nws.Run "${vbsCmd}", 0, False\r\n`;
|
|
150
|
+
fs.mkdirSync(STARTUP_DIR, { recursive: true });
|
|
151
|
+
fs.writeFileSync(STARTUP_VBS, vbs, 'utf-8');
|
|
152
|
+
debug('Registered via Windows Startup folder (VBS)');
|
|
120
153
|
}
|
|
121
154
|
|
|
122
|
-
function
|
|
123
|
-
// Remove Startup folder entry
|
|
155
|
+
function _removeStartupVbs() {
|
|
124
156
|
try {
|
|
125
157
|
if (fs.existsSync(STARTUP_VBS)) {
|
|
126
158
|
fs.unlinkSync(STARTUP_VBS);
|
|
127
|
-
debug('Removed Startup folder
|
|
159
|
+
debug('Removed stale Startup folder VBS');
|
|
160
|
+
}
|
|
161
|
+
} catch { /* ignore */ }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _unregisterWindows() {
|
|
165
|
+
// Remove Startup folder entries (VBS and .cmd)
|
|
166
|
+
_removeStartupVbs();
|
|
167
|
+
try {
|
|
168
|
+
const startupCmd = path.join(STARTUP_DIR, 'NightyTidy Agent.cmd');
|
|
169
|
+
if (fs.existsSync(startupCmd)) {
|
|
170
|
+
fs.unlinkSync(startupCmd);
|
|
171
|
+
debug('Removed Startup folder .cmd entry');
|
|
128
172
|
}
|
|
129
173
|
} catch { /* ignore */ }
|
|
130
174
|
|
|
131
|
-
//
|
|
175
|
+
// Remove .cmd launcher script from ~/.nightytidy/
|
|
132
176
|
try {
|
|
133
|
-
|
|
177
|
+
if (fs.existsSync(AGENT_CMD)) {
|
|
178
|
+
fs.unlinkSync(AGENT_CMD);
|
|
179
|
+
debug('Removed launcher script');
|
|
180
|
+
}
|
|
181
|
+
} catch { /* ignore */ }
|
|
182
|
+
|
|
183
|
+
// Remove Task Scheduler entry (try both PowerShell and schtasks)
|
|
184
|
+
try {
|
|
185
|
+
execSync(
|
|
186
|
+
`powershell.exe -NoProfile -Command "Unregister-ScheduledTask -TaskName '${SERVICE_NAME}' -Confirm:\\$false"`,
|
|
187
|
+
{ stdio: 'pipe', timeout: 15000 },
|
|
188
|
+
);
|
|
134
189
|
debug('Removed Task Scheduler entry');
|
|
135
|
-
} catch {
|
|
190
|
+
} catch {
|
|
191
|
+
try {
|
|
192
|
+
execSync(`schtasks /delete /tn "${SERVICE_NAME}" /f`, { stdio: 'pipe' });
|
|
193
|
+
debug('Removed Task Scheduler entry (schtasks)');
|
|
194
|
+
} catch { /* ignore — may not have been registered */ }
|
|
195
|
+
}
|
|
136
196
|
}
|
|
137
197
|
|
|
138
198
|
function _registerMacOS(cmd) {
|