workday-daemon 0.10.0 → 0.15.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 +6 -4
- package/dist/cli.js +38 -49
- package/dist/cli.js.map +1 -1
- package/dist/core/config.d.ts +3 -3
- package/dist/core/config.js +29 -19
- package/dist/core/config.js.map +1 -1
- package/dist/core/constants.d.ts +6 -9
- package/dist/core/constants.js +13 -12
- package/dist/core/constants.js.map +1 -1
- package/dist/core/daily-log.d.ts +24 -12
- package/dist/core/daily-log.js +61 -59
- package/dist/core/daily-log.js.map +1 -1
- package/dist/core/gap-detector.d.ts +20 -0
- package/dist/core/gap-detector.js +20 -0
- package/dist/core/gap-detector.js.map +1 -0
- package/dist/core/janitor.d.ts +33 -0
- package/dist/core/janitor.js +156 -0
- package/dist/core/janitor.js.map +1 -0
- package/dist/core/session-tracker.d.ts +44 -14
- package/dist/core/session-tracker.js +131 -66
- package/dist/core/session-tracker.js.map +1 -1
- package/dist/core/status-renderer.js +3 -5
- package/dist/core/status-renderer.js.map +1 -1
- package/dist/core/stop-marker.d.ts +3 -0
- package/dist/core/stop-marker.js +32 -0
- package/dist/core/stop-marker.js.map +1 -0
- package/dist/core/types.d.ts +11 -27
- package/dist/core/types.js +1 -0
- package/dist/core/types.js.map +1 -1
- package/dist/daemon.d.ts +21 -3
- package/dist/daemon.js +82 -46
- package/dist/daemon.js.map +1 -1
- package/dist/http-server.d.ts +1 -1
- package/dist/http-server.js +38 -38
- package/dist/http-server.js.map +1 -1
- package/dist/push/report-builder.js +28 -6
- package/dist/push/report-builder.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { readdirSync, rmdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getDataDir } from './config.js';
|
|
4
|
+
import { readDailyLog, writeDailyLog, deleteDailyLog, trimTrailingPauses, generateSessionId } from './daily-log.js';
|
|
5
|
+
import { ClosedBy } from './types.js';
|
|
6
|
+
import { DEFAULT_ACTIVITY } from './constants.js';
|
|
7
|
+
/**
|
|
8
|
+
* One-time migration: legacy per-session manualAdjustments become one
|
|
9
|
+
* session-born ManualEntry per session (minutes summed, reasons dropped as
|
|
10
|
+
* noise, createdAt = first addedAt). Totals are unchanged and session-born
|
|
11
|
+
* entries fold back into the session aggregate at push time, so the day
|
|
12
|
+
* status is deliberately NOT flipped to Draft — a re-push produces the exact
|
|
13
|
+
* same worklogs. Idempotent: the legacy field is removed from the file.
|
|
14
|
+
*/
|
|
15
|
+
function migrateAdjustments(log) {
|
|
16
|
+
let migrated = 0;
|
|
17
|
+
for (const session of log.sessions) {
|
|
18
|
+
const adjustments = session.manualAdjustments;
|
|
19
|
+
if (!Array.isArray(adjustments))
|
|
20
|
+
continue;
|
|
21
|
+
const minutes = adjustments.reduce((sum, a) => sum + (a.minutes ?? 0), 0);
|
|
22
|
+
// Task-less sessions can't become entries (and were never pushable) —
|
|
23
|
+
// leave their legacy field untouched rather than dropping data.
|
|
24
|
+
if (minutes > 0 && session.task) {
|
|
25
|
+
const entry = {
|
|
26
|
+
id: generateSessionId(),
|
|
27
|
+
task: session.task,
|
|
28
|
+
minutes,
|
|
29
|
+
description: '',
|
|
30
|
+
activity: DEFAULT_ACTIVITY,
|
|
31
|
+
createdAt: adjustments[0]?.addedAt ?? session.lastSeenAt,
|
|
32
|
+
sourceSessionId: session.id,
|
|
33
|
+
};
|
|
34
|
+
if (!log.manualEntries)
|
|
35
|
+
log.manualEntries = [];
|
|
36
|
+
log.manualEntries.push(entry);
|
|
37
|
+
migrated += adjustments.length;
|
|
38
|
+
delete session.manualAdjustments;
|
|
39
|
+
}
|
|
40
|
+
else if (minutes === 0) {
|
|
41
|
+
delete session.manualAdjustments; // empty legacy array — plain cleanup
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return migrated;
|
|
45
|
+
}
|
|
46
|
+
/** A day record with zero confirmed facts — safe to delete. */
|
|
47
|
+
export function isEmptyDayLog(log) {
|
|
48
|
+
return log.sessions.length === 0
|
|
49
|
+
&& (log.manualEntries ?? []).length === 0
|
|
50
|
+
&& !log.pushedAt;
|
|
51
|
+
}
|
|
52
|
+
/** Close orphaned open sessions at their trimmed honest end. */
|
|
53
|
+
function closeOrphans(log) {
|
|
54
|
+
let count = 0;
|
|
55
|
+
for (const session of log.sessions) {
|
|
56
|
+
if (session.closedBy)
|
|
57
|
+
continue;
|
|
58
|
+
const trimmedEnd = trimTrailingPauses(session);
|
|
59
|
+
if (trimmedEnd)
|
|
60
|
+
session.lastSeenAt = trimmedEnd;
|
|
61
|
+
session.closedBy = ClosedBy.DaemonCrash;
|
|
62
|
+
count++;
|
|
63
|
+
}
|
|
64
|
+
return count;
|
|
65
|
+
}
|
|
66
|
+
/** Drop sessions that never reached ACTIVE — invisible to reports. */
|
|
67
|
+
function pruneNeverActivated(log) {
|
|
68
|
+
const before = log.sessions.length;
|
|
69
|
+
log.sessions = log.sessions.filter(s => !!s.activatedAt);
|
|
70
|
+
return before - log.sessions.length;
|
|
71
|
+
}
|
|
72
|
+
/** All stored dates (any content), oldest first. */
|
|
73
|
+
function listAllStoredDates() {
|
|
74
|
+
const dataDir = getDataDir();
|
|
75
|
+
if (!existsSync(dataDir))
|
|
76
|
+
return [];
|
|
77
|
+
const dates = [];
|
|
78
|
+
let monthDirs;
|
|
79
|
+
try {
|
|
80
|
+
monthDirs = readdirSync(dataDir).filter(d => /^\d{4}-\d{2}$/.test(d));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
for (const monthDir of monthDirs) {
|
|
86
|
+
let files;
|
|
87
|
+
try {
|
|
88
|
+
files = readdirSync(join(dataDir, monthDir));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
const match = file.match(/^\d{2}-(\d{2})\.json$/);
|
|
95
|
+
if (match)
|
|
96
|
+
dates.push(`${monthDir}-${match[1]}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return dates.sort();
|
|
100
|
+
}
|
|
101
|
+
/** Remove month dirs that ended up empty after file deletion. */
|
|
102
|
+
function removeEmptyMonthDirs() {
|
|
103
|
+
const dataDir = getDataDir();
|
|
104
|
+
if (!existsSync(dataDir))
|
|
105
|
+
return;
|
|
106
|
+
let monthDirs;
|
|
107
|
+
try {
|
|
108
|
+
monthDirs = readdirSync(dataDir).filter(d => /^\d{4}-\d{2}$/.test(d));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
for (const monthDir of monthDirs) {
|
|
114
|
+
try {
|
|
115
|
+
rmdirSync(join(dataDir, monthDir));
|
|
116
|
+
}
|
|
117
|
+
catch { /* not empty — fine */ }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Run the full startup pass for every stored date before `currentDate`.
|
|
122
|
+
* Corrupted files (unparseable, no backup) are left alone — never deleted
|
|
123
|
+
* blindly. Idempotent: a clean data dir is a no-op.
|
|
124
|
+
*/
|
|
125
|
+
export function runStartupJanitor(currentDate) {
|
|
126
|
+
let recovered = 0;
|
|
127
|
+
let pruned = 0;
|
|
128
|
+
let migrated = 0;
|
|
129
|
+
const deletedFiles = [];
|
|
130
|
+
for (const date of listAllStoredDates()) {
|
|
131
|
+
if (date >= currentDate)
|
|
132
|
+
continue;
|
|
133
|
+
const log = readDailyLog(date);
|
|
134
|
+
// Unparseable or structurally alien files are left alone — never delete
|
|
135
|
+
// what we don't understand.
|
|
136
|
+
if (!log || !Array.isArray(log.sessions))
|
|
137
|
+
continue;
|
|
138
|
+
const closedHere = closeOrphans(log);
|
|
139
|
+
const prunedHere = pruneNeverActivated(log);
|
|
140
|
+
const migratedHere = migrateAdjustments(log);
|
|
141
|
+
recovered += closedHere;
|
|
142
|
+
pruned += prunedHere;
|
|
143
|
+
migrated += migratedHere;
|
|
144
|
+
if (isEmptyDayLog(log)) {
|
|
145
|
+
deleteDailyLog(date);
|
|
146
|
+
deletedFiles.push(date);
|
|
147
|
+
}
|
|
148
|
+
else if (closedHere > 0 || prunedHere > 0 || migratedHere > 0) {
|
|
149
|
+
writeDailyLog(log);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (deletedFiles.length > 0)
|
|
153
|
+
removeEmptyMonthDirs();
|
|
154
|
+
return { recoveredSessions: recovered, prunedSessions: pruned, deletedFiles, migratedAdjustments: migrated };
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=janitor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"janitor.js","sourceRoot":"","sources":["../../src/core/janitor.ts"],"names":[],"mappings":"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"}
|
|
@@ -67,13 +67,29 @@ export declare class SessionTracker {
|
|
|
67
67
|
oldLog: DailyLog;
|
|
68
68
|
materialized: boolean;
|
|
69
69
|
};
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Delete a session from today's log — a user decision at review time
|
|
72
|
+
* (junk from a stray touch), never automatic. The record is removed
|
|
73
|
+
* entirely; if the day loses its last confirmed fact the file goes too
|
|
74
|
+
* (storage invariant), unless the day was already pushed — then the file
|
|
75
|
+
* stays as the push marker and the status drops to Draft for re-sync.
|
|
76
|
+
* Deleting an open session is allowed: current activity simply re-births
|
|
77
|
+
* a fresh candidate on the next tick.
|
|
78
|
+
*/
|
|
79
|
+
deleteSession(target: string): {
|
|
80
|
+
ok: boolean;
|
|
81
|
+
error?: string;
|
|
82
|
+
deleted?: Session;
|
|
83
|
+
dayFileDeleted?: boolean;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* "+ Add time" on a session card: a session-born manual entry. Task comes
|
|
87
|
+
* from the session, activity is Development, no description by design.
|
|
88
|
+
*/
|
|
89
|
+
addSessionEntry(target: string, minutes: number): {
|
|
74
90
|
ok: boolean;
|
|
75
91
|
error?: string;
|
|
76
|
-
|
|
92
|
+
entry?: ManualEntry;
|
|
77
93
|
};
|
|
78
94
|
/** Add a manual entry to today's log */
|
|
79
95
|
addManualEntry(input: {
|
|
@@ -95,8 +111,6 @@ export declare class SessionTracker {
|
|
|
95
111
|
ok: boolean;
|
|
96
112
|
error?: string;
|
|
97
113
|
};
|
|
98
|
-
/** Get manual minutes for a session */
|
|
99
|
-
getManualMinutes(session: Session): number;
|
|
100
114
|
/**
|
|
101
115
|
* Write current daily log to disk (atomic). No-op until the day is
|
|
102
116
|
* materialized: file exists ⇔ a confirmed fact happened (lazy day).
|
|
@@ -142,21 +156,37 @@ export declare class SessionTracker {
|
|
|
142
156
|
resumeAllSessions(): void;
|
|
143
157
|
/** Check if a session is currently paused */
|
|
144
158
|
hasOpenPause(session: Session): boolean;
|
|
159
|
+
/**
|
|
160
|
+
* Idle auto-close (honest session end): a session sitting in an open
|
|
161
|
+
* IdleTimeout pause longer than session.idleCloseHours closes with a
|
|
162
|
+
* trimmed end (= where the pause chain began). Manual pauses are exempt —
|
|
163
|
+
* a frozen session waits for the user; Superseded transitions into
|
|
164
|
+
* IdleTimeout once the score drains, so it is covered transitively.
|
|
165
|
+
* Runs at the start of each daemon tick, so after a PC-sleep gap the
|
|
166
|
+
* stale session closes before wake-up activity births a new one.
|
|
167
|
+
*/
|
|
168
|
+
closeIdleSessions(now: number): void;
|
|
169
|
+
/**
|
|
170
|
+
* Retroactive idle pause after an observation gap (PC sleep / hibernate,
|
|
171
|
+
* suspended process). Every open, unpaused session gets an IdleTimeout
|
|
172
|
+
* pause opened at its own pre-gap lastSeenAt — the last moment work was
|
|
173
|
+
* actually observed — so the gap never counts as work. Sessions already
|
|
174
|
+
* paused keep their pause (it spans the gap naturally; the chain trim
|
|
175
|
+
* yields the honest end either way). Returns the number paused.
|
|
176
|
+
*/
|
|
177
|
+
applyGapPauses(): number;
|
|
145
178
|
/**
|
|
146
179
|
* True when someone is plausibly mid-work: at least one open, unpaused
|
|
147
|
-
* session OR a
|
|
180
|
+
* session OR a candidate (a session mid-birth — live by construction:
|
|
181
|
+
* a drained candidate evaporates on the same tick). Used as the
|
|
148
182
|
* quiet-window gate for self-update restarts.
|
|
149
183
|
*/
|
|
150
184
|
hasActiveWork(): boolean;
|
|
151
185
|
/** Candidate sessions (in-memory, not yet confirmed facts) in birth order. */
|
|
152
186
|
getCandidates(): readonly Session[];
|
|
153
|
-
/**
|
|
154
|
-
* TTL evaporation: a candidate whose last activity is older than
|
|
155
|
-
* CANDIDATE_TTL_MINUTES vanishes without logs or traces (A-7).
|
|
156
|
-
*/
|
|
157
|
-
sweepCandidates(now: number): void;
|
|
158
187
|
private dropCandidate;
|
|
159
|
-
|
|
188
|
+
/** Evaporate every candidate (rollover, observation gap — state is stale). */
|
|
189
|
+
dropAllCandidates(): void;
|
|
160
190
|
private findOpenSession;
|
|
161
191
|
/** Open session first, then candidate — for baseSha/ledger poll context. */
|
|
162
192
|
private findOpenOrCandidateSession;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
-
import { SessionState, ClosedBy, SignalType, PauseSource } from './types.js';
|
|
2
|
+
import { SessionState, ClosedBy, DayStatus, SignalType, PauseSource } from './types.js';
|
|
3
3
|
import { applyLedgerUpdate, countSessionCommits, createEmptyLedger } from './commit-ledger.js';
|
|
4
4
|
import { isDayMaterialized } from './day-lifecycle.js';
|
|
5
|
-
import {
|
|
6
|
-
import { generateSessionId, createEmptyEvidence, createEmptyLog, writeDailyLog, addSignal, addManualAdjustment, addManualEntry, editManualEntry, resolveSessionTarget, computeManualMinutes, getOpenPause, } from './daily-log.js';
|
|
5
|
+
import { generateSessionId, createEmptyEvidence, createEmptyLog, writeDailyLog, deleteDailyLog, addSignal, addManualEntry, editManualEntry, resolveSessionTarget, getOpenPause, trimTrailingPauses, } from './daily-log.js';
|
|
7
6
|
import { computeWorkingDate, getSensitivityForRepo, resolveSensitivityTicks, writeConfig } from './config.js';
|
|
7
|
+
import { DEFAULT_ACTIVITY } from './constants.js';
|
|
8
8
|
/**
|
|
9
9
|
* Manages session lifecycle within a DailyLog.
|
|
10
10
|
*
|
|
@@ -33,21 +33,19 @@ export class SessionTracker {
|
|
|
33
33
|
lastEvaluatorResult = null;
|
|
34
34
|
// In-memory candidate sessions (born from activity, not yet confirmed
|
|
35
35
|
// facts) keyed by repo name. Promoted to dailyLog.sessions by the
|
|
36
|
-
// evaluator (score > 0 && leader); evaporate
|
|
36
|
+
// evaluator (score > 0 && leader); evaporate when the stamina score
|
|
37
|
+
// drains to zero, on checkout away, or at rollover.
|
|
37
38
|
candidates = new Map();
|
|
38
39
|
// Lazy-day gate: a day loaded from disk keeps being written; a fresh draft
|
|
39
40
|
// materializes only on the first confirmed fact (activation/manual entry).
|
|
40
41
|
loadedFromDisk;
|
|
41
42
|
onSessionClosed = null;
|
|
42
43
|
constructor(config, initialLog) {
|
|
43
|
-
const today = computeWorkingDate(Date.now(), config.
|
|
44
|
+
const today = computeWorkingDate(Date.now(), config.boundaryHour, config.timezone);
|
|
44
45
|
this.config = config;
|
|
45
46
|
this.dailyLog = initialLog ?? createEmptyLog(today, config);
|
|
46
47
|
this.loadedFromDisk = initialLog !== undefined;
|
|
47
48
|
// Normalize old logs that lack new fields
|
|
48
|
-
if (this.dailyLog.dayStartedAt === undefined) {
|
|
49
|
-
this.dailyLog.dayStartedAt = null;
|
|
50
|
-
}
|
|
51
49
|
if (!this.dailyLog.manualEntries) {
|
|
52
50
|
this.dailyLog.manualEntries = [];
|
|
53
51
|
}
|
|
@@ -55,8 +53,6 @@ export class SessionTracker {
|
|
|
55
53
|
for (const session of this.dailyLog.sessions) {
|
|
56
54
|
if (!session.pauses)
|
|
57
55
|
session.pauses = [];
|
|
58
|
-
if (!session.manualAdjustments)
|
|
59
|
-
session.manualAdjustments = [];
|
|
60
56
|
if (session.activatedAt === undefined)
|
|
61
57
|
session.activatedAt = null;
|
|
62
58
|
if (session.evidence.linesAdded === undefined)
|
|
@@ -134,23 +130,17 @@ export class SessionTracker {
|
|
|
134
130
|
// 3. Candidate lifecycle — sessions are born from activity, never from
|
|
135
131
|
// the mere presence of a task branch (checkout alone is not activity).
|
|
136
132
|
let candidate = this.candidates.get(repoName);
|
|
137
|
-
if (candidate && candidate.
|
|
133
|
+
if (candidate && candidate.task !== result.task) {
|
|
138
134
|
this.dropCandidate(repoName);
|
|
139
135
|
candidate = undefined;
|
|
140
136
|
}
|
|
141
137
|
if (!candidate) {
|
|
142
138
|
if (!hasActivity)
|
|
143
139
|
return; // watching — nothing exists yet
|
|
144
|
-
candidate =
|
|
145
|
-
session: this.createSession(repoName, result.task, result.branch, now),
|
|
146
|
-
lastActivityAt: Date.now(),
|
|
147
|
-
};
|
|
140
|
+
candidate = this.createSession(repoName, result.task, result.branch, now);
|
|
148
141
|
this.candidates.set(repoName, candidate);
|
|
149
142
|
}
|
|
150
|
-
|
|
151
|
-
candidate.lastActivityAt = Date.now();
|
|
152
|
-
}
|
|
153
|
-
this.updateSessionTick(candidate.session, result, now);
|
|
143
|
+
this.updateSessionTick(candidate, result, now);
|
|
154
144
|
}
|
|
155
145
|
/**
|
|
156
146
|
* Close orphaned sessions from a previous daemon crash.
|
|
@@ -160,7 +150,9 @@ export class SessionTracker {
|
|
|
160
150
|
let count = 0;
|
|
161
151
|
for (const session of this.dailyLog.sessions) {
|
|
162
152
|
if (!session.closedBy) {
|
|
163
|
-
|
|
153
|
+
const trimmedEnd = trimTrailingPauses(session);
|
|
154
|
+
if (trimmedEnd)
|
|
155
|
+
session.lastSeenAt = trimmedEnd;
|
|
164
156
|
session.closedBy = ClosedBy.DaemonCrash;
|
|
165
157
|
count++;
|
|
166
158
|
}
|
|
@@ -219,27 +211,62 @@ export class SessionTracker {
|
|
|
219
211
|
const completedLog = this.dailyLog;
|
|
220
212
|
const materialized = isDayMaterialized(completedLog, this.loadedFromDisk);
|
|
221
213
|
this.dropAllCandidates();
|
|
222
|
-
const newDate = computeWorkingDate(Date.now(), this.config.
|
|
214
|
+
const newDate = computeWorkingDate(Date.now(), this.config.boundaryHour, this.config.timezone);
|
|
223
215
|
this.dailyLog = createEmptyLog(newDate, this.config);
|
|
224
216
|
this.loadedFromDisk = false;
|
|
225
217
|
this.lastEvaluatorResult = null;
|
|
226
218
|
return { oldLog: completedLog, materialized };
|
|
227
219
|
}
|
|
228
|
-
/**
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Delete a session from today's log — a user decision at review time
|
|
222
|
+
* (junk from a stray touch), never automatic. The record is removed
|
|
223
|
+
* entirely; if the day loses its last confirmed fact the file goes too
|
|
224
|
+
* (storage invariant), unless the day was already pushed — then the file
|
|
225
|
+
* stays as the push marker and the status drops to Draft for re-sync.
|
|
226
|
+
* Deleting an open session is allowed: current activity simply re-births
|
|
227
|
+
* a fresh candidate on the next tick.
|
|
228
|
+
*/
|
|
229
|
+
deleteSession(target) {
|
|
230
|
+
const session = resolveSessionTarget(this.dailyLog, target);
|
|
231
|
+
if (!session) {
|
|
232
|
+
return { ok: false, error: `Session not found: ${target}` };
|
|
233
|
+
}
|
|
234
|
+
this.dailyLog.sessions = this.dailyLog.sessions.filter(s => s !== session);
|
|
235
|
+
this.onSessionClosed?.(session.id);
|
|
236
|
+
if (this.dailyLog.sessions.length === 0
|
|
237
|
+
&& (this.dailyLog.manualEntries ?? []).length === 0
|
|
238
|
+
&& !this.dailyLog.pushedAt) {
|
|
239
|
+
deleteDailyLog(this.dailyLog.date);
|
|
240
|
+
this.loadedFromDisk = false; // day de-materializes back into a draft
|
|
241
|
+
return { ok: true, deleted: session, dayFileDeleted: true };
|
|
242
|
+
}
|
|
243
|
+
if (this.dailyLog.status !== DayStatus.Draft) {
|
|
244
|
+
this.dailyLog.status = DayStatus.Draft; // pushed day edited → re-sync on next push
|
|
232
245
|
}
|
|
246
|
+
this.flush();
|
|
247
|
+
return { ok: true, deleted: session, dayFileDeleted: false };
|
|
233
248
|
}
|
|
234
|
-
/**
|
|
235
|
-
|
|
249
|
+
/**
|
|
250
|
+
* "+ Add time" on a session card: a session-born manual entry. Task comes
|
|
251
|
+
* from the session, activity is Development, no description by design.
|
|
252
|
+
*/
|
|
253
|
+
addSessionEntry(target, minutes) {
|
|
236
254
|
const session = resolveSessionTarget(this.dailyLog, target);
|
|
237
255
|
if (!session) {
|
|
238
256
|
return { ok: false, error: `Session not found: ${target}` };
|
|
239
257
|
}
|
|
258
|
+
if (!session.task) {
|
|
259
|
+
return { ok: false, error: 'Session has no task — log the time with `workday log <task> ...`' };
|
|
260
|
+
}
|
|
240
261
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
262
|
+
const entry = addManualEntry(this.dailyLog, {
|
|
263
|
+
task: session.task,
|
|
264
|
+
minutes,
|
|
265
|
+
description: '',
|
|
266
|
+
activity: DEFAULT_ACTIVITY,
|
|
267
|
+
sourceSessionId: session.id,
|
|
268
|
+
}, this.config);
|
|
269
|
+
return { ok: true, entry };
|
|
243
270
|
}
|
|
244
271
|
catch (err) {
|
|
245
272
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
@@ -265,10 +292,6 @@ export class SessionTracker {
|
|
|
265
292
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
266
293
|
}
|
|
267
294
|
}
|
|
268
|
-
/** Get manual minutes for a session */
|
|
269
|
-
getManualMinutes(session) {
|
|
270
|
-
return computeManualMinutes(session);
|
|
271
|
-
}
|
|
272
295
|
/**
|
|
273
296
|
* Write current daily log to disk (atomic). No-op until the day is
|
|
274
297
|
* materialized: file exists ⇔ a confirmed fact happened (lazy day).
|
|
@@ -339,8 +362,8 @@ export class SessionTracker {
|
|
|
339
362
|
}
|
|
340
363
|
ticks.push(this.toTickInput(session, resultMap));
|
|
341
364
|
}
|
|
342
|
-
for (const
|
|
343
|
-
ticks.push(this.toTickInput(
|
|
365
|
+
for (const session of this.candidates.values()) {
|
|
366
|
+
ticks.push(this.toTickInput(session, resultMap));
|
|
344
367
|
}
|
|
345
368
|
return ticks;
|
|
346
369
|
}
|
|
@@ -390,18 +413,25 @@ export class SessionTracker {
|
|
|
390
413
|
}
|
|
391
414
|
}
|
|
392
415
|
}
|
|
393
|
-
// Candidate
|
|
394
|
-
//
|
|
395
|
-
//
|
|
416
|
+
// Candidate lifecycle by evaluator score. Promotion: leadership (implies
|
|
417
|
+
// score > 0) — the first active tick when the repo leads; the session
|
|
418
|
+
// becomes a confirmed fact, pushed into the log and flushed immediately
|
|
419
|
+
// (the moment the day materializes). Fade-out: score drained to zero —
|
|
420
|
+
// the same decay that idle-pauses a session, but an unconfirmed fact has
|
|
421
|
+
// nothing to pause, so it evaporates. Raw score, not isIdleTimeout: an
|
|
422
|
+
// Always-on repo must not breed an immortal candidate.
|
|
396
423
|
let promoted = false;
|
|
397
|
-
for (const [repoName,
|
|
398
|
-
const sessionScore = result.scores.get(
|
|
424
|
+
for (const [repoName, session] of [...this.candidates]) {
|
|
425
|
+
const sessionScore = result.scores.get(session.id);
|
|
399
426
|
if (!sessionScore)
|
|
400
427
|
continue;
|
|
401
|
-
if (sessionScore.score
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
428
|
+
if (sessionScore.score === 0) {
|
|
429
|
+
this.dropCandidate(repoName);
|
|
430
|
+
}
|
|
431
|
+
else if (result.leaderId === session.id) {
|
|
432
|
+
session.state = SessionState.Active;
|
|
433
|
+
session.activatedAt = now;
|
|
434
|
+
this.dailyLog.sessions.push(session);
|
|
405
435
|
this.candidates.delete(repoName);
|
|
406
436
|
promoted = true;
|
|
407
437
|
}
|
|
@@ -475,9 +505,55 @@ export class SessionTracker {
|
|
|
475
505
|
hasOpenPause(session) {
|
|
476
506
|
return getOpenPause(session) !== null;
|
|
477
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* Idle auto-close (honest session end): a session sitting in an open
|
|
510
|
+
* IdleTimeout pause longer than session.idleCloseHours closes with a
|
|
511
|
+
* trimmed end (= where the pause chain began). Manual pauses are exempt —
|
|
512
|
+
* a frozen session waits for the user; Superseded transitions into
|
|
513
|
+
* IdleTimeout once the score drains, so it is covered transitively.
|
|
514
|
+
* Runs at the start of each daemon tick, so after a PC-sleep gap the
|
|
515
|
+
* stale session closes before wake-up activity births a new one.
|
|
516
|
+
*/
|
|
517
|
+
closeIdleSessions(now) {
|
|
518
|
+
const thresholdMs = this.config.session.idleCloseHours * 3_600_000;
|
|
519
|
+
if (thresholdMs <= 0)
|
|
520
|
+
return;
|
|
521
|
+
const nowIso = new Date(now).toISOString();
|
|
522
|
+
for (const session of this.dailyLog.sessions) {
|
|
523
|
+
if (session.closedBy)
|
|
524
|
+
continue;
|
|
525
|
+
const pause = getOpenPause(session);
|
|
526
|
+
if (!pause || pause.source !== PauseSource.IdleTimeout)
|
|
527
|
+
continue;
|
|
528
|
+
if (now - Date.parse(pause.from) >= thresholdMs) {
|
|
529
|
+
this.closeSession(session, ClosedBy.IdleTimeout, nowIso);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Retroactive idle pause after an observation gap (PC sleep / hibernate,
|
|
535
|
+
* suspended process). Every open, unpaused session gets an IdleTimeout
|
|
536
|
+
* pause opened at its own pre-gap lastSeenAt — the last moment work was
|
|
537
|
+
* actually observed — so the gap never counts as work. Sessions already
|
|
538
|
+
* paused keep their pause (it spans the gap naturally; the chain trim
|
|
539
|
+
* yields the honest end either way). Returns the number paused.
|
|
540
|
+
*/
|
|
541
|
+
applyGapPauses() {
|
|
542
|
+
let count = 0;
|
|
543
|
+
for (const session of this.dailyLog.sessions) {
|
|
544
|
+
if (session.closedBy)
|
|
545
|
+
continue;
|
|
546
|
+
if (this.hasOpenPause(session))
|
|
547
|
+
continue;
|
|
548
|
+
session.pauses.push({ from: session.lastSeenAt, to: null, source: PauseSource.IdleTimeout });
|
|
549
|
+
count++;
|
|
550
|
+
}
|
|
551
|
+
return count;
|
|
552
|
+
}
|
|
478
553
|
/**
|
|
479
554
|
* True when someone is plausibly mid-work: at least one open, unpaused
|
|
480
|
-
* session OR a
|
|
555
|
+
* session OR a candidate (a session mid-birth — live by construction:
|
|
556
|
+
* a drained candidate evaporates on the same tick). Used as the
|
|
481
557
|
* quiet-window gate for self-update restarts.
|
|
482
558
|
*/
|
|
483
559
|
hasActiveWork() {
|
|
@@ -487,27 +563,16 @@ export class SessionTracker {
|
|
|
487
563
|
// ─── Candidates ──────────────────────────────────────────────────────
|
|
488
564
|
/** Candidate sessions (in-memory, not yet confirmed facts) in birth order. */
|
|
489
565
|
getCandidates() {
|
|
490
|
-
return [...this.candidates.values()]
|
|
491
|
-
}
|
|
492
|
-
/**
|
|
493
|
-
* TTL evaporation: a candidate whose last activity is older than
|
|
494
|
-
* CANDIDATE_TTL_MINUTES vanishes without logs or traces (A-7).
|
|
495
|
-
*/
|
|
496
|
-
sweepCandidates(now) {
|
|
497
|
-
const ttlMs = CANDIDATE_TTL_MINUTES * MS_PER_MINUTE;
|
|
498
|
-
for (const [repoName, entry] of [...this.candidates]) {
|
|
499
|
-
if (now - entry.lastActivityAt > ttlMs) {
|
|
500
|
-
this.dropCandidate(repoName);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
566
|
+
return [...this.candidates.values()];
|
|
503
567
|
}
|
|
504
568
|
dropCandidate(repoName) {
|
|
505
|
-
const
|
|
506
|
-
if (!
|
|
569
|
+
const session = this.candidates.get(repoName);
|
|
570
|
+
if (!session)
|
|
507
571
|
return;
|
|
508
572
|
this.candidates.delete(repoName);
|
|
509
|
-
this.onSessionClosed?.(
|
|
573
|
+
this.onSessionClosed?.(session.id);
|
|
510
574
|
}
|
|
575
|
+
/** Evaporate every candidate (rollover, observation gap — state is stale). */
|
|
511
576
|
dropAllCandidates() {
|
|
512
577
|
for (const repoName of [...this.candidates.keys()]) {
|
|
513
578
|
this.dropCandidate(repoName);
|
|
@@ -519,7 +584,7 @@ export class SessionTracker {
|
|
|
519
584
|
}
|
|
520
585
|
/** Open session first, then candidate — for baseSha/ledger poll context. */
|
|
521
586
|
findOpenOrCandidateSession(repo) {
|
|
522
|
-
return this.findOpenSession(repo) ?? this.candidates.get(repo)
|
|
587
|
+
return this.findOpenSession(repo) ?? this.candidates.get(repo) ?? null;
|
|
523
588
|
}
|
|
524
589
|
createSession(repo, task, branch, now) {
|
|
525
590
|
// Every session starts from a clean slate — nothing is inherited from
|
|
@@ -542,7 +607,6 @@ export class SessionTracker {
|
|
|
542
607
|
closedBy: null,
|
|
543
608
|
evidence: createEmptyEvidence(),
|
|
544
609
|
pauses: [],
|
|
545
|
-
manualAdjustments: [],
|
|
546
610
|
baseSha: null,
|
|
547
611
|
mergeBaseSha: null,
|
|
548
612
|
evidenceBaseline: null,
|
|
@@ -553,10 +617,11 @@ export class SessionTracker {
|
|
|
553
617
|
closeSession(session, reason, now) {
|
|
554
618
|
if (session.closedBy)
|
|
555
619
|
return; // already closed
|
|
556
|
-
//
|
|
557
|
-
|
|
620
|
+
// Honest end: an open trailing pause chain means work actually stopped
|
|
621
|
+
// where the chain began — trim it and end the session there.
|
|
622
|
+
const trimmedEnd = trimTrailingPauses(session);
|
|
558
623
|
session.closedBy = reason;
|
|
559
|
-
session.lastSeenAt = now;
|
|
624
|
+
session.lastSeenAt = trimmedEnd ?? now;
|
|
560
625
|
// state stays as 'pending' or 'active' — preserved for reporting
|
|
561
626
|
this.onSessionClosed?.(session.id);
|
|
562
627
|
}
|