@knpkv/jira-clockify 1.4.0 → 1.5.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 +72 -18
- package/dist/package.json +1 -1
- package/dist/src/agent/schedule.d.ts +6 -5
- package/dist/src/agent/schedule.d.ts.map +1 -1
- package/dist/src/agent/schedule.js +117 -102
- package/dist/src/agent/schedule.js.map +1 -1
- package/dist/src/agent/sessions.d.ts +27 -33
- package/dist/src/agent/sessions.d.ts.map +1 -1
- package/dist/src/agent/sessions.js +121 -83
- package/dist/src/agent/sessions.js.map +1 -1
- package/dist/src/agent/writePlanning.d.ts +2 -0
- package/dist/src/agent/writePlanning.d.ts.map +1 -1
- package/dist/src/agent/writePlanning.js +4 -1
- package/dist/src/agent/writePlanning.js.map +1 -1
- package/dist/src/cli/agentWrite.d.ts.map +1 -1
- package/dist/src/cli/agentWrite.js +12 -5
- package/dist/src/cli/agentWrite.js.map +1 -1
- package/dist/src/cli/auth.d.ts +39 -2
- package/dist/src/cli/auth.d.ts.map +1 -1
- package/dist/src/cli/auth.js +29 -5
- package/dist/src/cli/auth.js.map +1 -1
- package/dist/src/cli/config.d.ts +2 -2
- package/dist/src/cli/config.d.ts.map +1 -1
- package/dist/src/cli/config.js +34 -6
- package/dist/src/cli/config.js.map +1 -1
- package/dist/src/cli/layers.d.ts.map +1 -1
- package/dist/src/cli/layers.js +2 -2
- package/dist/src/cli/layers.js.map +1 -1
- package/dist/src/cli/reconcile.d.ts +6 -0
- package/dist/src/cli/reconcile.d.ts.map +1 -1
- package/dist/src/cli/reconcile.js +2 -0
- package/dist/src/cli/reconcile.js.map +1 -1
- package/dist/src/cli/setup.d.ts +1 -1
- package/dist/src/cli/setup.d.ts.map +1 -1
- package/dist/src/services/AgentSessionReader.d.ts +26 -12
- package/dist/src/services/AgentSessionReader.d.ts.map +1 -1
- package/dist/src/services/AgentSessionReader.js +163 -69
- package/dist/src/services/AgentSessionReader.js.map +1 -1
- package/dist/src/services/CodexTranscript.d.ts +50 -13
- package/dist/src/services/CodexTranscript.d.ts.map +1 -1
- package/dist/src/services/CodexTranscript.js +49 -13
- package/dist/src/services/CodexTranscript.js.map +1 -1
- package/dist/src/services/ConfigService.d.ts +2 -0
- package/dist/src/services/ConfigService.d.ts.map +1 -1
- package/dist/src/services/ConfigService.js +4 -1
- package/dist/src/services/ConfigService.js.map +1 -1
- package/dist/src/services/ProviderSnapshots.d.ts +41 -0
- package/dist/src/services/ProviderSnapshots.d.ts.map +1 -0
- package/dist/src/services/ProviderSnapshots.js +81 -0
- package/dist/src/services/ProviderSnapshots.js.map +1 -0
- package/dist/src/services/ReconcileService.d.ts +11 -3
- package/dist/src/services/ReconcileService.d.ts.map +1 -1
- package/dist/src/services/ReconcileService.js +212 -109
- package/dist/src/services/ReconcileService.js.map +1 -1
- package/dist/src/services/SavedEntries.d.ts +24 -4
- package/dist/src/services/SavedEntries.d.ts.map +1 -1
- package/dist/src/services/SavedEntries.js +335 -19
- package/dist/src/services/SavedEntries.js.map +1 -1
- package/dist/src/services/SessionAttributor.d.ts +12 -0
- package/dist/src/services/SessionAttributor.d.ts.map +1 -1
- package/dist/src/services/SessionAttributor.js +13 -1
- package/dist/src/services/SessionAttributor.js.map +1 -1
- package/dist/src/services/SessionLine.d.ts +29 -0
- package/dist/src/services/SessionLine.d.ts.map +1 -0
- package/dist/src/services/SessionLine.js +8 -0
- package/dist/src/services/SessionLine.js.map +1 -0
- package/dist/src/services/SourceLedger.d.ts +84 -1
- package/dist/src/services/SourceLedger.d.ts.map +1 -1
- package/dist/src/services/SourceLedger.js +178 -12
- package/dist/src/services/SourceLedger.js.map +1 -1
- package/dist/src/services/TicketRanking.d.ts +39 -0
- package/dist/src/services/TicketRanking.d.ts.map +1 -0
- package/dist/src/services/TicketRanking.js +87 -0
- package/dist/src/services/TicketRanking.js.map +1 -0
- package/dist/src/services/internal/retryTransport.d.ts +9 -0
- package/dist/src/services/internal/retryTransport.d.ts.map +1 -0
- package/dist/src/services/internal/retryTransport.js +15 -0
- package/dist/src/services/internal/retryTransport.js.map +1 -0
- package/dist/src/testing/fakeHeadless.d.ts +11 -0
- package/dist/src/testing/fakeHeadless.d.ts.map +1 -1
- package/dist/src/testing/fakeHeadless.js +66 -3
- package/dist/src/testing/fakeHeadless.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
|
@@ -21,36 +21,38 @@
|
|
|
21
21
|
*
|
|
22
22
|
* @module
|
|
23
23
|
*/
|
|
24
|
-
import { ClockifyApi
|
|
25
|
-
import { JiraApiClient
|
|
24
|
+
import { ClockifyApi } from "@knpkv/clockify-api-client";
|
|
25
|
+
import { JiraApiClient } from "@knpkv/jira-api-client";
|
|
26
26
|
import { JiraAuth } from "@knpkv/jira-cli/JiraAuth";
|
|
27
27
|
import * as Clock from "effect/Clock";
|
|
28
28
|
import * as Context from "effect/Context";
|
|
29
29
|
import * as Data from "effect/Data";
|
|
30
30
|
import * as Effect from "effect/Effect";
|
|
31
31
|
import * as FileSystem from "effect/FileSystem";
|
|
32
|
-
import * as HttpClient from "effect/http/HttpClient";
|
|
33
32
|
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
34
33
|
import * as HttpClientResponse from "effect/http/HttpClientResponse";
|
|
35
34
|
import * as Layer from "effect/Layer";
|
|
36
35
|
import * as Path from "effect/Path";
|
|
37
36
|
import * as Predicate from "effect/Predicate";
|
|
38
|
-
import * as Redacted from "effect/Redacted";
|
|
39
37
|
import * as Schema from "effect/Schema";
|
|
40
|
-
import { activeWindows, attributeSession, bothSides, buildSessionProposals, deterministicAttribution, expandHomePath, splitCredits } from "../agent/sessions.js";
|
|
38
|
+
import { activeWindows, attributeSession, bothSides, buildSessionProposals, deterministicAttribution, expandHomePath, mergeSpansWithinDays, splitCredits, splitSessionAttribution } from "../agent/sessions.js";
|
|
41
39
|
import * as SourceConsumption from "../agent/sourceConsumption.js";
|
|
42
40
|
import * as WriterGuard from "../cli/writerGuard.js";
|
|
43
41
|
import { localDay, nextLocalMidnight, splitIntervalByLocalDay } from "../utils/time.js";
|
|
44
42
|
import { AgentSessionReader } from "./AgentSessionReader.js";
|
|
45
|
-
import { ClockifyAuth } from "./ClockifyAuth.js";
|
|
46
43
|
import { ConfigService } from "./ConfigService.js";
|
|
47
44
|
import { HomeDirectory } from "./HomeDirectory.js";
|
|
48
45
|
import { postJiraWorklog } from "./internal/JiraWorklogPost.js";
|
|
46
|
+
import { retryTransport } from "./internal/retryTransport.js";
|
|
49
47
|
import { readJiraDeletionEvidence } from "./ProviderDeletion.js";
|
|
48
|
+
import { make as makeProviderSnapshots } from "./ProviderSnapshots.js";
|
|
50
49
|
import { jiraWorklogDescription } from "./SavedEntries.js";
|
|
51
50
|
import { SessionAttributor } from "./SessionAttributor.js";
|
|
52
51
|
import { SourceLedger } from "./SourceLedger.js";
|
|
52
|
+
import { candidateFact, readRankingFacts, ticketPriority } from "./TicketRanking.js";
|
|
53
53
|
import { TimerService } from "./TimerService.js";
|
|
54
|
+
/** Concurrent Jira worklog reads per tally. Bounded so a large week does not trip rate limits. */
|
|
55
|
+
const JIRA_WORKLOG_READ_CONCURRENCY = 8;
|
|
54
56
|
export { bothSides } from "../agent/sessions.js";
|
|
55
57
|
const mergeIntervals = (intervals) => {
|
|
56
58
|
const sorted = [...intervals].sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
|
|
@@ -137,6 +139,23 @@ const batchRequests = (requests) => {
|
|
|
137
139
|
batches.push(current);
|
|
138
140
|
return batches;
|
|
139
141
|
};
|
|
142
|
+
/** Raw ignored presence, unioned per ticket/day so parallel sessions on one key never duplicate it. */
|
|
143
|
+
const ignoredCredits = (evidence, ignoredTickets) => {
|
|
144
|
+
const windowsByTicket = new Map();
|
|
145
|
+
for (const window of evidence) {
|
|
146
|
+
if (window.ticketKey === null || !ignoredTickets.has(window.ticketKey))
|
|
147
|
+
continue;
|
|
148
|
+
windowsByTicket.set(window.ticketKey, [...(windowsByTicket.get(window.ticketKey) ?? []), ...window.spans]);
|
|
149
|
+
}
|
|
150
|
+
return [...windowsByTicket].flatMap(([ticketKey, spans]) => {
|
|
151
|
+
const secondsByDay = new Map();
|
|
152
|
+
for (const span of mergeSpansWithinDays(spans)) {
|
|
153
|
+
const day = localDay(new Date(span.startMs));
|
|
154
|
+
secondsByDay.set(day, (secondsByDay.get(day) ?? 0) + (span.endMs - span.startMs) / 1000);
|
|
155
|
+
}
|
|
156
|
+
return [...secondsByDay].map(([day, seconds]) => ({ ticketKey, day, seconds: Math.floor(seconds) }));
|
|
157
|
+
}).sort((a, b) => a.ticketKey.localeCompare(b.ticketKey) || a.day.localeCompare(b.day));
|
|
158
|
+
};
|
|
140
159
|
/**
|
|
141
160
|
* Provider totals that may size a write. Compatibility Jira rows remain visible, but cannot settle
|
|
142
161
|
* an executable gap until the same credential and account have been verified.
|
|
@@ -307,10 +326,8 @@ const toRawWorklog = (value) => {
|
|
|
307
326
|
};
|
|
308
327
|
};
|
|
309
328
|
export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
310
|
-
const clockifyAuth = yield* ClockifyAuth;
|
|
311
329
|
const jira = yield* JiraApiClient;
|
|
312
330
|
const jiraAuth = yield* JiraAuth;
|
|
313
|
-
const httpClient = yield* HttpClient.HttpClient;
|
|
314
331
|
const config = yield* ConfigService;
|
|
315
332
|
const fileSystem = yield* FileSystem.FileSystem;
|
|
316
333
|
const path = yield* Path.Path;
|
|
@@ -320,68 +337,9 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
320
337
|
const attributor = yield* SessionAttributor;
|
|
321
338
|
const sourceLedger = yield* SourceLedger;
|
|
322
339
|
const provideGuard = (effect) => effect.pipe(Effect.provideService(ConfigService, config), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path));
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
const auth = yield* getAuth;
|
|
327
|
-
const endpoint = yield* Effect.try({
|
|
328
|
-
try: () => new URL(auth.baseUrl),
|
|
329
|
-
catch: (cause) => new ReconcileError({ message: "Configured Clockify endpoint is invalid", cause })
|
|
330
|
-
});
|
|
331
|
-
if (!["https:", "http:"].includes(endpoint.protocol) || endpoint.username !== "" ||
|
|
332
|
-
endpoint.password !== "" || endpoint.search !== "" || endpoint.hash !== "")
|
|
333
|
-
return yield* new ReconcileError({ message: "Configured Clockify endpoint is invalid" });
|
|
334
|
-
const canonicalEndpoint = `${endpoint.origin}${endpoint.pathname.replace(/\/+$/u, "")}`;
|
|
335
|
-
const pinnedAuth = { ...auth, baseUrl: canonicalEndpoint };
|
|
336
|
-
const client = makeClockifyApi(httpClient, pinnedAuth);
|
|
337
|
-
const user = yield* client.getLoggedUser(undefined).pipe(Effect.mapError((cause) => new ReconcileError({ message: "Cannot verify the Clockify account", cause })));
|
|
338
|
-
if (user.id === "" || auth.userId !== user.id || auth.workspaceId === "" || auth.baseUrl === "") {
|
|
339
|
-
return yield* new ReconcileError({ message: "Configured Clockify account does not match the credential" });
|
|
340
|
-
}
|
|
341
|
-
return {
|
|
342
|
-
auth: pinnedAuth,
|
|
343
|
-
client,
|
|
344
|
-
scope: JSON.stringify(["clockify-v3", canonicalEndpoint, auth.workspaceId, user.id]),
|
|
345
|
-
legacyScope: JSON.stringify([auth.workspaceId, user.id])
|
|
346
|
-
};
|
|
347
|
-
});
|
|
348
|
-
/** Bind verification and worklog POST to one OAuth credential and selected site. */
|
|
349
|
-
const jiraWriteState = Effect.gen(function* () {
|
|
350
|
-
const login = yield* Effect.result(jiraAuth.isLoggedIn());
|
|
351
|
-
if (login._tag === "Failure")
|
|
352
|
-
return { availability: "unverified", snapshot: null };
|
|
353
|
-
if (!login.success)
|
|
354
|
-
return { availability: "not-logged-in", snapshot: null };
|
|
355
|
-
const tokenResult = yield* Effect.result(jiraAuth.getAccessToken());
|
|
356
|
-
const profileResult = yield* Effect.result(jiraAuth.getActiveProfile());
|
|
357
|
-
if (tokenResult._tag === "Failure" || profileResult._tag === "Failure") {
|
|
358
|
-
return { availability: "unverified", snapshot: null };
|
|
359
|
-
}
|
|
360
|
-
const token = tokenResult.success;
|
|
361
|
-
const profile = profileResult.success;
|
|
362
|
-
if (profile === null || profile.token.cloud_id === "" || profile.token.site_url === "" ||
|
|
363
|
-
profile.token.access_token !== Redacted.value(token))
|
|
364
|
-
return { availability: "unverified", snapshot: null };
|
|
365
|
-
const client = makeJiraApi(httpClient, {
|
|
366
|
-
baseUrl: "",
|
|
367
|
-
auth: { type: "oauth2", accessToken: token, cloudId: profile.token.cloud_id }
|
|
368
|
-
});
|
|
369
|
-
const live = yield* client.getCurrentUser({}).pipe(Effect.orElseSucceed(() => null));
|
|
370
|
-
if (live?.accountId === undefined || live.accountId === "") {
|
|
371
|
-
return { availability: "unverified", snapshot: null };
|
|
372
|
-
}
|
|
373
|
-
return {
|
|
374
|
-
availability: "verified",
|
|
375
|
-
snapshot: {
|
|
376
|
-
client,
|
|
377
|
-
ledgerScope: JSON.stringify([profile.token.cloud_id, live.accountId]),
|
|
378
|
-
heldScope: JSON.stringify([profile.token.cloud_id, profile.token.site_url, live.accountId]),
|
|
379
|
-
accountId: live.accountId,
|
|
380
|
-
cloudId: profile.token.cloud_id,
|
|
381
|
-
siteUrl: profile.token.site_url
|
|
382
|
-
}
|
|
383
|
-
};
|
|
384
|
-
});
|
|
340
|
+
const providerSnapshots = yield* makeProviderSnapshots;
|
|
341
|
+
const clockifyWriteSnapshot = providerSnapshots.clockify.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
342
|
+
const jiraWriteState = providerSnapshots.jira;
|
|
385
343
|
const jiraWriteSnapshot = jiraWriteState.pipe(Effect.map((state) => state.snapshot));
|
|
386
344
|
/**
|
|
387
345
|
* How many entries to ask Clockify for at a time, and how many pages to accept.
|
|
@@ -472,9 +430,9 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
472
430
|
let nextStartAt = 0;
|
|
473
431
|
let expectedTotal;
|
|
474
432
|
for (;;) {
|
|
475
|
-
const page = yield* client.getIssueWorklog(issueKey, {
|
|
433
|
+
const page = yield* retryTransport(client.getIssueWorklog(issueKey, {
|
|
476
434
|
params: { startAt: nextStartAt, startedAfter, startedBefore }
|
|
477
|
-
}).pipe(Effect.mapError((cause) => new ReconcileError({ message: `Jira worklog fetch failed for ${issueKey}: ${String(cause)}`, cause })));
|
|
435
|
+
})).pipe(Effect.mapError((cause) => new ReconcileError({ message: `Jira worklog fetch failed for ${issueKey}: ${String(cause)}`, cause })));
|
|
478
436
|
const { maxResults, startAt, total, worklogs: entries } = page;
|
|
479
437
|
if (startAt !== nextStartAt ||
|
|
480
438
|
maxResults === undefined || !Number.isSafeInteger(maxResults) || maxResults < 0 ||
|
|
@@ -507,7 +465,12 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
507
465
|
}
|
|
508
466
|
});
|
|
509
467
|
// Tally the current user's Jira worklogs in the period by (ticket, day).
|
|
510
|
-
const jiraTally = (period, requiredIssueKeys = [], pinned = undefined
|
|
468
|
+
const jiraTally = (period, requiredIssueKeys = [], pinned = undefined,
|
|
469
|
+
/**
|
|
470
|
+
* `window` searches every issue I logged in the period and adds the required keys;
|
|
471
|
+
* `required-only` reads just the required keys, to top up a tally that already searched.
|
|
472
|
+
*/
|
|
473
|
+
scope = "window") => Effect.gen(function* () {
|
|
511
474
|
// A verified snapshot pins the account, site and credential-backed client for every page.
|
|
512
475
|
// Falling back is only for a side without a usable write credential; that side cannot be
|
|
513
476
|
// written because its source scope remains null, but independent providers may still proceed.
|
|
@@ -542,19 +505,19 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
542
505
|
// "Jira is short by the whole day" — which `watch` would then post on top of hours Jira
|
|
543
506
|
// already holds. A window of more than a hundred distinct issues is an ordinary week.
|
|
544
507
|
const jql = `worklogAuthor = currentUser() AND worklogDate >= "${fromDay}" AND worklogDate <= "${toDay}"`;
|
|
545
|
-
const searchPage = (pageToken) => client.searchIssuesUsingJql({
|
|
508
|
+
const searchPage = (pageToken) => retryTransport(client.searchIssuesUsingJql({
|
|
546
509
|
params: {
|
|
547
510
|
jql,
|
|
548
511
|
maxResults: 100,
|
|
549
512
|
fields: ["key"],
|
|
550
513
|
...((pageToken !== undefined) && { nextPageToken: pageToken })
|
|
551
514
|
}
|
|
552
|
-
}).pipe(Effect.mapError((e) => new ReconcileError({ message: `Jira search failed: ${String(e)}`, cause: e })));
|
|
515
|
+
})).pipe(Effect.mapError((e) => new ReconcileError({ message: `Jira search failed: ${String(e)}`, cause: e })));
|
|
553
516
|
const searched = [];
|
|
554
517
|
let pageToken = undefined;
|
|
555
518
|
// Bounded so a server that keeps handing back a token cannot spin here for ever; hitting the
|
|
556
519
|
// bound fails the run rather than proceeding on a partial tally.
|
|
557
|
-
for (let page = 0; page < 50; page++) {
|
|
520
|
+
for (let page = 0; scope === "window" && page < 50; page++) {
|
|
558
521
|
const result = yield* searchPage(pageToken);
|
|
559
522
|
const issues = Predicate.isObject(result) ? result["issues"] : undefined;
|
|
560
523
|
// A page with no `issues` at all is not an empty page. The generated success schema makes
|
|
@@ -601,8 +564,12 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
601
564
|
}
|
|
602
565
|
const toMs = period.to.getTime();
|
|
603
566
|
const tally = [];
|
|
604
|
-
|
|
605
|
-
|
|
567
|
+
// Every issue's worklogs are read before any is tallied, a few at a time: a week of forty
|
|
568
|
+
// tickets read one after another made every confirmation wait on forty round trips. Tallying
|
|
569
|
+
// stays in issue order, so the result is the same as a sequential read.
|
|
570
|
+
const worklogsByIssue = yield* Effect.forEach(issueKeys, (issueKey) => readJiraWorklogs(client, issueKey, lookupFromMs - 1, toMs), { concurrency: JIRA_WORKLOG_READ_CONCURRENCY });
|
|
571
|
+
for (const [issueIndex, issueKey] of issueKeys.entries()) {
|
|
572
|
+
const worklogs = worklogsByIssue[issueIndex] ?? [];
|
|
606
573
|
for (const wl of worklogs) {
|
|
607
574
|
// Only this user's worklogs (the JQL narrows issues, not individual worklog authors).
|
|
608
575
|
const author = wl.author?.accountId;
|
|
@@ -641,9 +608,11 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
641
608
|
// A side that is out is not called at all. That is the point of the option for someone who
|
|
642
609
|
// tracks in one system: no Clockify workspace to configure, no Jira login to keep alive, and
|
|
643
610
|
// no request whose failure could stop a run that never needed it.
|
|
644
|
-
const jiraSideRead = sides.jira
|
|
645
|
-
?
|
|
646
|
-
:
|
|
611
|
+
const jiraSideRead = !sides.jira
|
|
612
|
+
? Effect.succeed([])
|
|
613
|
+
: options?.jiraPreRead !== undefined
|
|
614
|
+
? Effect.succeed(options.jiraPreRead)
|
|
615
|
+
: jiraTally(period, options?.jiraIssueKeys, options?.jiraSnapshot);
|
|
647
616
|
const [clockifySide, jiraSide] = yield* Effect.all([
|
|
648
617
|
sides.clockify
|
|
649
618
|
? clockifyTally(period, options?.clockifySnapshot)
|
|
@@ -1118,7 +1087,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1118
1087
|
// `attributeSession` remains the single place the precedence is actually applied.
|
|
1119
1088
|
const needsAgent = options.mode === "deterministic" ?
|
|
1120
1089
|
[] :
|
|
1121
|
-
sessions.filter((session) => deterministicAttribution(session,
|
|
1090
|
+
sessions.filter((session) => deterministicAttribution(session, options) === null &&
|
|
1122
1091
|
session.candidateKeys.length > 0);
|
|
1123
1092
|
const batches = batchRequests(needsAgent);
|
|
1124
1093
|
if (needsAgent.length > 0) {
|
|
@@ -1135,7 +1104,11 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1135
1104
|
const batchResults = yield* Effect.all(batches.map((batch, batchIndex) => attributor.attribute(batch.map((session) => ({
|
|
1136
1105
|
sessionId: session.sessionId,
|
|
1137
1106
|
candidateKeys: session.candidateKeys,
|
|
1138
|
-
digest: session.digest
|
|
1107
|
+
digest: session.digest,
|
|
1108
|
+
candidateFacts: new Map(session.candidateKeys.flatMap((key) => {
|
|
1109
|
+
const fact = options.candidateFact?.(key);
|
|
1110
|
+
return fact === undefined ? [] : [[key, fact]];
|
|
1111
|
+
}))
|
|
1139
1112
|
})), (activity) => options.onProgress({
|
|
1140
1113
|
_tag: "AgentActivity",
|
|
1141
1114
|
batch: batchIndex + 1,
|
|
@@ -1145,7 +1118,10 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1145
1118
|
const bySession = new Map(answers.map((answer) => [answer.sessionId, answer.choice]));
|
|
1146
1119
|
return batch.map((session) => {
|
|
1147
1120
|
const declined = { _tag: "None" };
|
|
1148
|
-
const
|
|
1121
|
+
const answer = bySession.get(session.sessionId) ?? declined;
|
|
1122
|
+
const choice = answer._tag === "Chosen" && options.ignoredTickets.has(answer.ticketKey)
|
|
1123
|
+
? declined
|
|
1124
|
+
: answer;
|
|
1149
1125
|
return {
|
|
1150
1126
|
session,
|
|
1151
1127
|
choice,
|
|
@@ -1200,7 +1176,8 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1200
1176
|
const attributions = sessions.map((session) => attributeSession(session, {
|
|
1201
1177
|
standingMap: options.standingMap,
|
|
1202
1178
|
agentChoice: chosen.get(session.sessionId) ?? null,
|
|
1203
|
-
confidenceFloor: options.confidenceFloor
|
|
1179
|
+
confidenceFloor: options.confidenceFloor,
|
|
1180
|
+
ignoredTickets: options.ignoredTickets
|
|
1204
1181
|
}));
|
|
1205
1182
|
return { attributions, attributorAvailable, attributorCalls: batches.length };
|
|
1206
1183
|
});
|
|
@@ -1224,15 +1201,40 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1224
1201
|
return requests.map((request) => byId.get(request.id) ?? null);
|
|
1225
1202
|
});
|
|
1226
1203
|
/** Both first reads and post-write refreshes subtract live totals through the same path. */
|
|
1227
|
-
const refreshRecordedTime = Effect.fn("ReconcileService.refreshRecordedTime")(function* (period,
|
|
1204
|
+
const refreshRecordedTime = Effect.fn("ReconcileService.refreshRecordedTime")(function* (period, cached, options,
|
|
1205
|
+
/** A Jira write state the caller already resolved, so one read never verifies twice. */
|
|
1206
|
+
pinnedJiraState,
|
|
1207
|
+
/**
|
|
1208
|
+
* A Jira tally taken under `pinnedJiraState` earlier in the same read, with the issue keys it
|
|
1209
|
+
* was required to cover. Reused only when it covers every key this refresh needs.
|
|
1210
|
+
*/
|
|
1211
|
+
preReadJira) {
|
|
1212
|
+
const ignoredTickets = new Set((yield* config.get).sessionIgnoredTickets);
|
|
1213
|
+
const ignored = [...new Map([
|
|
1214
|
+
...ignoredCredits(cached.sessionEvidence ?? [], ignoredTickets),
|
|
1215
|
+
...cached.ignored.filter((entry) => ignoredTickets.has(entry.ticketKey))
|
|
1216
|
+
].map((entry) => [`${entry.ticketKey}:${entry.day}`, entry])).values()];
|
|
1217
|
+
const previous = {
|
|
1218
|
+
...cached,
|
|
1219
|
+
attributed: cached.attributed.filter((row) => !ignoredTickets.has(row.ticketKey)),
|
|
1220
|
+
withheld: cached.withheld.filter((row) => !ignoredTickets.has(row.ticketKey)),
|
|
1221
|
+
ignoredTickets: [...ignoredTickets].sort(),
|
|
1222
|
+
ignored
|
|
1223
|
+
};
|
|
1228
1224
|
const { sides } = previous;
|
|
1225
|
+
const writeBlocked = {};
|
|
1229
1226
|
const stored = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
1230
1227
|
const clockifySnapshot = sides.clockify ? yield* clockifyWriteSnapshot : null;
|
|
1231
1228
|
if (clockifySnapshot !== null) {
|
|
1232
|
-
yield* sourceLedger.assertNoLegacyClockify(clockifySnapshot.legacyScope).pipe(Effect.
|
|
1229
|
+
yield* sourceLedger.assertNoLegacyClockify(clockifySnapshot.legacyScope).pipe(Effect.catchTag("SourceLedgerError", (cause) => {
|
|
1230
|
+
if (cause.reason !== "review-required")
|
|
1231
|
+
return Effect.fail(cause);
|
|
1232
|
+
writeBlocked.clockify = cause.message;
|
|
1233
|
+
return Effect.void;
|
|
1234
|
+
}), Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
1233
1235
|
}
|
|
1234
1236
|
const clockifyScope = clockifySnapshot?.scope ?? null;
|
|
1235
|
-
const jiraState = sides.jira ? yield* jiraWriteState : undefined;
|
|
1237
|
+
const jiraState = sides.jira ? pinnedJiraState ?? (yield* jiraWriteState) : undefined;
|
|
1236
1238
|
const jiraSnapshot = jiraState?.snapshot ?? null;
|
|
1237
1239
|
// Compatibility reads may still render Jira totals, but only one verified snapshot may grant
|
|
1238
1240
|
// durable source authority. Independent Clockify work remains executable while Jira is held.
|
|
@@ -1259,10 +1261,31 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1259
1261
|
...(options?.jiraIssueKeys ?? [])
|
|
1260
1262
|
])
|
|
1261
1263
|
];
|
|
1264
|
+
// Covered means read directly or found holding my time; anything else is read fresh, so reuse
|
|
1265
|
+
// is an optimization the result never depends on.
|
|
1266
|
+
const covered = new Set([
|
|
1267
|
+
...(preReadJira?.requiredKeys ?? []),
|
|
1268
|
+
...(preReadJira?.tally ?? []).map((row) => row.ticketKey)
|
|
1269
|
+
]);
|
|
1270
|
+
// Keys the pre-read did not cover — usually tickets the agent or a split chose — are read on
|
|
1271
|
+
// their own and merged. They held none of my time in the pre-read's search, so their rows
|
|
1272
|
+
// cannot duplicate it. If the top-up fails, the refresh falls back to a full read.
|
|
1273
|
+
const missingKeys = jiraIssueKeys.filter((key) => !covered.has(key));
|
|
1274
|
+
const topUp = preReadJira === undefined || pinnedJiraState === undefined || missingKeys.length === 0
|
|
1275
|
+
? undefined
|
|
1276
|
+
: yield* Effect.result(jiraTally(period, missingKeys, jiraSnapshot, "required-only"));
|
|
1277
|
+
const jiraPreRead = preReadJira === undefined || pinnedJiraState === undefined
|
|
1278
|
+
? undefined
|
|
1279
|
+
: missingKeys.length === 0
|
|
1280
|
+
? preReadJira.tally
|
|
1281
|
+
: topUp?._tag === "Success"
|
|
1282
|
+
? [...preReadJira.tally, ...topUp.success]
|
|
1283
|
+
: undefined;
|
|
1262
1284
|
const [{ recorded, unlinkedClockify }, excludedDays] = yield* Effect.all([
|
|
1263
1285
|
readRecorded(period, {
|
|
1264
1286
|
sides,
|
|
1265
1287
|
jiraIssueKeys,
|
|
1288
|
+
jiraPreRead,
|
|
1266
1289
|
clockifySnapshot: clockifySnapshot ?? undefined,
|
|
1267
1290
|
jiraSnapshot,
|
|
1268
1291
|
tolerateUnavailableJira: jiraState?.availability !== "verified"
|
|
@@ -1329,19 +1352,23 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1329
1352
|
scope,
|
|
1330
1353
|
fromMs: period.from.getTime(),
|
|
1331
1354
|
toMs: period.to.getTime()
|
|
1332
|
-
}, observed.map((entry) => ({ entryId: entry.id, startMs: entry.startMs })), verified, provider === "clockify" ? clockifySnapshot?.legacyScope : undefined).pipe(Effect.
|
|
1355
|
+
}, observed.map((entry) => ({ entryId: entry.id, startMs: entry.startMs })), verified, provider === "clockify" ? clockifySnapshot?.legacyScope : undefined).pipe(Effect.catchTag("SourceLedgerError", (cause) => {
|
|
1356
|
+
if (cause.reason !== "review-required")
|
|
1357
|
+
return Effect.fail(cause);
|
|
1358
|
+
writeBlocked[provider] = cause.message;
|
|
1359
|
+
return Effect.void;
|
|
1360
|
+
}), Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
1333
1361
|
}
|
|
1334
1362
|
const verifiedLedger = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
1335
|
-
if (previous.attributed.length > 0
|
|
1336
|
-
|
|
1363
|
+
if (previous.attributed.length > 0) {
|
|
1364
|
+
for (const entry of entries) {
|
|
1337
1365
|
const scope = entry.source === "clockify" ? clockifyScope : jiraScope;
|
|
1338
|
-
|
|
1366
|
+
if (scope !== null && entry.description !== null &&
|
|
1339
1367
|
SourceConsumption.markers(entry.description).length > 0 &&
|
|
1340
|
-
!verifiedLedger.bindings.some((binding) => binding.provider === entry.source && binding.entryId === entry.id && binding.scope === scope)
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
});
|
|
1368
|
+
!verifiedLedger.bindings.some((binding) => binding.provider === entry.source && binding.entryId === entry.id && binding.scope === scope)) {
|
|
1369
|
+
writeBlocked[entry.source] = "An unlinked source marker needs private manual review before session writes";
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1345
1372
|
}
|
|
1346
1373
|
const sourceEntries = verifiedLedger.bindings.filter((binding) => binding.provider === "clockify" ? binding.scope === clockifyScope : binding.scope === jiraScope).flatMap((binding) => {
|
|
1347
1374
|
const entry = entries.find((candidate) => candidate.source === binding.provider && candidate.id === binding.entryId);
|
|
@@ -1357,6 +1384,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1357
1384
|
const executableRecorded = recordedForExecution(recorded, jiraState?.availability);
|
|
1358
1385
|
return {
|
|
1359
1386
|
...previous,
|
|
1387
|
+
writeBlocked,
|
|
1360
1388
|
proposals: buildSessionProposals(previous.attributed, executableRecorded, {
|
|
1361
1389
|
minimumSeconds: MINIMUM_PROPOSAL_SECONDS,
|
|
1362
1390
|
excludedDays: excludedDays.map((excluded) => excluded.day),
|
|
@@ -1366,7 +1394,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1366
1394
|
day: slice.day,
|
|
1367
1395
|
intervals: slice.entry === undefined ? [] : [{ entry: slice.entry }]
|
|
1368
1396
|
}))
|
|
1369
|
-
}),
|
|
1397
|
+
}).map((proposal) => ({ ...proposal, writeBlocked })),
|
|
1370
1398
|
recorded,
|
|
1371
1399
|
unlinkedClockify,
|
|
1372
1400
|
sourceEntries,
|
|
@@ -1378,13 +1406,72 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1378
1406
|
const proposeFromSessions = (period, options) => Effect.gen(function* () {
|
|
1379
1407
|
const onProgress = options?.onProgress ?? (() => Effect.void);
|
|
1380
1408
|
const cfg = yield* config.get;
|
|
1409
|
+
const ignoredTickets = new Set(cfg.sessionIgnoredTickets);
|
|
1410
|
+
const standingMap = expandedStandingMap(cfg.sessionTicketMap, home);
|
|
1381
1411
|
const sessions = yield* sessionReader.read(period).pipe(Effect.mapError((error) => new ReconcileError({ message: error.message, cause: error })));
|
|
1382
1412
|
yield* onProgress({ _tag: "SessionsRead", count: sessions.length });
|
|
1383
|
-
const
|
|
1384
|
-
|
|
1413
|
+
const sides = options?.sides ?? bothSides;
|
|
1414
|
+
// Only a run about Jira reads Jira to rank: a Clockify-only week makes no Jira request. The
|
|
1415
|
+
// verified profile's client, so a sprint is never read from a different account's site.
|
|
1416
|
+
// Resolved once for the whole read: ranking and the first recorded refresh must see the
|
|
1417
|
+
// same verified authority, and resolving twice can observe two different answers. The pin
|
|
1418
|
+
// never outlives the read: every write path refreshes without it and verifies afresh.
|
|
1419
|
+
const jiraState = sides.jira ? yield* jiraWriteState : undefined;
|
|
1420
|
+
const rankingSnapshot = jiraState?.snapshot ?? undefined;
|
|
1421
|
+
// The Jira tally is read before attribution, so ranking and the attribution prompt see who
|
|
1422
|
+
// logged what, and the first refresh reuses it. Branch, path and standing targets and
|
|
1423
|
+
// ledger-bound tickets are required reads; any other candidate the tally lacks is unlogged.
|
|
1424
|
+
const preReadJira = rankingSnapshot === undefined ? undefined : yield* Effect.gen(function* () {
|
|
1425
|
+
const stored = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
|
|
1426
|
+
const requiredKeys = [
|
|
1427
|
+
...new Set([
|
|
1428
|
+
...sessions.flatMap((session) => {
|
|
1429
|
+
const target = deterministicAttribution(session, { standingMap });
|
|
1430
|
+
return target === null || ignoredTickets.has(target.ticketKey) ? [] : [target.ticketKey];
|
|
1431
|
+
}),
|
|
1432
|
+
...stored.bindings.filter((binding) => binding.provider === "jira" && binding.scope === rankingSnapshot.ledgerScope).map((binding) => binding.ticketKey)
|
|
1433
|
+
])
|
|
1434
|
+
];
|
|
1435
|
+
const tally = yield* Effect.result(jiraTally(period, requiredKeys, rankingSnapshot));
|
|
1436
|
+
return tally._tag === "Success" ? { tally: tally.success, requiredKeys } : undefined;
|
|
1437
|
+
});
|
|
1438
|
+
const ranking = rankingSnapshot !== undefined
|
|
1439
|
+
? yield* readRankingFacts((jql, nextPageToken) => retryTransport(rankingSnapshot.client.searchIssuesUsingJql({
|
|
1440
|
+
params: {
|
|
1441
|
+
jql,
|
|
1442
|
+
maxResults: 100,
|
|
1443
|
+
fields: ["key"],
|
|
1444
|
+
...((nextPageToken !== undefined) && { nextPageToken })
|
|
1445
|
+
}
|
|
1446
|
+
})), preReadJira?.tally)
|
|
1447
|
+
: {
|
|
1448
|
+
_tag: "Unknown",
|
|
1449
|
+
reason: sides.jira ? "Jira account is not verified" : "Jira is out of scope"
|
|
1450
|
+
};
|
|
1451
|
+
const ignoredBySession = new Map(sessions.flatMap((session) => {
|
|
1452
|
+
const original = deterministicAttribution(session, { standingMap });
|
|
1453
|
+
return original === null || !ignoredTickets.has(original.ticketKey)
|
|
1454
|
+
? []
|
|
1455
|
+
: [[session.sessionId, original.ticketKey]];
|
|
1456
|
+
}));
|
|
1457
|
+
const { attributions: primaryAttributions, attributorAvailable, attributorCalls } = yield* attributeSessions(sessions.map((session) => ({
|
|
1458
|
+
...session,
|
|
1459
|
+
candidateKeys: session.candidateKeys.filter((key) => !ignoredTickets.has(key))
|
|
1460
|
+
})), {
|
|
1461
|
+
standingMap,
|
|
1462
|
+
ignoredTickets,
|
|
1385
1463
|
confidenceFloor: cfg.sessionConfidenceFloor,
|
|
1386
1464
|
mode: options?.attribution ?? "full",
|
|
1387
|
-
onProgress
|
|
1465
|
+
onProgress,
|
|
1466
|
+
candidateFact: candidateFact(ranking)
|
|
1467
|
+
});
|
|
1468
|
+
// A split is a guess from mention counts: a deterministic read reports the session unplaced
|
|
1469
|
+
// instead, so an unattended watch never downgrades a branch-placed row to `split`.
|
|
1470
|
+
const attributions = primaryAttributions.map((attribution, index) => {
|
|
1471
|
+
const session = sessions[index];
|
|
1472
|
+
return session === undefined || options?.attribution === "deterministic" ?
|
|
1473
|
+
attribution :
|
|
1474
|
+
splitSessionAttribution(session, attribution, ranking._tag === "Known" && jiraState?.availability === "verified" ? ranking.sprint : undefined, ignoredTickets);
|
|
1388
1475
|
});
|
|
1389
1476
|
// Windows first, then attribution, then sharing — in that order, because time is divided
|
|
1390
1477
|
// between distinct Issue Keys, not between sessions. Two sessions on one ticket must union
|
|
@@ -1397,27 +1484,43 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
|
|
|
1397
1484
|
observedAtMs: Math.min(period.to.getTime(), nowMs),
|
|
1398
1485
|
boundsBySession: new Map(sessions.flatMap((session) => session.boundedAtMs === null ? [] : [[session.sessionId, session.boundedAtMs]]))
|
|
1399
1486
|
});
|
|
1400
|
-
const
|
|
1487
|
+
const attributionBySession = new Map(attributions.map((attribution) => [attribution.sessionId, attribution]));
|
|
1488
|
+
const ignored = ignoredCredits(windows.map((window) => ({
|
|
1489
|
+
...window,
|
|
1490
|
+
ticketKey: ignoredBySession.get(window.sessionId) ?? null
|
|
1491
|
+
})), ignoredTickets);
|
|
1492
|
+
const eligibleWindows = windows.filter((window) => {
|
|
1493
|
+
const key = attributionBySession.get(window.sessionId)?.ticketKey;
|
|
1494
|
+
return !ignoredBySession.has(window.sessionId) ||
|
|
1495
|
+
(attributionBySession.get(window.sessionId)?.targets?.length ?? 0) > 0 ||
|
|
1496
|
+
(key !== null && key !== undefined && !ignoredTickets.has(key));
|
|
1497
|
+
});
|
|
1498
|
+
const split = splitCredits(eligibleWindows, attributions, {
|
|
1401
1499
|
cwdBySession: new Map(sessions.map((session) => [session.sessionId, session.cwd])),
|
|
1402
|
-
dwellSeconds: cfg.sessionDwellSeconds
|
|
1500
|
+
dwellSeconds: cfg.sessionDwellSeconds,
|
|
1501
|
+
priority: ticketPriority(ranking),
|
|
1502
|
+
ignoredTickets
|
|
1403
1503
|
});
|
|
1404
|
-
const sides = options?.sides ?? bothSides;
|
|
1405
1504
|
yield* onProgress({ _tag: "ReadingRecordedTime", sides });
|
|
1406
1505
|
return yield* refreshRecordedTime(period, {
|
|
1407
1506
|
attributed: split.attributed,
|
|
1408
|
-
sessionEvidence: windows.
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1507
|
+
sessionEvidence: windows.flatMap((window) => {
|
|
1508
|
+
const attribution = attributionBySession.get(window.sessionId);
|
|
1509
|
+
const keys = attribution?.targets?.map((target) => target.ticketKey) ??
|
|
1510
|
+
[attribution?.ticketKey ?? ignoredBySession.get(window.sessionId) ?? null];
|
|
1511
|
+
return keys.map((ticketKey) => ({ ...window, ticketKey }));
|
|
1512
|
+
}),
|
|
1412
1513
|
sides,
|
|
1413
1514
|
withheld: split.withheld,
|
|
1414
1515
|
unattributed: split.unattributed,
|
|
1516
|
+
ignored,
|
|
1517
|
+
ignoredTickets: [...ignoredTickets].sort(),
|
|
1415
1518
|
attributorAvailable,
|
|
1416
1519
|
sessionCount: sessions.length,
|
|
1417
1520
|
sessionRootCount: cfg.sessionRoots.length,
|
|
1418
1521
|
attributorCalls,
|
|
1419
1522
|
digests: new Map(sessions.map((session) => [session.sessionId, session.digest]))
|
|
1420
|
-
});
|
|
1523
|
+
}, undefined, jiraState, preReadJira);
|
|
1421
1524
|
});
|
|
1422
1525
|
return {
|
|
1423
1526
|
compare,
|