workday-daemon 0.17.2 → 0.19.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.
Files changed (43) hide show
  1. package/README.md +3 -0
  2. package/dist/cli.js +196 -39
  3. package/dist/cli.js.map +1 -1
  4. package/dist/core/constants.d.ts +7 -1
  5. package/dist/core/constants.js +11 -1
  6. package/dist/core/constants.js.map +1 -1
  7. package/dist/core/day-edit.d.ts +41 -0
  8. package/dist/core/day-edit.js +75 -0
  9. package/dist/core/day-edit.js.map +1 -0
  10. package/dist/core/session-tracker.d.ts +6 -0
  11. package/dist/core/session-tracker.js +9 -0
  12. package/dist/core/session-tracker.js.map +1 -1
  13. package/dist/core/types.d.ts +97 -1
  14. package/dist/core/types.js +11 -0
  15. package/dist/core/types.js.map +1 -1
  16. package/dist/http-server.d.ts +20 -0
  17. package/dist/http-server.js +266 -45
  18. package/dist/http-server.js.map +1 -1
  19. package/dist/push/month-report.d.ts +18 -0
  20. package/dist/push/month-report.js +132 -0
  21. package/dist/push/month-report.js.map +1 -0
  22. package/dist/push/push-log.d.ts +15 -0
  23. package/dist/push/push-log.js +70 -0
  24. package/dist/push/push-log.js.map +1 -0
  25. package/dist/push/reconcile.d.ts +21 -0
  26. package/dist/push/reconcile.js +292 -0
  27. package/dist/push/reconcile.js.map +1 -0
  28. package/dist/push/tempo-approvals.d.ts +12 -0
  29. package/dist/push/tempo-approvals.js +104 -0
  30. package/dist/push/tempo-approvals.js.map +1 -0
  31. package/dist/push/tempo-client.d.ts +63 -0
  32. package/dist/push/tempo-client.js +46 -10
  33. package/dist/push/tempo-client.js.map +1 -1
  34. package/dist/push/tempo-pusher.d.ts +4 -4
  35. package/dist/push/tempo-pusher.js +88 -162
  36. package/dist/push/tempo-pusher.js.map +1 -1
  37. package/dist/push/tempo-schedule.d.ts +10 -0
  38. package/dist/push/tempo-schedule.js +81 -0
  39. package/dist/push/tempo-schedule.js.map +1 -0
  40. package/dist/push/tempo-snapshot.d.ts +18 -0
  41. package/dist/push/tempo-snapshot.js +98 -0
  42. package/dist/push/tempo-snapshot.js.map +1 -0
  43. package/package.json +2 -2
@@ -0,0 +1,104 @@
1
+ import { readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getDataDir } from '../core/config.js';
4
+ import { APPROVAL_CACHE_FILE, APPROVAL_CACHE_TTL_MS } from '../core/constants.js';
5
+ import { TempoClient, TempoApiError } from './tempo-client.js';
6
+ import { getAccountId, isJiraConfigured } from './jira-client.js';
7
+ import { getMonthRange } from './month-report.js';
8
+ function getCachePath() {
9
+ return join(getDataDir(), APPROVAL_CACHE_FILE);
10
+ }
11
+ function readCache() {
12
+ const path = getCachePath();
13
+ if (!existsSync(path))
14
+ return {};
15
+ try {
16
+ return JSON.parse(readFileSync(path, 'utf-8'));
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ function writeCache(cache) {
23
+ try {
24
+ writeFileSync(getCachePath(), JSON.stringify(cache, null, 2), 'utf-8');
25
+ }
26
+ catch { /* best effort — cache is an optimization */ }
27
+ }
28
+ /** Drop the whole cache — Tempo-side timeSpentSeconds just changed. */
29
+ export function invalidateApprovalCache() {
30
+ try {
31
+ rmSync(getCachePath(), { force: true });
32
+ }
33
+ catch { /* best effort */ }
34
+ }
35
+ function monthKey(year, month) {
36
+ return `${year}-${String(month).padStart(2, '0')}`;
37
+ }
38
+ function fromEntry(entry, fromCache) {
39
+ return {
40
+ available: true,
41
+ period: entry.period,
42
+ statusKey: entry.statusKey,
43
+ requiredSeconds: entry.requiredSeconds,
44
+ timeSpentSeconds: entry.timeSpentSeconds,
45
+ canSubmit: entry.canSubmit,
46
+ fromCache,
47
+ };
48
+ }
49
+ /** Empty degraded response — also used by callers with no secrets at all. */
50
+ export function approvalUnavailable(reason) {
51
+ return {
52
+ available: false,
53
+ reason,
54
+ period: null,
55
+ statusKey: null,
56
+ requiredSeconds: null,
57
+ timeSpentSeconds: null,
58
+ canSubmit: false,
59
+ fromCache: false,
60
+ };
61
+ }
62
+ /**
63
+ * Timesheet approval for the period containing the given month (period =
64
+ * calendar month on approvalPeriod=MONTH instances). Needs both a Jira
65
+ * account (accountId lookup) and a Tempo token with approvals:view. Cached
66
+ * briefly; the cache is dropped after every successful push.
67
+ */
68
+ export async function resolveMonthApproval(year, month, secrets, forceRefresh = false) {
69
+ if (!secrets.Tempo_Token || secrets.Tempo_Token.trim().length === 0 || !isJiraConfigured(secrets)) {
70
+ return approvalUnavailable('no-token');
71
+ }
72
+ const key = monthKey(year, month);
73
+ const cache = readCache();
74
+ const cached = cache[key];
75
+ if (!forceRefresh && cached
76
+ && Date.now() - Date.parse(cached.fetchedAt) < APPROVAL_CACHE_TTL_MS) {
77
+ return fromEntry(cached, true);
78
+ }
79
+ const { from } = getMonthRange(year, month);
80
+ try {
81
+ const accountId = await getAccountId(secrets);
82
+ const client = new TempoClient(secrets.Tempo_Token);
83
+ const raw = await client.getUserTimesheetApproval(accountId, from);
84
+ const entry = {
85
+ fetchedAt: new Date().toISOString(),
86
+ period: raw.period ?? null,
87
+ statusKey: raw.status?.key ?? null,
88
+ requiredSeconds: raw.requiredSeconds ?? null,
89
+ timeSpentSeconds: raw.timeSpentSeconds ?? null,
90
+ canSubmit: raw.actions?.submit !== undefined,
91
+ };
92
+ cache[key] = entry;
93
+ writeCache(cache);
94
+ return fromEntry(entry, false);
95
+ }
96
+ catch (err) {
97
+ if (err instanceof TempoApiError && err.status === 403)
98
+ return approvalUnavailable('scope');
99
+ if (cached)
100
+ return fromEntry(cached, true); // stale beats nothing
101
+ return approvalUnavailable('error');
102
+ }
103
+ }
104
+ //# sourceMappingURL=tempo-approvals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tempo-approvals.js","sourceRoot":"","sources":["../../src/push/tempo-approvals.ts"],"names":[],"mappings":"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"}
@@ -14,6 +14,57 @@ export interface TempoWorkAttribute {
14
14
  readonly values?: readonly string[];
15
15
  readonly names?: Readonly<Record<string, string>>;
16
16
  }
17
+ /** Worklog as the /4/worklogs list endpoints return it. */
18
+ export interface RawTempoWorklog {
19
+ readonly tempoWorklogId: number;
20
+ readonly issue?: {
21
+ readonly id: number;
22
+ };
23
+ readonly issueId?: number;
24
+ readonly startDate: string;
25
+ readonly timeSpentSeconds: number;
26
+ readonly description?: string;
27
+ readonly updatedAt?: string;
28
+ readonly attributes?: {
29
+ readonly values?: ReadonlyArray<{
30
+ readonly key: string;
31
+ readonly value: string;
32
+ }>;
33
+ };
34
+ }
35
+ /** Map a raw API worklog to our full snapshot shape (description/activity/updatedAt kept). */
36
+ export declare function mapTempoWorklog(raw: RawTempoWorklog): TempoWorklog;
37
+ /** Tempo HTTP failure with the status preserved — 403 means "scope missing". */
38
+ export declare class TempoApiError extends Error {
39
+ readonly status: number;
40
+ constructor(status: number, message: string);
41
+ }
42
+ /** Raw /4/user-schedule day (DaySchedule in the Tempo OpenAPI spec). */
43
+ export interface TempoScheduleDay {
44
+ readonly date: string;
45
+ readonly requiredSeconds: number;
46
+ readonly type: string;
47
+ readonly holiday?: {
48
+ readonly name?: string;
49
+ readonly description?: string;
50
+ readonly durationSeconds?: number;
51
+ };
52
+ }
53
+ /** Raw /4/timesheet-approvals/user response (TimesheetApproval in the spec). */
54
+ export interface TempoTimesheetApproval {
55
+ readonly period: {
56
+ readonly from: string;
57
+ readonly to: string;
58
+ };
59
+ readonly requiredSeconds: number;
60
+ readonly timeSpentSeconds: number;
61
+ readonly status?: {
62
+ readonly key?: string;
63
+ };
64
+ readonly actions?: {
65
+ readonly submit?: unknown;
66
+ };
67
+ }
17
68
  export declare class TempoClient {
18
69
  private readonly token;
19
70
  private lastRequestTime;
@@ -22,6 +73,18 @@ export declare class TempoClient {
22
73
  private request;
23
74
  /** Fetch user worklogs for a date range with pagination */
24
75
  getUserWorklogs(accountId: string, from: string, to: string): Promise<TempoWorklog[]>;
76
+ /**
77
+ * Day schedule of the token's user (requiredSeconds, day type, holiday).
78
+ * Needs scope schemes:view — 403 without it. Not paginated: the endpoint
79
+ * takes only from/to and returns every day of the range.
80
+ */
81
+ getUserSchedule(from: string, to: string): Promise<TempoScheduleDay[]>;
82
+ /**
83
+ * Current timesheet approval for the period containing `from` (period
84
+ * granularity comes from globalconfiguration.approvalPeriod). Needs scope
85
+ * approvals:view — 403 without it.
86
+ */
87
+ getUserTimesheetApproval(accountId: string, from: string): Promise<TempoTimesheetApproval>;
25
88
  /** Fetch all work attributes (e.g. _Activity_ STATIC_LIST values). */
26
89
  getWorkAttributes(): Promise<TempoWorkAttribute[]>;
27
90
  /** Build the shared worklog body. activity defaults to Development; description omitted when empty. */
@@ -1,4 +1,25 @@
1
1
  import { TEMPO_BASE_URL, TEMPO_RATE_LIMIT_MS, ACTIVITY_ATTRIBUTE_KEY, DEFAULT_ACTIVITY } from '../core/constants.js';
2
+ /** Map a raw API worklog to our full snapshot shape (description/activity/updatedAt kept). */
3
+ export function mapTempoWorklog(raw) {
4
+ const activity = raw.attributes?.values?.find(v => v.key === ACTIVITY_ATTRIBUTE_KEY)?.value;
5
+ return {
6
+ tempoWorklogId: raw.tempoWorklogId,
7
+ issueId: raw.issue?.id ?? raw.issueId ?? 0,
8
+ startDate: raw.startDate,
9
+ timeSpentSeconds: raw.timeSpentSeconds,
10
+ ...(raw.description !== undefined ? { description: raw.description } : {}),
11
+ ...(activity !== undefined ? { activity } : {}),
12
+ ...(raw.updatedAt !== undefined ? { updatedAt: raw.updatedAt } : {}),
13
+ };
14
+ }
15
+ /** Tempo HTTP failure with the status preserved — 403 means "scope missing". */
16
+ export class TempoApiError extends Error {
17
+ status;
18
+ constructor(status, message) {
19
+ super(message);
20
+ this.status = status;
21
+ }
22
+ }
2
23
  export class TempoClient {
3
24
  token;
4
25
  lastRequestTime = 0;
@@ -29,7 +50,7 @@ export class TempoClient {
29
50
  });
30
51
  if (!res.ok) {
31
52
  const text = await res.text();
32
- throw new Error(`Tempo API ${res.status} ${method} ${path}: ${text.slice(0, 300)}`);
53
+ throw new TempoApiError(res.status, `Tempo API ${res.status} ${method} ${path}: ${text.slice(0, 300)}`);
33
54
  }
34
55
  // DELETE returns 204 No Content
35
56
  if (res.status === 204)
@@ -45,21 +66,36 @@ export class TempoClient {
45
66
  const path = `/4/worklogs/user/${encodeURIComponent(accountId)}`
46
67
  + `?from=${from}&to=${to}&offset=${offset}&limit=${limit}`;
47
68
  const response = await this.request('GET', path);
48
- for (const wl of response.results ?? []) {
49
- allWorklogs.push({
50
- tempoWorklogId: wl.tempoWorklogId,
51
- issueId: wl.issue?.id ?? wl.issueId ?? 0,
52
- startDate: wl.startDate,
53
- timeSpentSeconds: wl.timeSpentSeconds,
54
- });
69
+ const items = response.results ?? [];
70
+ for (const wl of items) {
71
+ allWorklogs.push(mapTempoWorklog(wl));
55
72
  }
56
- const meta = response.metadata;
57
- if (!meta || offset + limit >= meta.count)
73
+ // metadata.count is the PAGE size, not the total — the only reliable
74
+ // "more pages" signal is metadata.next (verified live 2026-07-07).
75
+ if (!response.metadata?.next || items.length === 0)
58
76
  break;
59
77
  offset += limit;
60
78
  }
61
79
  return allWorklogs;
62
80
  }
81
+ /**
82
+ * Day schedule of the token's user (requiredSeconds, day type, holiday).
83
+ * Needs scope schemes:view — 403 without it. Not paginated: the endpoint
84
+ * takes only from/to and returns every day of the range.
85
+ */
86
+ async getUserSchedule(from, to) {
87
+ const response = await this.request('GET', `/4/user-schedule?from=${from}&to=${to}`);
88
+ return response.results ?? [];
89
+ }
90
+ /**
91
+ * Current timesheet approval for the period containing `from` (period
92
+ * granularity comes from globalconfiguration.approvalPeriod). Needs scope
93
+ * approvals:view — 403 without it.
94
+ */
95
+ async getUserTimesheetApproval(accountId, from) {
96
+ const path = `/4/timesheet-approvals/user/${encodeURIComponent(accountId)}?from=${from}`;
97
+ return await this.request('GET', path);
98
+ }
63
99
  /** Fetch all work attributes (e.g. _Activity_ STATIC_LIST values). */
64
100
  async getWorkAttributes() {
65
101
  const response = await this.request('GET', '/4/work-attributes?limit=100');
@@ -1 +1 @@
1
- {"version":3,"file":"tempo-client.js","sourceRoot":"","sources":["../../src/push/tempo-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tempo-client.js","sourceRoot":"","sources":["../../src/push/tempo-client.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
- import { type AppConfig, type Secrets, type TaskDayReport, type TempoWorklog, type JiraIssue, type PushPlanEntry, type PushLogEntry, type PushResult, type PushResponse } from '../core/types.js';
1
+ import { type AppConfig, type Secrets, type PushPlanEntry, type PushResult, type PushResponse } from '../core/types.js';
2
2
  import { TempoClient } from './tempo-client.js';
3
- /** Build a plan by comparing report entries with push log and existing Tempo worklogs */
4
- export declare function buildPushPlan(report: readonly TaskDayReport[], jiraMap: Map<string, JiraIssue>, pushLog: Record<string, PushLogEntry>, tempoWorklogs: readonly TempoWorklog[]): PushPlanEntry[];
3
+ import { buildPushPlan } from './reconcile.js';
4
+ export { buildPushPlan };
5
5
  /** Execute mutations from the plan, update push log */
6
6
  export declare function executePlan(plan: readonly PushPlanEntry[], tempoClient: TempoClient, accountId: string): Promise<PushResult>;
7
7
  interface RunPushOptions {
@@ -11,7 +11,7 @@ interface RunPushOptions {
11
11
  readonly config: AppConfig;
12
12
  readonly secrets: Secrets;
13
13
  readonly filePath?: string;
14
+ readonly force?: boolean;
14
15
  }
15
16
  /** Full push pipeline: build report → resolve Jira → fetch Tempo → plan → execute */
16
17
  export declare function runPush(options: RunPushOptions): Promise<PushResponse>;
17
- export {};
@@ -1,161 +1,21 @@
1
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { getDataDir } from '../core/config.js';
1
+ import { readFileSync } from 'node:fs';
4
2
  import { readDailyLog, writeDailyLog } from '../core/daily-log.js';
5
- import { PUSH_LOG_FILE, TEMPO_TOLERANCE_SECONDS } from '../core/constants.js';
6
3
  import { DayStatus } from '../core/types.js';
7
4
  import { buildReport } from './report-builder.js';
8
5
  import { getAccountId, resolveIssueIds } from './jira-client.js';
9
6
  import { TempoClient } from './tempo-client.js';
10
- // ─── Push log persistence ────────────────────────────────────────────────
11
- function getPushLogPath() {
12
- return join(getDataDir(), PUSH_LOG_FILE);
13
- }
14
- function loadPushLog() {
15
- const path = getPushLogPath();
16
- if (!existsSync(path))
17
- return {};
18
- try {
19
- return JSON.parse(readFileSync(path, 'utf-8'));
20
- }
21
- catch {
22
- return {};
23
- }
24
- }
25
- function savePushLog(log) {
26
- writeFileSync(getPushLogPath(), JSON.stringify(log, null, 2), 'utf-8');
27
- }
28
- function pushLogKey(date, task, entryId) {
29
- return entryId ? `${date}|${task}|m:${entryId}` : `${date}|${task}`;
30
- }
7
+ import { invalidateApprovalCache } from './tempo-approvals.js';
8
+ import { loadPushLog, savePushLog, pushLogKey, loadTombstones, removeTombstonesByWorklogIds } from './push-log.js';
9
+ import { refreshSnapshotsInRange } from './tempo-snapshot.js';
10
+ import { buildPushPlan, formatHours } from './reconcile.js';
31
11
  // ─── Push plan ───────────────────────────────────────────────────────────
32
- /** Build a plan by comparing report entries with push log and existing Tempo worklogs */
33
- export function buildPushPlan(report, jiraMap, pushLog, tempoWorklogs) {
34
- const plan = [];
35
- // Index Tempo worklogs by (date, issueId) for fast lookup
36
- const tempoByKey = new Map();
37
- for (const wl of tempoWorklogs) {
38
- const key = `${wl.startDate}|${wl.issueId}`;
39
- const list = tempoByKey.get(key) ?? [];
40
- list.push(wl);
41
- tempoByKey.set(key, list);
42
- }
43
- // Worklogs we own as MANUAL entries — excluded from the session aggregate so a
44
- // session line never treats its own task's manual worklogs as extra/foreign.
45
- const manualOwnedTempoIds = new Set();
46
- for (const [key, logEntry] of Object.entries(pushLog)) {
47
- if (key.includes('|m:'))
48
- manualOwnedTempoIds.add(logEntry.tempoWorklogId);
49
- }
50
- // Track which Tempo worklogs are accounted for by our report
51
- const accountedTempoIds = new Set();
52
- for (const entry of report) {
53
- const jira = jiraMap.get(entry.task);
54
- if (!jira) {
55
- plan.push({
56
- date: entry.date, task: entry.task, targetSeconds: entry.totalSeconds,
57
- action: 'error', detail: 'Unresolved in Jira',
58
- kind: entry.kind, entryId: entry.entryId, description: entry.description, activity: entry.activity,
59
- });
60
- continue;
61
- }
62
- const tempoMatches = tempoByKey.get(`${entry.date}|${jira.issueId}`) ?? [];
63
- // ── Manual entry: its own worklog, keyed by entryId (stable across edits) ──
64
- if (entry.kind === 'manual') {
65
- const base = {
66
- date: entry.date, task: entry.task, targetSeconds: entry.totalSeconds,
67
- issueId: jira.issueId, kind: 'manual',
68
- entryId: entry.entryId, description: entry.description, activity: entry.activity,
69
- };
70
- const key = pushLogKey(entry.date, entry.task, entry.entryId);
71
- const logEntry = pushLog[key];
72
- const live = logEntry && tempoMatches.some(w => w.tempoWorklogId === logEntry.tempoWorklogId)
73
- ? logEntry : null;
74
- if (live) {
75
- accountedTempoIds.add(live.tempoWorklogId);
76
- const timeDrift = Math.abs(live.timeSpentSeconds - entry.totalSeconds) > TEMPO_TOLERANCE_SECONDS;
77
- const textDrift = (live.description ?? '') !== (entry.description ?? '')
78
- || (live.activity ?? '') !== (entry.activity ?? '');
79
- if (!timeDrift && !textDrift) {
80
- plan.push({ ...base, action: 'skip', detail: `Already pushed (${formatHours(live.timeSpentSeconds)})`, existingWorklogId: live.tempoWorklogId });
81
- }
82
- else {
83
- const detail = timeDrift
84
- ? `${formatHours(live.timeSpentSeconds)} → ${formatHours(entry.totalSeconds)}`
85
- : 'text/activity changed';
86
- plan.push({ ...base, action: 'update', detail, existingWorklogId: live.tempoWorklogId });
87
- }
88
- }
89
- else {
90
- // No live worklog of ours — create. Foreign worklogs on the same issue+date
91
- // are irrelevant: this entry has its own identity (entryId).
92
- plan.push({ ...base, action: 'create', detail: `New (${formatHours(entry.totalSeconds)})` });
93
- }
94
- continue;
95
- }
96
- // ── Session aggregate: one worklog per (date, task) ──
97
- const base = {
98
- date: entry.date, task: entry.task, targetSeconds: entry.totalSeconds,
99
- issueId: jira.issueId, kind: 'session',
100
- };
101
- const logEntry = pushLog[pushLogKey(entry.date, entry.task)];
102
- // Drop manual-owned worklogs: they belong to manual lines, not this aggregate.
103
- const sessionMatches = tempoMatches.filter(w => !manualOwnedTempoIds.has(w.tempoWorklogId));
104
- const validLogEntry = logEntry && sessionMatches.some(w => w.tempoWorklogId === logEntry.tempoWorklogId)
105
- ? logEntry : null;
106
- if (validLogEntry) {
107
- accountedTempoIds.add(validLogEntry.tempoWorklogId);
108
- const diff = Math.abs(validLogEntry.timeSpentSeconds - entry.totalSeconds);
109
- if (diff <= TEMPO_TOLERANCE_SECONDS) {
110
- plan.push({ ...base, action: 'skip', detail: `Already pushed (${formatHours(validLogEntry.timeSpentSeconds)})`, existingWorklogId: validLogEntry.tempoWorklogId });
111
- }
112
- else {
113
- plan.push({ ...base, action: 'update', detail: `${formatHours(validLogEntry.timeSpentSeconds)} → ${formatHours(entry.totalSeconds)}`, existingWorklogId: validLogEntry.tempoWorklogId });
114
- }
115
- for (const wl of sessionMatches)
116
- accountedTempoIds.add(wl.tempoWorklogId);
117
- }
118
- else if (sessionMatches.length > 0) {
119
- for (const wl of sessionMatches)
120
- accountedTempoIds.add(wl.tempoWorklogId);
121
- const existingTotal = sessionMatches.reduce((s, w) => s + w.timeSpentSeconds, 0);
122
- const diff = Math.abs(existingTotal - entry.totalSeconds);
123
- if (diff <= TEMPO_TOLERANCE_SECONDS) {
124
- plan.push({ ...base, action: 'skip', detail: `Exists in Tempo (${formatHours(existingTotal)})`, extraWorklogIds: sessionMatches.map(w => w.tempoWorklogId) });
125
- }
126
- else {
127
- plan.push({ ...base, action: 'create', detail: `Tempo has ${formatHours(existingTotal)}, adding ${formatHours(entry.totalSeconds)}`, extraWorklogIds: sessionMatches.map(w => w.tempoWorklogId) });
128
- }
129
- }
130
- else {
131
- plan.push({ ...base, action: 'create', detail: `New (${formatHours(entry.totalSeconds)})` });
132
- }
133
- }
134
- // Tempo-only worklogs (not matched by our report) — show, never mutate.
135
- for (const wl of tempoWorklogs) {
136
- if (accountedTempoIds.has(wl.tempoWorklogId))
137
- continue;
138
- let taskKey = `issue:${wl.issueId}`;
139
- for (const [key, jira] of jiraMap) {
140
- if (jira.issueId === wl.issueId) {
141
- taskKey = key;
142
- break;
143
- }
144
- }
145
- plan.push({
146
- date: wl.startDate, task: taskKey, targetSeconds: wl.timeSpentSeconds,
147
- action: 'skip', detail: `Tempo only (${formatHours(wl.timeSpentSeconds)})`,
148
- existingWorklogId: wl.tempoWorklogId, kind: 'session',
149
- });
150
- }
151
- // Sort by date, then task
152
- plan.sort((a, b) => a.date.localeCompare(b.date) || a.task.localeCompare(b.task));
153
- return plan;
154
- }
12
+ // The diff engine lives in reconcile.ts; re-exported here for existing callers.
13
+ export { buildPushPlan };
155
14
  // ─── Execute plan ────────────────────────────────────────────────────────
156
15
  /** Execute mutations from the plan, update push log */
157
16
  export async function executePlan(plan, tempoClient, accountId) {
158
17
  const pushLog = loadPushLog();
18
+ const deletedWorklogIds = new Set();
159
19
  let posted = 0;
160
20
  let updated = 0;
161
21
  let deleted = 0;
@@ -227,15 +87,49 @@ export async function executePlan(plan, tempoClient, accountId) {
227
87
  }
228
88
  break;
229
89
  }
90
+ case 'delete': {
91
+ if (!entry.existingWorklogId) {
92
+ failed++;
93
+ break;
94
+ }
95
+ try {
96
+ await tempoClient.deleteWorklog(entry.existingWorklogId);
97
+ delete pushLog[key]; // stray ownership, if any
98
+ deletedWorklogIds.add(entry.existingWorklogId);
99
+ deleted++;
100
+ console.log(` DEL ${entry.date} ${entry.task} ${formatHours(entry.targetSeconds)}`);
101
+ }
102
+ catch (err) {
103
+ failed++;
104
+ console.error(` FAIL DEL ${entry.date} ${entry.task}: ${err instanceof Error ? err.message : String(err)}`);
105
+ }
106
+ break;
107
+ }
230
108
  case 'error':
231
109
  failed++;
232
110
  break;
233
111
  }
234
112
  }
235
113
  savePushLog(pushLog);
114
+ if (deletedWorklogIds.size > 0) {
115
+ removeTombstonesByWorklogIds(deletedWorklogIds);
116
+ }
236
117
  return { posted, updated, deleted, skipped, failed };
237
118
  }
238
119
  // ─── Mark daily logs as pushed ───────────────────────────────────────────
120
+ /** Dates in [from, to] whose local day file exists — the stray-delete guard. */
121
+ function collectDatesWithData(from, to) {
122
+ const dates = new Set();
123
+ const current = new Date(from + 'T12:00:00Z');
124
+ const end = new Date(to + 'T12:00:00Z');
125
+ while (current <= end) {
126
+ const date = current.toISOString().slice(0, 10);
127
+ if (readDailyLog(date))
128
+ dates.add(date);
129
+ current.setUTCDate(current.getUTCDate() + 1);
130
+ }
131
+ return dates;
132
+ }
239
133
  function markDaysPushed(from, to) {
240
134
  const current = new Date(from + 'T12:00:00Z');
241
135
  const end = new Date(to + 'T12:00:00Z');
@@ -255,7 +149,7 @@ function markDaysPushed(from, to) {
255
149
  }
256
150
  /** Full push pipeline: build report → resolve Jira → fetch Tempo → plan → execute */
257
151
  export async function runPush(options) {
258
- const { from, to, commit, config, secrets, filePath } = options;
152
+ const { from, to, commit, config, secrets, filePath, force } = options;
259
153
  // Step 1: Build or load report
260
154
  let report;
261
155
  if (filePath) {
@@ -268,7 +162,15 @@ export async function runPush(options) {
268
162
  report = buildReport(from, to, config);
269
163
  console.log(`Built report: ${report.length} entries (${from} → ${to})`);
270
164
  }
271
- if (report.length === 0) {
165
+ // Local deletions may still need Tempo-side propagation even when the
166
+ // report is empty (a fully cleared pushed day, tombstoned entries).
167
+ const pushLog = loadPushLog();
168
+ const rangeTombstones = loadTombstones().filter(t => t.date >= from && t.date <= to);
169
+ const hasRangeOwnership = Object.keys(pushLog).some(k => {
170
+ const date = k.slice(0, 10);
171
+ return date >= from && date <= to;
172
+ });
173
+ if (report.length === 0 && rangeTombstones.length === 0 && !hasRangeOwnership) {
272
174
  return { dryRun: !commit, plan: [], result: { posted: 0, updated: 0, deleted: 0, skipped: 0, failed: 0 } };
273
175
  }
274
176
  // Step 2: Resolve Jira issue IDs
@@ -288,20 +190,52 @@ export async function runPush(options) {
288
190
  console.log(`Fetching Tempo worklogs (${from} → ${to})...`);
289
191
  const tempoWorklogs = await tempoClient.getUserWorklogs(accountId, from, to);
290
192
  console.log(`Found ${tempoWorklogs.length} existing worklog(s)`);
291
- // Step 4: Build plan
292
- const pushLog = loadPushLog();
293
- const plan = buildPushPlan(report, jiraMap, pushLog, tempoWorklogs);
193
+ // Step 4: Build plan. Tombstones whose worklog is already gone from Tempo
194
+ // (deleted remotely too) have nothing left to do — purge them silently.
195
+ const aliveIds = new Set(tempoWorklogs.map(w => w.tempoWorklogId));
196
+ const deadTombstones = rangeTombstones.filter(t => !aliveIds.has(t.tempoWorklogId));
197
+ if (deadTombstones.length > 0) {
198
+ removeTombstonesByWorklogIds(new Set(deadTombstones.map(t => t.tempoWorklogId)));
199
+ }
200
+ const plan = buildPushPlan(report, jiraMap, pushLog, tempoWorklogs, {
201
+ tombstones: rangeTombstones.filter(t => aliveIds.has(t.tempoWorklogId)),
202
+ from,
203
+ to,
204
+ datesWithData: collectDatesWithData(from, to),
205
+ });
294
206
  if (!commit) {
295
207
  return { dryRun: true, plan };
296
208
  }
209
+ // Conflict gate: "local wins" is a choice, not a default. A commit push
210
+ // that would overwrite Tempo-side edits stops here until the caller
211
+ // explicitly forces it — nothing (conflicted or not) is executed.
212
+ if (!force) {
213
+ const conflicted = plan.filter(e => e.conflict);
214
+ if (conflicted.length > 0) {
215
+ console.log(`Push blocked: ${conflicted.length} worklog(s) edited in Tempo since our push.`);
216
+ return { dryRun: false, plan, blockedByConflicts: true };
217
+ }
218
+ }
297
219
  // Step 5: Execute
298
- const actionable = plan.filter(e => e.action === 'create' || e.action === 'update');
220
+ const actionable = plan.filter(e => e.action === 'create' || e.action === 'update' || e.action === 'delete');
299
221
  if (actionable.length === 0) {
300
222
  console.log('Nothing to push.');
223
+ // Parity with Tempo is still a successful sync — seal the days, or an
224
+ // edited-then-reverted day stays Outdated forever (no mutation ever
225
+ // triggers the seal below). Plan errors (unresolved Jira) block it.
226
+ if (!plan.some(e => e.action === 'error')) {
227
+ markDaysPushed(from, to);
228
+ }
301
229
  return { dryRun: false, plan, result: { posted: 0, updated: 0, deleted: 0, skipped: 0, failed: 0 } };
302
230
  }
303
231
  console.log(`Executing ${actionable.length} mutation(s)...`);
304
232
  const result = await executePlan(plan, tempoClient, accountId);
233
+ // Tempo-side state changed: approval cache is stale; worklog snapshots are
234
+ // refetched right away so month statuses turn diff-based after every push.
235
+ if (result.posted > 0 || result.updated > 0 || result.deleted > 0) {
236
+ invalidateApprovalCache();
237
+ await refreshSnapshotsInRange(from, to, secrets, accountId);
238
+ }
305
239
  // Seal the day only on a clean push. A failed mutation (network, Jira limit, etc.)
306
240
  // must NOT mark the day pushed, or it would silently drop out of future syncs.
307
241
  if (result.failed === 0) {
@@ -312,12 +246,4 @@ export async function runPush(options) {
312
246
  }
313
247
  return { dryRun: false, plan, result };
314
248
  }
315
- // ─── Helpers ─────────────────────────────────────────────────────────────
316
- function formatHours(seconds) {
317
- const hours = seconds / 3600;
318
- const rounded1 = parseFloat(hours.toFixed(1));
319
- if (Math.abs(hours - rounded1) < 0.01)
320
- return `${hours.toFixed(1)}h`;
321
- return `${hours.toFixed(2)}h`;
322
- }
323
249
  //# sourceMappingURL=tempo-pusher.js.map