@plinth-music/cli 0.6.0 → 0.8.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/CHANGELOG.md +64 -0
- package/README.md +1 -1
- package/dist/build-stamp.json +2 -2
- package/dist/cli.js +1105 -65
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -9193,7 +9193,7 @@ async function runMain(cmd, opts = {}) {
|
|
|
9193
9193
|
// package.json
|
|
9194
9194
|
var package_default = {
|
|
9195
9195
|
name: "@plinth-music/cli",
|
|
9196
|
-
version: "0.
|
|
9196
|
+
version: "0.8.0",
|
|
9197
9197
|
description: "Plinth capstone sync daemon and CLI",
|
|
9198
9198
|
type: "module",
|
|
9199
9199
|
private: false,
|
|
@@ -25237,6 +25237,79 @@ function deleteExtractedText(workspaceSlug, relPath) {
|
|
|
25237
25237
|
throw err;
|
|
25238
25238
|
}
|
|
25239
25239
|
}
|
|
25240
|
+
var CALENDAR_VIEW_DIR = "calendar";
|
|
25241
|
+
var CALENDAR_UPCOMING_NAME = "upcoming";
|
|
25242
|
+
var CALENDAR_INTERNAL_NAME = "_internal";
|
|
25243
|
+
var CALENDAR_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
25244
|
+
function calendarViewRelPath(name) {
|
|
25245
|
+
return `${VIEWS_SUBTREE}/${CALENDAR_VIEW_DIR}/${name}.md`;
|
|
25246
|
+
}
|
|
25247
|
+
function calendarViewPath(workspaceSlug, name) {
|
|
25248
|
+
if (name !== CALENDAR_INTERNAL_NAME && !CALENDAR_NAME_RE.test(name)) {
|
|
25249
|
+
throw new MirrorPathError(`Invalid calendar view name: ${JSON.stringify(name)}`);
|
|
25250
|
+
}
|
|
25251
|
+
if (name.length > 200) {
|
|
25252
|
+
throw new MirrorPathError(`Calendar view name too long: ${name.length}`);
|
|
25253
|
+
}
|
|
25254
|
+
const ws = workspaceMirrorPath(workspaceSlug);
|
|
25255
|
+
const posix = calendarViewRelPath(name);
|
|
25256
|
+
const native = sep2 === "/" ? posix : posix.split("/").join(sep2);
|
|
25257
|
+
const target = resolve(join2(ws, native));
|
|
25258
|
+
const prefix = join2(ws, VIEWS_SUBTREE, CALENDAR_VIEW_DIR) + sep2;
|
|
25259
|
+
if (!target.startsWith(prefix)) {
|
|
25260
|
+
throw new MirrorPathError(`Resolved calendar-view path escapes the views/calendar subtree`);
|
|
25261
|
+
}
|
|
25262
|
+
return target;
|
|
25263
|
+
}
|
|
25264
|
+
function writeCalendarView(workspaceSlug, name, content) {
|
|
25265
|
+
const target = calendarViewPath(workspaceSlug, name);
|
|
25266
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
25267
|
+
mkdirSync(dirname(target), { recursive: true, mode: 448 });
|
|
25268
|
+
writeFileSync(tmp, content, { mode: 384 });
|
|
25269
|
+
try {
|
|
25270
|
+
renameSync(tmp, target);
|
|
25271
|
+
} catch (err) {
|
|
25272
|
+
try {
|
|
25273
|
+
unlinkSync(tmp);
|
|
25274
|
+
} catch {}
|
|
25275
|
+
throw err;
|
|
25276
|
+
}
|
|
25277
|
+
}
|
|
25278
|
+
function readCalendarView(workspaceSlug, name) {
|
|
25279
|
+
try {
|
|
25280
|
+
return readFileSync(calendarViewPath(workspaceSlug, name), "utf8");
|
|
25281
|
+
} catch (err) {
|
|
25282
|
+
if (err?.code === "ENOENT")
|
|
25283
|
+
return null;
|
|
25284
|
+
throw err;
|
|
25285
|
+
}
|
|
25286
|
+
}
|
|
25287
|
+
function deleteCalendarView(workspaceSlug, name) {
|
|
25288
|
+
const target = calendarViewPath(workspaceSlug, name);
|
|
25289
|
+
try {
|
|
25290
|
+
unlinkSync(target);
|
|
25291
|
+
return true;
|
|
25292
|
+
} catch (err) {
|
|
25293
|
+
if (err?.code === "ENOENT")
|
|
25294
|
+
return false;
|
|
25295
|
+
throw err;
|
|
25296
|
+
}
|
|
25297
|
+
}
|
|
25298
|
+
function listCalendarViews(workspaceSlug) {
|
|
25299
|
+
let dir;
|
|
25300
|
+
try {
|
|
25301
|
+
dir = join2(workspaceMirrorPath(workspaceSlug), VIEWS_SUBTREE, CALENDAR_VIEW_DIR);
|
|
25302
|
+
} catch {
|
|
25303
|
+
return [];
|
|
25304
|
+
}
|
|
25305
|
+
let entries;
|
|
25306
|
+
try {
|
|
25307
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
25308
|
+
} catch {
|
|
25309
|
+
return [];
|
|
25310
|
+
}
|
|
25311
|
+
return entries.filter((e2) => e2.isFile() && e2.name.endsWith(".md")).map((e2) => e2.name.slice(0, -3)).sort();
|
|
25312
|
+
}
|
|
25240
25313
|
function contextFilePath(workspaceSlug, relativePath) {
|
|
25241
25314
|
const ws = workspaceMirrorPath(workspaceSlug);
|
|
25242
25315
|
const resolved = resolve(join2(ws, relativePath));
|
|
@@ -26837,7 +26910,7 @@ function buildGroundingBlock(today, workspaceRules = null, workspaceMemory = nul
|
|
|
26837
26910
|
``,
|
|
26838
26911
|
`Write through the canonical surface. In this cockpit the file tree is your edit ` + `surface, so reach for it before the most direct tool. Change prose (document ` + `bodies, free-form notes, anything textual) by editing the mirror markdown ` + `file; the daemon syncs it up. An artist's master context is the clearest case: ` + `it lives in the body of that artist's \`artists/<slug>/_artist.md\` mirror file, ` + `the file the web context pane renders, and one of only two files under ` + `\`artists/<slug>/\` the daemon syncs both ways (\`memory.md\` is the other), ` + `so change it by editing that body, never by creating ` + `a separate document or context entity (e.g. \`create_document\`) the pane won't ` + `render. One carve-out inside that file: a block labelled as rendered from ` + `Plinth is regenerated on every sync and your edits to it are discarded, so ` + `write those through the tool the block names. Change structured fields (task ` + `status, priority, assignees, due dates, a project's category) through the ` + `Plinth MCP (\`mcp__plinth__*\`) or the task file's frontmatter, which is ` + `row-canonical but still written through the validated API. Make a bulk or ` + `destructive change (a mass delete, a gutting overwrite) by editing the file ` + `path, so the daemon's confirm-gate can quarantine it for \`plinth confirm\`. ` + `Never write the database directly, which rules out raw SQL and ` + `\`execute_sql\`, \`apply_migration\` and \`psql\`: those bypass the daemon, the ` + `mirror, the confirm-gate, canonicality, and API validation.`,
|
|
26839
26912
|
``,
|
|
26840
|
-
`Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Never create ` + `or update a key date: do not call \`create_key_date\` or \`update_key_date\` (they ` + `write to the retired table and are being removed from the tool surface). Reading ` + `dates is fine
|
|
26913
|
+
`Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Never create ` + `or update a key date: do not call \`create_key_date\` or \`update_key_date\` (they ` + `write to the retired table and are being removed from the tool surface). Reading ` + `dates is fine, and this mirror already holds them: the daemon projects every ` + `mapped calendar into \`views/calendar/upcoming.md\` and ` + `\`views/calendar/<slug>.md\`, so read those before reaching for a tool ` + `(\`list_artist_calendars\`, \`list_key_dates\`). They are derived and overwritten, ` + `so they are current only as of the \`generated_at\` in their header - quote that ` + `stamp, never edit them, and check the status line, because a calendar Plinth ` + `could not reach is unknown rather than empty.`,
|
|
26841
26914
|
``,
|
|
26842
26915
|
`Read the calendar, and hand a date over rather than writing it yourself. An ` + `artist's dates live on their mapped Google Calendar and reading them is yours: ` + `resolve the calendar through \`mcp__plinth__list_artist_calendars\` and use its ` + `\`calendar_id\` field, not the mapping row's \`id\`, because the default ` + `\`primary\` is almost never theirs. Writing it is not yours. When a date needs ` + `adding or correcting (a show, a hold, a release, a deadline), say plainly what ` + `the change is, say that it goes in through Plinth's calendar view, and say that ` + `a person makes that change rather than you. Say the reason too, because a date ` + `left looking handled is worse than one openly outstanding: a calendar write ` + `goes out to everyone invited the moment it lands, and it cannot be recalled. ` + `This is not a puzzle to route around. Do not reach for a shell command line to ` + `do it, the Google Workspace one included, and do not go looking for another ` + `tool that writes a calendar. Those tools are denied to you wherever Plinth has ` + `configured this workspace, and where a deny has not reached, the rule is the ` + `same and you are the only thing holding it.`,
|
|
26843
26916
|
``,
|
|
@@ -29237,7 +29310,7 @@ function withUserBlock(lines) {
|
|
|
29237
29310
|
function buildStartCommandMd() {
|
|
29238
29311
|
return withUserBlock([
|
|
29239
29312
|
"---",
|
|
29240
|
-
"description: Open a Plinth session in this workspace. Reads both memory files, pulls what is unread, gives a three-line brief, then asks what you want to work on.",
|
|
29313
|
+
"description: Open a Plinth session in this workspace. Reads both memory files, pulls what is unread, reads the projected calendar, gives a three-line brief, then asks what you want to work on.",
|
|
29241
29314
|
"---",
|
|
29242
29315
|
"",
|
|
29243
29316
|
OWNERSHIP_NOTE,
|
|
@@ -29260,23 +29333,37 @@ function buildStartCommandMd() {
|
|
|
29260
29333
|
"",
|
|
29261
29334
|
"Pull **unread notifications** from the Plinth MCP (`list_notifications`). If the Plinth MCP is not connected, say so in one line and carry on from the injected indexes rather than failing.",
|
|
29262
29335
|
"",
|
|
29263
|
-
"**That one call
|
|
29336
|
+
"**That one call plus the single file read in Step 3 are the whole budget, and the list below is exhaustive rather than indicative.** Do not sweep tasks, do not list projects, do not open a calendar, do not read an artist folder, and do not re-read the memory files. If a fact is not in the two injected indexes, in the notifications, or in that one file, it does not belong in this brief.",
|
|
29337
|
+
"",
|
|
29338
|
+
"## Step 3: what is coming up",
|
|
29339
|
+
"",
|
|
29340
|
+
"Read **one file**: `views/calendar/upcoming.md` at the mirror root. It is Plinth's projection of every artist's calendar, written into the mirror by the daemon.",
|
|
29341
|
+
"",
|
|
29342
|
+
"**This is a FILE READ, not a calendar call, and the rule in Step 2 stands unchanged: you still never open a calendar tool.** The file is here so that you do not have to.",
|
|
29343
|
+
"",
|
|
29344
|
+
'**Brief it as of its own `generated_at` stamp, and say the stamp.** A projection is a cache. It is exactly as current as the header says it is and no more, so "nothing on this week" is only ever true as of that moment.',
|
|
29345
|
+
"",
|
|
29346
|
+
"**Read its calendar-status list before you read the dates, and escalate anything that was not read.** A calendar Plinth could not reach is UNKNOWN, not empty, and the file says which. Briefing an unread calendar as a clear diary is the one failure this file exists to prevent - if a line says NOT READ, say so out loud in the brief.",
|
|
29347
|
+
"",
|
|
29348
|
+
"If the file carries `fetch_state: stale`, the last refresh failed: say how old the dates are rather than reporting them as current. If the file is absent altogether, say the projection has not run yet and carry on without it.",
|
|
29264
29349
|
"",
|
|
29265
|
-
"
|
|
29350
|
+
"**Dates are read here and changed by a person, in Plinth's calendar view.** If something needs moving, say what the change is and hand it over.",
|
|
29351
|
+
"",
|
|
29352
|
+
"## Step 4: brief, in three lines",
|
|
29266
29353
|
"",
|
|
29267
29354
|
"Then stop reading and report. **Three lines, and not a summary of what you read:**",
|
|
29268
29355
|
"",
|
|
29269
|
-
"1. Today, and anything with a clock on it that the injected indexes or the
|
|
29356
|
+
"1. Today, and anything with a clock on it that the injected indexes, the notifications or the calendar file **have already named** - with the calendar's own stamp when you use it. Do not go looking for more.",
|
|
29270
29357
|
"2. The unread count, and the one or two that actually matter.",
|
|
29271
29358
|
"3. Anything blocked or waiting on someone.",
|
|
29272
29359
|
"",
|
|
29273
29360
|
'Then ask: **"What do you want to work on?"** If the user already said so in the same message, skip the question and load that context now.',
|
|
29274
29361
|
"",
|
|
29275
|
-
"## Step
|
|
29362
|
+
"## Step 5: offer the sweep, do not run it",
|
|
29276
29363
|
"",
|
|
29277
29364
|
`A full roster sweep, every artist's overdue and upcoming work plus calendars and project status, is useful and it is **not** part of opening a session. **Offer it in one clause ("want the roster sweep?") and stop there.** Reporting a sweep you ran anyway is the failure this step exists to prevent: it is the same cost the user did not ask for, with the offer bolted on afterwards.`,
|
|
29278
29365
|
"",
|
|
29279
|
-
"**The budget is the one call in Step 2, and it is a ceiling rather than a target.** Every new session pays this command before the user has asked for anything, so a second call needs a reason you would defend out loud. An opener that takes half a minute gets turned off, and an opener nobody runs banks nothing at the other end.",
|
|
29366
|
+
"**The budget is the one call in Step 2 and the one file read in Step 3, and it is a ceiling rather than a target.** Every new session pays this command before the user has asked for anything, so a second call needs a reason you would defend out loud. An opener that takes half a minute gets turned off, and an opener nobody runs banks nothing at the other end.",
|
|
29280
29367
|
"",
|
|
29281
29368
|
"## Loading area context",
|
|
29282
29369
|
"",
|
|
@@ -29420,6 +29507,7 @@ function backupStamp2(d2) {
|
|
|
29420
29507
|
}
|
|
29421
29508
|
|
|
29422
29509
|
// src/lib/context/workspace-claude-md.ts
|
|
29510
|
+
var EXTRACTED_TEXT_DIR = extractedTextRelPath("x").replace(/x\.md$/, "");
|
|
29423
29511
|
function rosterBlock(roster, activeProjectCountBySlug) {
|
|
29424
29512
|
if (roster.length === 0)
|
|
29425
29513
|
return "_No artists synced yet._";
|
|
@@ -29468,6 +29556,11 @@ function buildWorkspaceClaudeMd(input) {
|
|
|
29468
29556
|
`- Per-artist long-term facts → \`artists/<slug>/memory.md\`, which syncs to that artist's Plinth record; most have none yet, and creating the file is how you start one · rolling activity → \`artists/<slug>/daily-log.md\`, local-only and never the place for a durable fact`,
|
|
29469
29557
|
`- ${MCP_AVAILABILITY_POINTER}`,
|
|
29470
29558
|
``,
|
|
29559
|
+
`## Navigating the corpus`,
|
|
29560
|
+
`- **What points at X?** → \`plinth backlinks "<name>"\`, a mirror path, or a wikilink target as written. Render-only blocks, code spans and comments are narrowed out; \`--unfiltered\` shows them. A bare name several files answer to is refused with the candidates listed, so re-run with the path it names`,
|
|
29561
|
+
`- **What does this file link to?** → grep \`[[\` in it`,
|
|
29562
|
+
`- **Find a file by name** → glob under \`files/\` \`documents/\` \`artists/\` · **text inside a PDF** → \`${EXTRACTED_TEXT_DIR}\` (folder map above) · **a task, project, artist or contact by field** → the \`mcp__plinth__*\` \`search_entities\` / \`list_*\` tools`,
|
|
29563
|
+
``,
|
|
29471
29564
|
`## Entity model: ${MCP_INTROSPECTION_POINTER}`,
|
|
29472
29565
|
...entityLines,
|
|
29473
29566
|
``,
|
|
@@ -30468,10 +30561,12 @@ class DestructiveGate {
|
|
|
30468
30561
|
settleMs;
|
|
30469
30562
|
maxBatch;
|
|
30470
30563
|
onSettle;
|
|
30564
|
+
onError;
|
|
30471
30565
|
constructor(opts) {
|
|
30472
30566
|
this.settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
|
|
30473
30567
|
this.maxBatch = opts.maxBatch ?? DEFAULT_MAX_BATCH;
|
|
30474
30568
|
this.onSettle = opts.onSettle;
|
|
30569
|
+
this.onError = opts.onError;
|
|
30475
30570
|
}
|
|
30476
30571
|
consider(op) {
|
|
30477
30572
|
this.pending.push(op);
|
|
@@ -30500,7 +30595,11 @@ class DestructiveGate {
|
|
|
30500
30595
|
return;
|
|
30501
30596
|
const batch = this.pending;
|
|
30502
30597
|
this.pending = [];
|
|
30503
|
-
|
|
30598
|
+
try {
|
|
30599
|
+
await this.onSettle(batch);
|
|
30600
|
+
} catch (err) {
|
|
30601
|
+
this.onError?.(err);
|
|
30602
|
+
}
|
|
30504
30603
|
}
|
|
30505
30604
|
}
|
|
30506
30605
|
|
|
@@ -30609,6 +30708,17 @@ function pullNoticeLine(kind) {
|
|
|
30609
30708
|
const token = kind === "auth" ? "PULL_PAUSED" : "PULL_DEGRADED";
|
|
30610
30709
|
return `[${token} reason=${kind}]`;
|
|
30611
30710
|
}
|
|
30711
|
+
function calendarProjectedLine(f3) {
|
|
30712
|
+
return `[CALENDAR_PROJECTED written=${f3.written} pruned=${f3.pruned} ` + `calendars=${f3.calendars} not_read=${f3.notRead}]`;
|
|
30713
|
+
}
|
|
30714
|
+
function calendarFailedLine(f3) {
|
|
30715
|
+
const serverPart = f3.serverMessage ? ` server_msg="${f3.serverMessage}"` : "";
|
|
30716
|
+
return `[CALENDAR_FAILED status=${f3.status} reason=${f3.reason} staled=${f3.staled}${serverPart}]`;
|
|
30717
|
+
}
|
|
30718
|
+
function calendarNoticeLine(kind) {
|
|
30719
|
+
const token = kind === "auth" ? "CALENDAR_PAUSED" : kind === "unsupported" ? "CALENDAR_UNSUPPORTED" : "CALENDAR_DEGRADED";
|
|
30720
|
+
return `[${token} reason=${kind}]`;
|
|
30721
|
+
}
|
|
30612
30722
|
|
|
30613
30723
|
// src/lib/rejected.ts
|
|
30614
30724
|
import { relative as relative3 } from "node:path";
|
|
@@ -32709,10 +32819,12 @@ class FileDestructiveGate {
|
|
|
32709
32819
|
settleMs;
|
|
32710
32820
|
maxBatch;
|
|
32711
32821
|
onSettle;
|
|
32822
|
+
onError;
|
|
32712
32823
|
constructor(opts) {
|
|
32713
32824
|
this.settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS2;
|
|
32714
32825
|
this.maxBatch = opts.maxBatch ?? DEFAULT_MAX_BATCH2;
|
|
32715
32826
|
this.onSettle = opts.onSettle;
|
|
32827
|
+
this.onError = opts.onError;
|
|
32716
32828
|
}
|
|
32717
32829
|
consider(op) {
|
|
32718
32830
|
this.pending.push(op);
|
|
@@ -32741,7 +32853,11 @@ class FileDestructiveGate {
|
|
|
32741
32853
|
return;
|
|
32742
32854
|
const batch = this.pending;
|
|
32743
32855
|
this.pending = [];
|
|
32744
|
-
|
|
32856
|
+
try {
|
|
32857
|
+
await this.onSettle(batch);
|
|
32858
|
+
} catch (err) {
|
|
32859
|
+
this.onError?.(err);
|
|
32860
|
+
}
|
|
32745
32861
|
}
|
|
32746
32862
|
}
|
|
32747
32863
|
|
|
@@ -35363,6 +35479,753 @@ class PullLoop {
|
|
|
35363
35479
|
}
|
|
35364
35480
|
}
|
|
35365
35481
|
|
|
35482
|
+
// src/lib/calendar-loop.ts
|
|
35483
|
+
var DEFAULT_CALENDAR_INTERVAL_MS = 900000;
|
|
35484
|
+
var CALENDAR_BACKOFF_CAP_MS = 3600000;
|
|
35485
|
+
var CALENDAR_AUTH_RETRY_MS = 300000;
|
|
35486
|
+
function calendarIntervalMs() {
|
|
35487
|
+
const raw = process.env.PLINTH_CALENDAR_INTERVAL_MS;
|
|
35488
|
+
if (raw) {
|
|
35489
|
+
const n2 = Number(raw);
|
|
35490
|
+
if (Number.isFinite(n2) && n2 > 0)
|
|
35491
|
+
return n2;
|
|
35492
|
+
}
|
|
35493
|
+
return DEFAULT_CALENDAR_INTERVAL_MS;
|
|
35494
|
+
}
|
|
35495
|
+
|
|
35496
|
+
class CalendarLoop {
|
|
35497
|
+
runOnce;
|
|
35498
|
+
onProjected;
|
|
35499
|
+
notice;
|
|
35500
|
+
loginHint;
|
|
35501
|
+
intervalMs;
|
|
35502
|
+
capMs;
|
|
35503
|
+
authRetryMs;
|
|
35504
|
+
setTimer;
|
|
35505
|
+
clearTimer;
|
|
35506
|
+
timer = null;
|
|
35507
|
+
inFlight = false;
|
|
35508
|
+
stopped = false;
|
|
35509
|
+
failures = 0;
|
|
35510
|
+
authAnnounced = false;
|
|
35511
|
+
degradedAnnounced = false;
|
|
35512
|
+
constructor(deps) {
|
|
35513
|
+
this.runOnce = deps.runOnce;
|
|
35514
|
+
this.onProjected = deps.onProjected;
|
|
35515
|
+
this.notice = deps.notice;
|
|
35516
|
+
this.loginHint = deps.loginHint;
|
|
35517
|
+
this.intervalMs = deps.intervalMs ?? calendarIntervalMs();
|
|
35518
|
+
this.capMs = deps.capMs ?? CALENDAR_BACKOFF_CAP_MS;
|
|
35519
|
+
this.authRetryMs = deps.authRetryMs ?? CALENDAR_AUTH_RETRY_MS;
|
|
35520
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
35521
|
+
this.clearTimer = deps.clearTimer ?? ((t2) => clearTimeout(t2));
|
|
35522
|
+
}
|
|
35523
|
+
startNow() {
|
|
35524
|
+
this.tick();
|
|
35525
|
+
}
|
|
35526
|
+
start() {
|
|
35527
|
+
this.schedule(this.intervalMs);
|
|
35528
|
+
}
|
|
35529
|
+
stop() {
|
|
35530
|
+
this.stopped = true;
|
|
35531
|
+
if (this.timer !== null) {
|
|
35532
|
+
this.clearTimer(this.timer);
|
|
35533
|
+
this.timer = null;
|
|
35534
|
+
}
|
|
35535
|
+
}
|
|
35536
|
+
schedule(ms) {
|
|
35537
|
+
if (this.stopped)
|
|
35538
|
+
return;
|
|
35539
|
+
this.timer = this.setTimer(() => {
|
|
35540
|
+
this.tick();
|
|
35541
|
+
}, ms);
|
|
35542
|
+
}
|
|
35543
|
+
async tick() {
|
|
35544
|
+
if (this.stopped || this.inFlight)
|
|
35545
|
+
return;
|
|
35546
|
+
this.inFlight = true;
|
|
35547
|
+
let nextDelay;
|
|
35548
|
+
try {
|
|
35549
|
+
const result = await this.runOnce();
|
|
35550
|
+
nextDelay = result.ok ? this.onSuccess(result) : this.onFailure(result.failure);
|
|
35551
|
+
} catch (err) {
|
|
35552
|
+
const reason = err instanceof Error ? err.message : "unknown error";
|
|
35553
|
+
nextDelay = this.onFailure({ kind: "transient", reason });
|
|
35554
|
+
} finally {
|
|
35555
|
+
this.inFlight = false;
|
|
35556
|
+
}
|
|
35557
|
+
if (nextDelay !== null)
|
|
35558
|
+
this.schedule(nextDelay);
|
|
35559
|
+
}
|
|
35560
|
+
onSuccess(result) {
|
|
35561
|
+
this.failures = 0;
|
|
35562
|
+
this.authAnnounced = false;
|
|
35563
|
+
this.degradedAnnounced = false;
|
|
35564
|
+
if (result.written > 0 || result.pruned > 0) {
|
|
35565
|
+
this.onProjected(result.written, result.pruned);
|
|
35566
|
+
}
|
|
35567
|
+
return this.intervalMs;
|
|
35568
|
+
}
|
|
35569
|
+
onFailure(failure) {
|
|
35570
|
+
if (failure.kind === "unsupported") {
|
|
35571
|
+
this.stop();
|
|
35572
|
+
this.notice("unsupported", `Calendar projection is off: this Plinth server has no calendar route. ` + `Nothing under views/calendar/ will be written or updated. Restart the ` + `daemon once the server is up to date.`);
|
|
35573
|
+
return null;
|
|
35574
|
+
}
|
|
35575
|
+
if (failure.kind === "auth") {
|
|
35576
|
+
if (!this.authAnnounced) {
|
|
35577
|
+
this.authAnnounced = true;
|
|
35578
|
+
this.notice("auth", `Calendar refresh paused - the server rejected the token. Run ` + `\`${this.loginHint}\`, then RESTART the daemon: the token is read once ` + `at startup, so a re-login is not picked up until it does. Files ` + `under views/calendar/ keep their last good read and say they are stale.`);
|
|
35579
|
+
}
|
|
35580
|
+
return this.authRetryMs;
|
|
35581
|
+
}
|
|
35582
|
+
this.failures += 1;
|
|
35583
|
+
let delay;
|
|
35584
|
+
if (failure.kind === "rate_limit" && failure.retryAfterMs !== undefined) {
|
|
35585
|
+
delay = Math.min(Math.max(failure.retryAfterMs, 1000), this.capMs);
|
|
35586
|
+
} else {
|
|
35587
|
+
delay = Math.min(this.intervalMs * 2 ** (this.failures - 1), this.capMs);
|
|
35588
|
+
}
|
|
35589
|
+
if (delay >= this.capMs && !this.degradedAnnounced) {
|
|
35590
|
+
this.degradedAnnounced = true;
|
|
35591
|
+
const mins = Math.round(this.capMs / 60000);
|
|
35592
|
+
this.notice("degraded", `Calendar refresh degraded - repeated failures, retrying about every ` + `${mins}m. Files under views/calendar/ still show the last good read ` + `and say that they are stale.`);
|
|
35593
|
+
}
|
|
35594
|
+
return delay;
|
|
35595
|
+
}
|
|
35596
|
+
}
|
|
35597
|
+
function classifyCalendarFailure(err) {
|
|
35598
|
+
if (err instanceof ApiError) {
|
|
35599
|
+
if (err.status === 404)
|
|
35600
|
+
return { kind: "unsupported", reason: err.message };
|
|
35601
|
+
if (err.status === 401 || err.status === 403) {
|
|
35602
|
+
return { kind: "auth", reason: err.message };
|
|
35603
|
+
}
|
|
35604
|
+
if (err.status === 429) {
|
|
35605
|
+
return {
|
|
35606
|
+
kind: "rate_limit",
|
|
35607
|
+
reason: err.message,
|
|
35608
|
+
retryAfterMs: err.retryAfterMs
|
|
35609
|
+
};
|
|
35610
|
+
}
|
|
35611
|
+
return { kind: "transient", reason: err.message };
|
|
35612
|
+
}
|
|
35613
|
+
return {
|
|
35614
|
+
kind: "transient",
|
|
35615
|
+
reason: err instanceof Error ? err.message : "unknown error"
|
|
35616
|
+
};
|
|
35617
|
+
}
|
|
35618
|
+
|
|
35619
|
+
// src/lib/calendar/api.ts
|
|
35620
|
+
class CalendarContractError extends ApiError {
|
|
35621
|
+
constructor(status, message) {
|
|
35622
|
+
super(status, message);
|
|
35623
|
+
this.name = "CalendarContractError";
|
|
35624
|
+
}
|
|
35625
|
+
}
|
|
35626
|
+
var CalendarEventSchema = exports_external.object({
|
|
35627
|
+
id: exports_external.string(),
|
|
35628
|
+
title: exports_external.string(),
|
|
35629
|
+
start: exports_external.string().min(1),
|
|
35630
|
+
end: exports_external.string().min(1),
|
|
35631
|
+
all_day: exports_external.boolean(),
|
|
35632
|
+
location: exports_external.string().nullish().transform((v2) => v2 ?? null),
|
|
35633
|
+
booking_status: exports_external.enum(["confirmed", "hold"]),
|
|
35634
|
+
url: exports_external.string()
|
|
35635
|
+
});
|
|
35636
|
+
var CALENDAR_STATUSES = [
|
|
35637
|
+
"fetched",
|
|
35638
|
+
"no-token",
|
|
35639
|
+
"token-expired",
|
|
35640
|
+
"google-error"
|
|
35641
|
+
];
|
|
35642
|
+
var CalendarRowSchema = exports_external.object({
|
|
35643
|
+
artist_id: exports_external.string().nullable(),
|
|
35644
|
+
artist_slug: exports_external.string().nullable(),
|
|
35645
|
+
artist_name: exports_external.string().nullable(),
|
|
35646
|
+
calendar_id: exports_external.string(),
|
|
35647
|
+
calendar_name: exports_external.string(),
|
|
35648
|
+
status: exports_external.enum(CALENDAR_STATUSES),
|
|
35649
|
+
events: exports_external.array(CalendarEventSchema).nullable(),
|
|
35650
|
+
error: exports_external.object({ upstream_status: exports_external.number(), message: exports_external.string() }).nullish().transform((v2) => v2 ?? null),
|
|
35651
|
+
truncated: exports_external.boolean().optional().default(false)
|
|
35652
|
+
});
|
|
35653
|
+
var CalendarResponseSchema = exports_external.object({
|
|
35654
|
+
generated_at: exports_external.string().min(1),
|
|
35655
|
+
window: exports_external.object({
|
|
35656
|
+
time_min: exports_external.string().min(1),
|
|
35657
|
+
time_max: exports_external.string().min(1)
|
|
35658
|
+
}),
|
|
35659
|
+
artist_count: exports_external.number().int().nonnegative(),
|
|
35660
|
+
mapped_artist_count: exports_external.number().int().nonnegative(),
|
|
35661
|
+
calendars: exports_external.array(CalendarRowSchema)
|
|
35662
|
+
}).superRefine((body, ctx) => {
|
|
35663
|
+
for (const [i2, row] of body.calendars.entries()) {
|
|
35664
|
+
const isFetched = row.status === "fetched";
|
|
35665
|
+
if (isFetched && row.events === null) {
|
|
35666
|
+
ctx.addIssue({
|
|
35667
|
+
code: exports_external.ZodIssueCode.custom,
|
|
35668
|
+
path: ["calendars", i2, "events"],
|
|
35669
|
+
message: `status "fetched" must carry an events array, not null`
|
|
35670
|
+
});
|
|
35671
|
+
}
|
|
35672
|
+
if (!isFetched && row.events !== null) {
|
|
35673
|
+
ctx.addIssue({
|
|
35674
|
+
code: exports_external.ZodIssueCode.custom,
|
|
35675
|
+
path: ["calendars", i2, "events"],
|
|
35676
|
+
message: `status "${row.status}" must carry events: null, not an array`
|
|
35677
|
+
});
|
|
35678
|
+
}
|
|
35679
|
+
}
|
|
35680
|
+
});
|
|
35681
|
+
async function fetchCalendar(args) {
|
|
35682
|
+
const { baseUrl, pat, fetchImpl = fetch } = args;
|
|
35683
|
+
const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/calendar`;
|
|
35684
|
+
let res;
|
|
35685
|
+
try {
|
|
35686
|
+
res = await fetchImpl(url2, {
|
|
35687
|
+
method: "GET",
|
|
35688
|
+
headers: {
|
|
35689
|
+
Authorization: `Bearer ${pat}`,
|
|
35690
|
+
Accept: "application/json"
|
|
35691
|
+
}
|
|
35692
|
+
});
|
|
35693
|
+
} catch (err) {
|
|
35694
|
+
const msg = err instanceof Error ? err.message : "network error";
|
|
35695
|
+
throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
|
|
35696
|
+
}
|
|
35697
|
+
if (res.status === 401) {
|
|
35698
|
+
throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
|
|
35699
|
+
}
|
|
35700
|
+
if (res.status === 404) {
|
|
35701
|
+
throw new ApiError(404, "This Plinth server has no /api/sync/calendar route.");
|
|
35702
|
+
}
|
|
35703
|
+
if (res.status === 429) {
|
|
35704
|
+
throw new ApiError(429, "Rate limited by the server. Wait a few seconds and retry.", parseRetryAfterMs(res.headers.get("retry-after")));
|
|
35705
|
+
}
|
|
35706
|
+
if (!res.ok) {
|
|
35707
|
+
throw new ApiError(res.status, `sync calendar failed with HTTP ${res.status}`);
|
|
35708
|
+
}
|
|
35709
|
+
let body;
|
|
35710
|
+
try {
|
|
35711
|
+
body = await res.json();
|
|
35712
|
+
} catch {
|
|
35713
|
+
throw new CalendarContractError(res.status, "sync calendar response was not JSON");
|
|
35714
|
+
}
|
|
35715
|
+
const parsed = CalendarResponseSchema.safeParse(body);
|
|
35716
|
+
if (!parsed.success) {
|
|
35717
|
+
const detail = parsed.error.issues.slice(0, 3).map((i2) => `${i2.path.join(".") || "(root)"}: ${i2.message}`).join("; ");
|
|
35718
|
+
throw new CalendarContractError(res.status, `sync calendar response did not match the contract: ${detail}`);
|
|
35719
|
+
}
|
|
35720
|
+
return parsed.data;
|
|
35721
|
+
}
|
|
35722
|
+
|
|
35723
|
+
// src/lib/calendar/render.ts
|
|
35724
|
+
var CALENDAR_BODY_MARKER = "<!-- PLINTH:CALENDAR-BODY -->";
|
|
35725
|
+
var OWNERSHIP_NOTE2 = [
|
|
35726
|
+
"<!-- Generated by Plinth from the calendars mapped in this workspace. This file",
|
|
35727
|
+
" is DERIVED: one-way, overwritten on every refresh, and never uploaded.",
|
|
35728
|
+
" Your edits here are lost. Dates are changed in Plinth's calendar view, by a",
|
|
35729
|
+
" person - not from this file and not by an agent. -->"
|
|
35730
|
+
].join(`
|
|
35731
|
+
`);
|
|
35732
|
+
var DEFAULT_USER_LABEL = "the Plinth account this mirror syncs with";
|
|
35733
|
+
var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
35734
|
+
var MONTHS = [
|
|
35735
|
+
"Jan",
|
|
35736
|
+
"Feb",
|
|
35737
|
+
"Mar",
|
|
35738
|
+
"Apr",
|
|
35739
|
+
"May",
|
|
35740
|
+
"Jun",
|
|
35741
|
+
"Jul",
|
|
35742
|
+
"Aug",
|
|
35743
|
+
"Sep",
|
|
35744
|
+
"Oct",
|
|
35745
|
+
"Nov",
|
|
35746
|
+
"Dec"
|
|
35747
|
+
];
|
|
35748
|
+
var DAY_MS = 86400000;
|
|
35749
|
+
var FULL_BAND_DAYS = 14;
|
|
35750
|
+
function yamlString(value) {
|
|
35751
|
+
return JSON.stringify(value);
|
|
35752
|
+
}
|
|
35753
|
+
function oneLine(value, maxLength = 200) {
|
|
35754
|
+
const flattened = value.replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/<!--/g, "<! --").replace(/\s+/g, " ").trim();
|
|
35755
|
+
if (flattened.length === 0)
|
|
35756
|
+
return "(untitled)";
|
|
35757
|
+
if (flattened.length <= maxLength)
|
|
35758
|
+
return flattened;
|
|
35759
|
+
return `${flattened.slice(0, maxLength - 3)}...`;
|
|
35760
|
+
}
|
|
35761
|
+
function dateParts(value) {
|
|
35762
|
+
const m2 = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
|
35763
|
+
if (!m2)
|
|
35764
|
+
return null;
|
|
35765
|
+
return { y: Number(m2[1]), m: Number(m2[2]), d: Number(m2[3]) };
|
|
35766
|
+
}
|
|
35767
|
+
function timeParts(value) {
|
|
35768
|
+
const m2 = /T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(value);
|
|
35769
|
+
if (!m2)
|
|
35770
|
+
return null;
|
|
35771
|
+
const rawOffset = m2[3];
|
|
35772
|
+
const offset = rawOffset === undefined ? "" : rawOffset === "Z" ? "UTC" : rawOffset;
|
|
35773
|
+
return { hh: m2[1], mi: m2[2], offset };
|
|
35774
|
+
}
|
|
35775
|
+
function utcMs(parts) {
|
|
35776
|
+
return Date.UTC(parts.y, parts.m - 1, parts.d);
|
|
35777
|
+
}
|
|
35778
|
+
function shiftDays(parts, days) {
|
|
35779
|
+
const d2 = new Date(utcMs(parts) + days * DAY_MS);
|
|
35780
|
+
return { y: d2.getUTCFullYear(), m: d2.getUTCMonth() + 1, d: d2.getUTCDate() };
|
|
35781
|
+
}
|
|
35782
|
+
function formatDay(parts, referenceYear) {
|
|
35783
|
+
const weekday = WEEKDAYS[new Date(utcMs(parts)).getUTCDay()] ?? "";
|
|
35784
|
+
const month = MONTHS[parts.m - 1] ?? "";
|
|
35785
|
+
const year = parts.y === referenceYear ? "" : ` ${parts.y}`;
|
|
35786
|
+
return `${weekday} ${parts.d} ${month}${year}`;
|
|
35787
|
+
}
|
|
35788
|
+
function sortInstant(event) {
|
|
35789
|
+
if (!event.all_day) {
|
|
35790
|
+
const ms = Date.parse(event.start);
|
|
35791
|
+
if (!Number.isNaN(ms))
|
|
35792
|
+
return ms;
|
|
35793
|
+
}
|
|
35794
|
+
const parts = dateParts(event.start);
|
|
35795
|
+
if (parts)
|
|
35796
|
+
return utcMs(parts);
|
|
35797
|
+
return Number.MAX_SAFE_INTEGER;
|
|
35798
|
+
}
|
|
35799
|
+
function describeWhen(event, referenceYear) {
|
|
35800
|
+
const start = dateParts(event.start);
|
|
35801
|
+
if (!start)
|
|
35802
|
+
return oneLine(event.start, 60);
|
|
35803
|
+
if (event.all_day) {
|
|
35804
|
+
const end2 = dateParts(event.end);
|
|
35805
|
+
const last = end2 ? shiftDays(end2, -1) : start;
|
|
35806
|
+
const single = !end2 || utcMs(last) <= utcMs(start);
|
|
35807
|
+
return single ? `${formatDay(start, referenceYear)} (all day)` : `${formatDay(start, referenceYear)} to ${formatDay(last, referenceYear)} (all day)`;
|
|
35808
|
+
}
|
|
35809
|
+
const startTime = timeParts(event.start);
|
|
35810
|
+
const end = dateParts(event.end);
|
|
35811
|
+
const endTime = timeParts(event.end);
|
|
35812
|
+
if (!startTime)
|
|
35813
|
+
return formatDay(start, referenceYear);
|
|
35814
|
+
const suffix = startTime.offset ? ` ${startTime.offset}` : "";
|
|
35815
|
+
if (!end || !endTime) {
|
|
35816
|
+
return `${formatDay(start, referenceYear)}, ${startTime.hh}:${startTime.mi}${suffix}`;
|
|
35817
|
+
}
|
|
35818
|
+
if (utcMs(end) === utcMs(start)) {
|
|
35819
|
+
return `${formatDay(start, referenceYear)}, ${startTime.hh}:${startTime.mi}-${endTime.hh}:${endTime.mi}${suffix}`;
|
|
35820
|
+
}
|
|
35821
|
+
const endSuffix = endTime.offset ? ` ${endTime.offset}` : "";
|
|
35822
|
+
return `${formatDay(start, referenceYear)} ${startTime.hh}:${startTime.mi}${suffix}` + ` to ${formatDay(end, referenceYear)} ${endTime.hh}:${endTime.mi}${endSuffix}`;
|
|
35823
|
+
}
|
|
35824
|
+
function fullLine(event, referenceYear, artistPrefix, includeUrl) {
|
|
35825
|
+
const parts = [
|
|
35826
|
+
`**${describeWhen(event, referenceYear)}**`,
|
|
35827
|
+
artistPrefix,
|
|
35828
|
+
`${oneLine(event.title)} (${event.booking_status})`
|
|
35829
|
+
].filter((p) => p.length > 0);
|
|
35830
|
+
if (event.location)
|
|
35831
|
+
parts.push(oneLine(event.location, 80));
|
|
35832
|
+
if (includeUrl && event.url)
|
|
35833
|
+
parts.push(oneLine(event.url, 300));
|
|
35834
|
+
return `- ${parts.join(" - ")}`;
|
|
35835
|
+
}
|
|
35836
|
+
function compactLine(event, referenceYear, artistPrefix) {
|
|
35837
|
+
const parts = [
|
|
35838
|
+
describeWhen(event, referenceYear),
|
|
35839
|
+
artistPrefix,
|
|
35840
|
+
`${oneLine(event.title)} (${event.booking_status})`
|
|
35841
|
+
].filter((p) => p.length > 0);
|
|
35842
|
+
return `- ${parts.join(" - ")}`;
|
|
35843
|
+
}
|
|
35844
|
+
function humanStamp(iso) {
|
|
35845
|
+
const ms = Date.parse(iso);
|
|
35846
|
+
if (Number.isNaN(ms))
|
|
35847
|
+
return iso;
|
|
35848
|
+
const d2 = new Date(ms);
|
|
35849
|
+
const day = formatDay({ y: d2.getUTCFullYear(), m: d2.getUTCMonth() + 1, d: d2.getUTCDate() }, -1);
|
|
35850
|
+
const hh = String(d2.getUTCHours()).padStart(2, "0");
|
|
35851
|
+
const mi = String(d2.getUTCMinutes()).padStart(2, "0");
|
|
35852
|
+
return `${day}, ${hh}:${mi} UTC`;
|
|
35853
|
+
}
|
|
35854
|
+
function windowLine(timeMin, timeMax) {
|
|
35855
|
+
const from = dateParts(timeMin);
|
|
35856
|
+
const to = dateParts(timeMax);
|
|
35857
|
+
if (!from || !to)
|
|
35858
|
+
return `${timeMin} to ${timeMax}`;
|
|
35859
|
+
return `${formatDay(from, -1)} to ${formatDay(to, -1)}`;
|
|
35860
|
+
}
|
|
35861
|
+
function statusLine(args) {
|
|
35862
|
+
const { status, eventCount, error: error52, truncated, stamp, userLabel } = args;
|
|
35863
|
+
switch (status) {
|
|
35864
|
+
case "fetched": {
|
|
35865
|
+
if (eventCount === 0) {
|
|
35866
|
+
return `**Read fine as of ${stamp}. Nothing in this window.** The diary really ` + `is clear across the dates above. This is a successful read of an empty ` + `calendar, not a failed read - if it had failed, this line would say so.`;
|
|
35867
|
+
}
|
|
35868
|
+
const truncatedNote = truncated ? " **This list is INCOMPLETE**: Google had more entries than one page and the rest were not collected." : "";
|
|
35869
|
+
return `**Read fine as of ${stamp}.** ${eventCount} ` + `${eventCount === 1 ? "entry" : "entries"} in the window.${truncatedNote}`;
|
|
35870
|
+
}
|
|
35871
|
+
case "no-token":
|
|
35872
|
+
return `**NOT READ - Google is not connected for ${userLabel}.** Nothing is listed ` + `below because Plinth could not look, NOT because the diary is clear. ` + `Connect Google in Plinth to fill this in, and until then treat this ` + `calendar as unknown rather than empty.`;
|
|
35873
|
+
case "token-expired":
|
|
35874
|
+
return `**NOT READ - the Google connection for ${userLabel} has expired.** Plinth ` + `still holds a connection but could not refresh it, so nothing below is ` + `current. Reconnect Google in Plinth. Treat this calendar as unknown ` + `rather than empty.`;
|
|
35875
|
+
case "google-error":
|
|
35876
|
+
return `**NOT READ - Google refused the request` + `${error52 ? ` (HTTP ${error52.upstream_status})` : ""}.** ` + `${error52 ? oneLine(error52.message, 300) : "No detail was returned."} ` + `A calendar that was unshared or deleted in Google reads exactly like this. ` + `Nothing below is current; treat this calendar as unknown rather than empty.`;
|
|
35877
|
+
}
|
|
35878
|
+
}
|
|
35879
|
+
function shortStatus(row) {
|
|
35880
|
+
switch (row.status) {
|
|
35881
|
+
case "fetched": {
|
|
35882
|
+
const n2 = row.events?.length ?? 0;
|
|
35883
|
+
if (n2 === 0)
|
|
35884
|
+
return "read fine, nothing in this window";
|
|
35885
|
+
return `read fine, ${n2} ${n2 === 1 ? "entry" : "entries"}${row.truncated ? " (INCOMPLETE - more than one page)" : ""}`;
|
|
35886
|
+
}
|
|
35887
|
+
case "no-token":
|
|
35888
|
+
return "NOT READ - Google is not connected; unknown, not empty";
|
|
35889
|
+
case "token-expired":
|
|
35890
|
+
return "NOT READ - the Google connection has expired; unknown, not empty";
|
|
35891
|
+
case "google-error":
|
|
35892
|
+
return `NOT READ - Google refused` + `${row.error ? ` (HTTP ${row.error.upstream_status}): ${oneLine(row.error.message, 120)}` : ""}` + `; unknown, not empty`;
|
|
35893
|
+
}
|
|
35894
|
+
}
|
|
35895
|
+
function rowFileName(row, internalName) {
|
|
35896
|
+
return row.artist_slug ?? internalName;
|
|
35897
|
+
}
|
|
35898
|
+
function rowDisplayName(row) {
|
|
35899
|
+
if (row.artist_name)
|
|
35900
|
+
return oneLine(row.artist_name, 80);
|
|
35901
|
+
return `${oneLine(row.calendar_name, 80)} (internal)`;
|
|
35902
|
+
}
|
|
35903
|
+
function splitBands(events, generatedAt) {
|
|
35904
|
+
const base2 = Date.parse(generatedAt);
|
|
35905
|
+
const boundary = Number.isNaN(base2) ? Number.MAX_SAFE_INTEGER : base2 + FULL_BAND_DAYS * DAY_MS;
|
|
35906
|
+
const full = [];
|
|
35907
|
+
const rest = [];
|
|
35908
|
+
for (const e2 of events) {
|
|
35909
|
+
(sortInstant(e2) < boundary ? full : rest).push(e2);
|
|
35910
|
+
}
|
|
35911
|
+
return { full, rest };
|
|
35912
|
+
}
|
|
35913
|
+
function renderArtistCalendar(args) {
|
|
35914
|
+
const { row, generatedAt, window: window2 } = args;
|
|
35915
|
+
const userLabel = args.userLabel ?? DEFAULT_USER_LABEL;
|
|
35916
|
+
const events = row.events ?? [];
|
|
35917
|
+
const stamp = humanStamp(generatedAt);
|
|
35918
|
+
const referenceYear = new Date(Date.parse(generatedAt) || 0).getUTCFullYear();
|
|
35919
|
+
const title = rowDisplayName(row);
|
|
35920
|
+
const header = [
|
|
35921
|
+
"---",
|
|
35922
|
+
"plinth_generated: true",
|
|
35923
|
+
"view: calendar",
|
|
35924
|
+
`artist_slug: ${row.artist_slug === null ? "null" : yamlString(row.artist_slug)}`,
|
|
35925
|
+
`artist_name: ${row.artist_name === null ? "null" : yamlString(row.artist_name)}`,
|
|
35926
|
+
`calendar_name: ${yamlString(row.calendar_name)}`,
|
|
35927
|
+
`generated_at: ${yamlString(generatedAt)}`,
|
|
35928
|
+
`window_start: ${yamlString(window2.time_min)}`,
|
|
35929
|
+
`window_end: ${yamlString(window2.time_max)}`,
|
|
35930
|
+
`status: ${row.status}`,
|
|
35931
|
+
"fetch_state: fresh",
|
|
35932
|
+
`entry_count: ${events.length}`,
|
|
35933
|
+
`truncated: ${row.truncated}`,
|
|
35934
|
+
"---",
|
|
35935
|
+
"",
|
|
35936
|
+
OWNERSHIP_NOTE2,
|
|
35937
|
+
"",
|
|
35938
|
+
`# ${title} - calendar`,
|
|
35939
|
+
""
|
|
35940
|
+
].join(`
|
|
35941
|
+
`);
|
|
35942
|
+
const body = [
|
|
35943
|
+
"",
|
|
35944
|
+
statusLine({
|
|
35945
|
+
status: row.status,
|
|
35946
|
+
eventCount: events.length,
|
|
35947
|
+
error: row.error,
|
|
35948
|
+
truncated: row.truncated,
|
|
35949
|
+
stamp,
|
|
35950
|
+
userLabel
|
|
35951
|
+
}),
|
|
35952
|
+
"",
|
|
35953
|
+
`**Window:** ${windowLine(window2.time_min, window2.time_max)}.`,
|
|
35954
|
+
"",
|
|
35955
|
+
"**Dates are read here and changed in Plinth's calendar view, by a person.** If something below needs moving, say what the change is and hand it over.",
|
|
35956
|
+
"",
|
|
35957
|
+
renderBands(events, generatedAt, referenceYear, "")
|
|
35958
|
+
].join(`
|
|
35959
|
+
`);
|
|
35960
|
+
return `${header}${CALENDAR_BODY_MARKER}
|
|
35961
|
+
${body}`;
|
|
35962
|
+
}
|
|
35963
|
+
function renderBands(events, generatedAt, referenceYear, artistPrefix) {
|
|
35964
|
+
if (events.length === 0)
|
|
35965
|
+
return "";
|
|
35966
|
+
const { full, rest } = splitBands(events, generatedAt);
|
|
35967
|
+
const out = [""];
|
|
35968
|
+
out.push(`## Next ${FULL_BAND_DAYS} days`, "");
|
|
35969
|
+
if (full.length === 0) {
|
|
35970
|
+
out.push(`_Nothing in the next ${FULL_BAND_DAYS} days._`);
|
|
35971
|
+
} else {
|
|
35972
|
+
for (const e2 of full)
|
|
35973
|
+
out.push(fullLine(e2, referenceYear, artistPrefix, true));
|
|
35974
|
+
}
|
|
35975
|
+
out.push("", "## Rest of the window", "");
|
|
35976
|
+
if (rest.length === 0) {
|
|
35977
|
+
out.push("_Nothing further in the window._");
|
|
35978
|
+
} else {
|
|
35979
|
+
for (const e2 of rest)
|
|
35980
|
+
out.push(compactLine(e2, referenceYear, artistPrefix));
|
|
35981
|
+
}
|
|
35982
|
+
out.push("");
|
|
35983
|
+
return out.join(`
|
|
35984
|
+
`);
|
|
35985
|
+
}
|
|
35986
|
+
function renderUpcoming(args) {
|
|
35987
|
+
const {
|
|
35988
|
+
rows,
|
|
35989
|
+
unmappedArtists,
|
|
35990
|
+
generatedAt,
|
|
35991
|
+
window: window2,
|
|
35992
|
+
artistCount,
|
|
35993
|
+
mappedArtistCount,
|
|
35994
|
+
internalName
|
|
35995
|
+
} = args;
|
|
35996
|
+
const stamp = humanStamp(generatedAt);
|
|
35997
|
+
const referenceYear = new Date(Date.parse(generatedAt) || 0).getUTCFullYear();
|
|
35998
|
+
const fetched = rows.filter((r3) => r3.status === "fetched");
|
|
35999
|
+
const failed = rows.filter((r3) => r3.status !== "fetched");
|
|
36000
|
+
const truncatedAnywhere = rows.some((r3) => r3.truncated);
|
|
36001
|
+
const merged = fetched.flatMap((row) => (row.events ?? []).map((event) => ({ event, row }))).sort((a2, b2) => sortInstant(a2.event) - sortInstant(b2.event));
|
|
36002
|
+
const { full, rest } = splitBands(merged.map((m2) => m2.event), generatedAt);
|
|
36003
|
+
const fullSet = new Set(full);
|
|
36004
|
+
const restSet = new Set(rest);
|
|
36005
|
+
const header = [
|
|
36006
|
+
"---",
|
|
36007
|
+
"plinth_generated: true",
|
|
36008
|
+
"view: calendar",
|
|
36009
|
+
"scope: workspace",
|
|
36010
|
+
`generated_at: ${yamlString(generatedAt)}`,
|
|
36011
|
+
`window_start: ${yamlString(window2.time_min)}`,
|
|
36012
|
+
`window_end: ${yamlString(window2.time_max)}`,
|
|
36013
|
+
`artist_count: ${artistCount}`,
|
|
36014
|
+
`mapped_artist_count: ${mappedArtistCount}`,
|
|
36015
|
+
`calendars_read: ${fetched.length}`,
|
|
36016
|
+
`calendars_not_read: ${failed.length}`,
|
|
36017
|
+
"fetch_state: fresh",
|
|
36018
|
+
`truncated: ${truncatedAnywhere}`,
|
|
36019
|
+
"---",
|
|
36020
|
+
"",
|
|
36021
|
+
OWNERSHIP_NOTE2,
|
|
36022
|
+
"",
|
|
36023
|
+
"# Upcoming - every calendar in this workspace",
|
|
36024
|
+
""
|
|
36025
|
+
].join(`
|
|
36026
|
+
`);
|
|
36027
|
+
const preamble = [
|
|
36028
|
+
"",
|
|
36029
|
+
`**As of ${stamp}.** Window ${windowLine(window2.time_min, window2.time_max)}.`,
|
|
36030
|
+
"",
|
|
36031
|
+
failed.length === 0 ? `**All ${fetched.length} mapped ${fetched.length === 1 ? "calendar was" : "calendars were"} read.**` : `**${fetched.length} of ${rows.length} mapped ${rows.length === 1 ? "calendar" : "calendars"} read; ${failed.length} could NOT be read.**` + ` Anything on an unread calendar is missing from the lists below - unknown, not absent.`,
|
|
36032
|
+
"",
|
|
36033
|
+
`${artistCount} ${artistCount === 1 ? "artist" : "artists"} in the roster; ` + `${mappedArtistCount} ${mappedArtistCount === 1 ? "has" : "have"} a calendar mapped in Plinth.`,
|
|
36034
|
+
"",
|
|
36035
|
+
"**Dates are read here and changed in Plinth's calendar view, by a person.** If something below needs moving, say what the change is and hand it over.",
|
|
36036
|
+
"",
|
|
36037
|
+
"## Calendar status",
|
|
36038
|
+
"",
|
|
36039
|
+
...rows.map((row) => `- **${rowDisplayName(row)}**` + `${row.artist_slug ? ` (\`${oneLine(row.artist_slug, 80)}\`)` : ""}` + ` - ${shortStatus(row)} - \`views/calendar/${rowFileName(row, internalName)}.md\``),
|
|
36040
|
+
...unmappedArtists.map((a2) => `- **${oneLine(a2.name, 80)}** (\`${oneLine(a2.slug, 80)}\`) - NO CALENDAR MAPPED in Plinth, so there is nothing to read and no file for them. Map one in Plinth if they should have dates.`),
|
|
36041
|
+
""
|
|
36042
|
+
].join(`
|
|
36043
|
+
`);
|
|
36044
|
+
const body = [preamble];
|
|
36045
|
+
body.push(`## Next ${FULL_BAND_DAYS} days`, "", "_Links are in the per-artist file named beside each calendar above._", "");
|
|
36046
|
+
const fullMerged = merged.filter((m2) => fullSet.has(m2.event));
|
|
36047
|
+
if (fullMerged.length === 0) {
|
|
36048
|
+
body.push(fetched.length === 0 ? `_No calendar could be read, so nothing can be listed for the next ${FULL_BAND_DAYS} days._` : `_Nothing in the next ${FULL_BAND_DAYS} days on any calendar that was read._`);
|
|
36049
|
+
} else {
|
|
36050
|
+
for (const { event, row } of fullMerged) {
|
|
36051
|
+
body.push(fullLine(event, referenceYear, `**${rowDisplayName(row)}**`, false));
|
|
36052
|
+
}
|
|
36053
|
+
}
|
|
36054
|
+
body.push("", "## Rest of the window", "");
|
|
36055
|
+
const restMerged = merged.filter((m2) => restSet.has(m2.event));
|
|
36056
|
+
if (restMerged.length === 0) {
|
|
36057
|
+
body.push(fetched.length === 0 ? "_No calendar could be read, so nothing can be listed._" : "_Nothing further in the window on any calendar that was read._");
|
|
36058
|
+
} else {
|
|
36059
|
+
for (const { event, row } of restMerged) {
|
|
36060
|
+
body.push(compactLine(event, referenceYear, `**${rowDisplayName(row)}**`));
|
|
36061
|
+
}
|
|
36062
|
+
}
|
|
36063
|
+
body.push("");
|
|
36064
|
+
return `${header}${CALENDAR_BODY_MARKER}
|
|
36065
|
+
${body.join(`
|
|
36066
|
+
`)}`;
|
|
36067
|
+
}
|
|
36068
|
+
function splitCalendarFile(content) {
|
|
36069
|
+
const at = content.indexOf(CALENDAR_BODY_MARKER);
|
|
36070
|
+
if (at === -1)
|
|
36071
|
+
return null;
|
|
36072
|
+
return {
|
|
36073
|
+
header: content.slice(0, at),
|
|
36074
|
+
body: content.slice(at + CALENDAR_BODY_MARKER.length)
|
|
36075
|
+
};
|
|
36076
|
+
}
|
|
36077
|
+
function headerField(header, key) {
|
|
36078
|
+
const re = new RegExp(`^${key}: (.*)$`, "m");
|
|
36079
|
+
const m2 = re.exec(header);
|
|
36080
|
+
if (!m2)
|
|
36081
|
+
return null;
|
|
36082
|
+
const raw = m2[1].trim();
|
|
36083
|
+
if (raw === "null")
|
|
36084
|
+
return null;
|
|
36085
|
+
if (raw.startsWith('"')) {
|
|
36086
|
+
try {
|
|
36087
|
+
return JSON.parse(raw);
|
|
36088
|
+
} catch {
|
|
36089
|
+
return raw;
|
|
36090
|
+
}
|
|
36091
|
+
}
|
|
36092
|
+
return raw;
|
|
36093
|
+
}
|
|
36094
|
+
function renderStaleFile(existing, args) {
|
|
36095
|
+
const split = splitCalendarFile(existing);
|
|
36096
|
+
if (!split)
|
|
36097
|
+
return null;
|
|
36098
|
+
const previousGeneratedAt = headerField(split.header, "generated_at");
|
|
36099
|
+
const title = /^# (.+)$/m.exec(split.header)?.[1]?.trim() ?? "Calendar";
|
|
36100
|
+
const fmMatch = /^---\n([\s\S]*?)\n---\n/.exec(split.header);
|
|
36101
|
+
const carried = (fmMatch?.[1] ?? "").split(`
|
|
36102
|
+
`).filter((line) => !/^(fetch_state|last_refresh_attempt|last_refresh_error):/.test(line));
|
|
36103
|
+
const stamp = previousGeneratedAt ? humanStamp(previousGeneratedAt) : "an earlier refresh";
|
|
36104
|
+
const header = [
|
|
36105
|
+
"---",
|
|
36106
|
+
...carried,
|
|
36107
|
+
"fetch_state: stale",
|
|
36108
|
+
`last_refresh_attempt: ${yamlString(args.attemptedAt)}`,
|
|
36109
|
+
`last_refresh_error: ${yamlString(oneLine(args.reason, 300))}`,
|
|
36110
|
+
"---",
|
|
36111
|
+
"",
|
|
36112
|
+
OWNERSHIP_NOTE2,
|
|
36113
|
+
"",
|
|
36114
|
+
`# ${title}`,
|
|
36115
|
+
"",
|
|
36116
|
+
`**STALE - the last refresh FAILED and nothing below was updated.** Attempted ` + `${humanStamp(args.attemptedAt)} and it did not succeed: ` + `${oneLine(args.reason, 300)}`,
|
|
36117
|
+
"",
|
|
36118
|
+
`**What is below was last read at ${stamp}** and has not moved since. Anything ` + `changed in Google after that is not here, and something cancelled after that ` + `is still listed. Say the age out loud rather than briefing it as current.`,
|
|
36119
|
+
""
|
|
36120
|
+
].join(`
|
|
36121
|
+
`);
|
|
36122
|
+
return `${header}${CALENDAR_BODY_MARKER}${split.body}`;
|
|
36123
|
+
}
|
|
36124
|
+
|
|
36125
|
+
// src/lib/calendar/project.ts
|
|
36126
|
+
function writeIfChanged(workspaceSlug, name, content) {
|
|
36127
|
+
if (readCalendarView(workspaceSlug, name) === content)
|
|
36128
|
+
return false;
|
|
36129
|
+
writeCalendarView(workspaceSlug, name, content);
|
|
36130
|
+
return true;
|
|
36131
|
+
}
|
|
36132
|
+
function projectCalendar(args) {
|
|
36133
|
+
const { workspaceSlug, response, roster, userLabel } = args;
|
|
36134
|
+
const { generated_at: generatedAt, window: window2 } = response;
|
|
36135
|
+
let written = 0;
|
|
36136
|
+
let unchanged = 0;
|
|
36137
|
+
let collided = 0;
|
|
36138
|
+
const seen = new Set;
|
|
36139
|
+
const rows = [];
|
|
36140
|
+
for (const row of response.calendars) {
|
|
36141
|
+
const name = rowFileName(row, CALENDAR_INTERNAL_NAME);
|
|
36142
|
+
if (seen.has(name) || name === CALENDAR_UPCOMING_NAME) {
|
|
36143
|
+
collided += 1;
|
|
36144
|
+
continue;
|
|
36145
|
+
}
|
|
36146
|
+
seen.add(name);
|
|
36147
|
+
rows.push(row);
|
|
36148
|
+
const content = renderArtistCalendar({
|
|
36149
|
+
row,
|
|
36150
|
+
generatedAt,
|
|
36151
|
+
window: window2,
|
|
36152
|
+
userLabel
|
|
36153
|
+
});
|
|
36154
|
+
if (writeIfChanged(workspaceSlug, name, content))
|
|
36155
|
+
written += 1;
|
|
36156
|
+
else
|
|
36157
|
+
unchanged += 1;
|
|
36158
|
+
}
|
|
36159
|
+
const mapped = new Set(response.calendars.map((r3) => r3.artist_slug).filter((s2) => s2 !== null));
|
|
36160
|
+
const unmappedArtists = roster.filter((a2) => !mapped.has(a2.slug) && a2.status !== "archived").map((a2) => ({ slug: a2.slug, name: a2.name }));
|
|
36161
|
+
const upcoming = renderUpcoming({
|
|
36162
|
+
rows,
|
|
36163
|
+
unmappedArtists,
|
|
36164
|
+
generatedAt,
|
|
36165
|
+
window: window2,
|
|
36166
|
+
artistCount: response.artist_count,
|
|
36167
|
+
mappedArtistCount: response.mapped_artist_count,
|
|
36168
|
+
internalName: CALENDAR_INTERNAL_NAME,
|
|
36169
|
+
userLabel
|
|
36170
|
+
});
|
|
36171
|
+
if (writeIfChanged(workspaceSlug, CALENDAR_UPCOMING_NAME, upcoming))
|
|
36172
|
+
written += 1;
|
|
36173
|
+
else
|
|
36174
|
+
unchanged += 1;
|
|
36175
|
+
const expected = new Set([...seen, CALENDAR_UPCOMING_NAME]);
|
|
36176
|
+
let pruned = 0;
|
|
36177
|
+
for (const name of listCalendarViews(workspaceSlug)) {
|
|
36178
|
+
if (expected.has(name))
|
|
36179
|
+
continue;
|
|
36180
|
+
if (!ownsFile(workspaceSlug, name))
|
|
36181
|
+
continue;
|
|
36182
|
+
try {
|
|
36183
|
+
if (deleteCalendarView(workspaceSlug, name))
|
|
36184
|
+
pruned += 1;
|
|
36185
|
+
} catch {}
|
|
36186
|
+
}
|
|
36187
|
+
return { written, unchanged, pruned, collided };
|
|
36188
|
+
}
|
|
36189
|
+
function ownsFile(workspaceSlug, name) {
|
|
36190
|
+
try {
|
|
36191
|
+
const existing = readCalendarView(workspaceSlug, name);
|
|
36192
|
+
return existing !== null && existing.includes(CALENDAR_BODY_MARKER);
|
|
36193
|
+
} catch {
|
|
36194
|
+
return false;
|
|
36195
|
+
}
|
|
36196
|
+
}
|
|
36197
|
+
function markCalendarStale(args) {
|
|
36198
|
+
const { workspaceSlug, attemptedAt, reason } = args;
|
|
36199
|
+
let rewritten = 0;
|
|
36200
|
+
let unchanged = 0;
|
|
36201
|
+
let skipped = 0;
|
|
36202
|
+
for (const name of listCalendarViews(workspaceSlug)) {
|
|
36203
|
+
let existing;
|
|
36204
|
+
try {
|
|
36205
|
+
existing = readCalendarView(workspaceSlug, name);
|
|
36206
|
+
} catch {
|
|
36207
|
+
skipped += 1;
|
|
36208
|
+
continue;
|
|
36209
|
+
}
|
|
36210
|
+
if (existing === null)
|
|
36211
|
+
continue;
|
|
36212
|
+
const staled = renderStaleFile(existing, { attemptedAt, reason });
|
|
36213
|
+
if (staled === null) {
|
|
36214
|
+
skipped += 1;
|
|
36215
|
+
continue;
|
|
36216
|
+
}
|
|
36217
|
+
try {
|
|
36218
|
+
if (writeIfChanged(workspaceSlug, name, staled))
|
|
36219
|
+
rewritten += 1;
|
|
36220
|
+
else
|
|
36221
|
+
unchanged += 1;
|
|
36222
|
+
} catch {
|
|
36223
|
+
skipped += 1;
|
|
36224
|
+
}
|
|
36225
|
+
}
|
|
36226
|
+
return { rewritten, unchanged, skipped };
|
|
36227
|
+
}
|
|
36228
|
+
|
|
35366
36229
|
// src/lib/pull-suppression.ts
|
|
35367
36230
|
var PULL_SUPPRESS_TTL_MS = 1e4;
|
|
35368
36231
|
|
|
@@ -36474,7 +37337,7 @@ import { existsSync as existsSync5, readFileSync as readFileSync18, statSync as
|
|
|
36474
37337
|
// src/lib/files/extracted-text.ts
|
|
36475
37338
|
import { createHash as createHash3 } from "node:crypto";
|
|
36476
37339
|
var DERIVED_TEXT_VERSION = 2;
|
|
36477
|
-
function
|
|
37340
|
+
function yamlString2(value) {
|
|
36478
37341
|
return JSON.stringify(value);
|
|
36479
37342
|
}
|
|
36480
37343
|
var PARTIAL_WARNING = [
|
|
@@ -36513,8 +37376,8 @@ function renderExtractedText(doc2) {
|
|
|
36513
37376
|
const frontmatter = [
|
|
36514
37377
|
"---",
|
|
36515
37378
|
"plinth_generated: true",
|
|
36516
|
-
`source_file: ${doc2.bytesInMirror ?
|
|
36517
|
-
`source_uri: ${doc2.sourceUri === undefined ? "unknown" : doc2.sourceUri === null ? "null" :
|
|
37379
|
+
`source_file: ${doc2.bytesInMirror ? yamlString2(`files/${doc2.relPath}`) : "null"}`,
|
|
37380
|
+
`source_uri: ${doc2.sourceUri === undefined ? "unknown" : doc2.sourceUri === null ? "null" : yamlString2(doc2.sourceUri)}`,
|
|
36518
37381
|
`content_quality: ${doc2.quality ?? "unchecked"}`,
|
|
36519
37382
|
`body_withheld: ${withheld}`,
|
|
36520
37383
|
`extracted_chars: ${withheld ? 0 : doc2.text.length}`,
|
|
@@ -37799,6 +38662,97 @@ function discardSink() {
|
|
|
37799
38662
|
const stream = { write: () => true };
|
|
37800
38663
|
return { stdout: stream, stderr: stream };
|
|
37801
38664
|
}
|
|
38665
|
+
async function flushHeldEntityOp(deps) {
|
|
38666
|
+
const now = deps.now ?? (() => new Date);
|
|
38667
|
+
try {
|
|
38668
|
+
const result = await pushFile({
|
|
38669
|
+
workspaceSlug: deps.workspaceSlug,
|
|
38670
|
+
baseUrl: deps.baseUrl,
|
|
38671
|
+
pat: deps.pat,
|
|
38672
|
+
absolutePath: deps.op.absolutePath,
|
|
38673
|
+
entityType: deps.op.entityType,
|
|
38674
|
+
state: deps.getState(),
|
|
38675
|
+
exists: deps.op.kind === "overwrite",
|
|
38676
|
+
unlinkContext: deps.op.kind === "delete" ? { id: deps.op.id, slug: deps.op.slug, updated_at: deps.op.updatedAt } : null,
|
|
38677
|
+
deps: {
|
|
38678
|
+
fetchImpl: deps.fetchImpl,
|
|
38679
|
+
now,
|
|
38680
|
+
mutex: deps.mutex,
|
|
38681
|
+
output: deps.output
|
|
38682
|
+
}
|
|
38683
|
+
});
|
|
38684
|
+
deps.setState(result.state);
|
|
38685
|
+
reportOutcome(deps.spinner, deps.workspaceSlug, deps.getState(), result.outcome);
|
|
38686
|
+
} catch (err) {
|
|
38687
|
+
const msg = err instanceof Error ? err.message : "unknown push error";
|
|
38688
|
+
deps.output?.log(pushFailedLine({
|
|
38689
|
+
entityType: deps.op.entityType,
|
|
38690
|
+
slug: deps.op.slug,
|
|
38691
|
+
id: deps.op.id,
|
|
38692
|
+
...categorizeError(err)
|
|
38693
|
+
}));
|
|
38694
|
+
deps.spinner.warn(`Push failed for ${deps.op.entityType}/${deps.op.slug}: ${msg}`);
|
|
38695
|
+
if (isRecordableFailure(err)) {
|
|
38696
|
+
deps.setState(recordEntityUnsynced({
|
|
38697
|
+
state: deps.getState(),
|
|
38698
|
+
workspaceSlug: deps.workspaceSlug,
|
|
38699
|
+
absolutePath: deps.op.absolutePath,
|
|
38700
|
+
op: deps.op.kind === "delete" ? "delete" : "upsert",
|
|
38701
|
+
err,
|
|
38702
|
+
now
|
|
38703
|
+
}));
|
|
38704
|
+
}
|
|
38705
|
+
}
|
|
38706
|
+
}
|
|
38707
|
+
async function flushHeldFileOpImpl(deps) {
|
|
38708
|
+
const now = deps.now ?? (() => new Date);
|
|
38709
|
+
const fileDeps = {
|
|
38710
|
+
fetchImpl: deps.fetchImpl,
|
|
38711
|
+
now,
|
|
38712
|
+
output: deps.output,
|
|
38713
|
+
suppressor: deps.suppressor
|
|
38714
|
+
};
|
|
38715
|
+
const run = async () => {
|
|
38716
|
+
try {
|
|
38717
|
+
const result = deps.op.kind === "delete" ? await pushFilesDelete({
|
|
38718
|
+
workspaceSlug: deps.workspaceSlug,
|
|
38719
|
+
baseUrl: deps.baseUrl,
|
|
38720
|
+
pat: deps.pat,
|
|
38721
|
+
relPath: deps.op.relPath,
|
|
38722
|
+
state: deps.getState(),
|
|
38723
|
+
deps: fileDeps
|
|
38724
|
+
}) : await pushFilesUpsert({
|
|
38725
|
+
workspaceSlug: deps.workspaceSlug,
|
|
38726
|
+
baseUrl: deps.baseUrl,
|
|
38727
|
+
pat: deps.pat,
|
|
38728
|
+
absolutePath: deps.op.absolutePath,
|
|
38729
|
+
relPath: deps.op.relPath,
|
|
38730
|
+
state: deps.getState(),
|
|
38731
|
+
deps: fileDeps
|
|
38732
|
+
});
|
|
38733
|
+
deps.setState(result.state);
|
|
38734
|
+
reportFileOutcome(deps.spinner, deps.workspaceSlug, deps.getState(), result.outcome);
|
|
38735
|
+
} catch (err) {
|
|
38736
|
+
const msg = err instanceof Error ? err.message : "unknown push error";
|
|
38737
|
+
deps.output?.log(fileFailedLine({ path: deps.op.relPath, ...categorizeError(err) }));
|
|
38738
|
+
deps.spinner.warn(`File push failed for files/${deps.op.relPath}: ${msg}`);
|
|
38739
|
+
if (isRecordableFailure(err)) {
|
|
38740
|
+
deps.setState(recordFileUnsynced({
|
|
38741
|
+
state: deps.getState(),
|
|
38742
|
+
relPath: deps.op.relPath,
|
|
38743
|
+
op: deps.op.kind === "delete" ? "delete" : "upsert",
|
|
38744
|
+
err,
|
|
38745
|
+
now
|
|
38746
|
+
}));
|
|
38747
|
+
}
|
|
38748
|
+
}
|
|
38749
|
+
};
|
|
38750
|
+
if (deps.mutex) {
|
|
38751
|
+
await deps.mutex.run(`files:${deps.op.relPath}`, run);
|
|
38752
|
+
} else {
|
|
38753
|
+
await run();
|
|
38754
|
+
}
|
|
38755
|
+
}
|
|
37802
38756
|
async function runStart(workspaceSlug, deps = {}) {
|
|
37803
38757
|
const daemonLog = deps.logSink ?? new DaemonLog({ path: daemonLogPath(), now: deps.now });
|
|
37804
38758
|
const out = new Output({
|
|
@@ -38052,22 +39006,25 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
38052
39006
|
return r3 ? { entityType: r3.entityType, slug: r3.entitySlug } : null;
|
|
38053
39007
|
};
|
|
38054
39008
|
const suppressor = new PullSuppressor;
|
|
38055
|
-
const flushHeldOp =
|
|
38056
|
-
|
|
38057
|
-
|
|
38058
|
-
|
|
38059
|
-
|
|
38060
|
-
|
|
38061
|
-
|
|
38062
|
-
state
|
|
38063
|
-
|
|
38064
|
-
|
|
38065
|
-
|
|
38066
|
-
|
|
38067
|
-
|
|
38068
|
-
|
|
38069
|
-
};
|
|
39009
|
+
const flushHeldOp = (op) => flushHeldEntityOp({
|
|
39010
|
+
workspaceSlug: slug,
|
|
39011
|
+
baseUrl: entry2.api_base_url,
|
|
39012
|
+
pat,
|
|
39013
|
+
op,
|
|
39014
|
+
getState: () => state,
|
|
39015
|
+
setState: (s2) => {
|
|
39016
|
+
state = s2;
|
|
39017
|
+
},
|
|
39018
|
+
mutex,
|
|
39019
|
+
fetchImpl,
|
|
39020
|
+
now: deps.now,
|
|
39021
|
+
output: out,
|
|
39022
|
+
spinner
|
|
39023
|
+
});
|
|
38070
39024
|
const gate = new DestructiveGate({
|
|
39025
|
+
onError: (err) => {
|
|
39026
|
+
out.log(`[DESTRUCTIVE_SETTLE_FAILED source=entity-watch reason=${categorizeError(err).reason}]`);
|
|
39027
|
+
},
|
|
38071
39028
|
onSettle: async (ops) => {
|
|
38072
39029
|
const batch = dedupeOps(ops);
|
|
38073
39030
|
if (batch.length === 0)
|
|
@@ -38090,43 +39047,49 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
38090
39047
|
prior_len: op.kind === "overwrite" ? op.priorLen : undefined,
|
|
38091
39048
|
new_len: op.kind === "overwrite" ? op.newLen : undefined
|
|
38092
39049
|
}));
|
|
38093
|
-
|
|
38094
|
-
|
|
38095
|
-
|
|
38096
|
-
|
|
38097
|
-
|
|
38098
|
-
|
|
38099
|
-
|
|
39050
|
+
let stashPath;
|
|
39051
|
+
try {
|
|
39052
|
+
stashPath = stashPendingBatch({
|
|
39053
|
+
workspaceSlug: slug,
|
|
39054
|
+
batchId,
|
|
39055
|
+
timestamp: ts,
|
|
39056
|
+
tripReason: result.reason,
|
|
39057
|
+
ops: pendingOps
|
|
39058
|
+
});
|
|
39059
|
+
} catch (err) {
|
|
39060
|
+
const msg = err instanceof Error ? err.message : "unknown error";
|
|
39061
|
+
out.log(`[DESTRUCTIVE_HELD_STASH_FAILED source=entity-watch reason=${categorizeError(err).reason}]`);
|
|
39062
|
+
spinner.warn(`Could not quarantine ${batch.length} destructive op${batch.length === 1 ? "" : "s"} (${msg}); they were dropped and stay local-only.`);
|
|
39063
|
+
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
39064
|
+
refreshFrozen();
|
|
39065
|
+
return;
|
|
39066
|
+
}
|
|
38100
39067
|
refreshFrozen();
|
|
38101
39068
|
out.log(`[DESTRUCTIVE_HELD batch=${batchId} ops=${batch.length} reason=${result.reason.kind}]`);
|
|
38102
39069
|
spinner.warn(`Held ${batch.length} destructive op${batch.length === 1 ? "" : "s"} pending confirm → ${stashPath}. Run \`plinth confirm\` to review.`);
|
|
38103
39070
|
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
38104
39071
|
}
|
|
38105
39072
|
});
|
|
38106
|
-
const flushHeldFileOp =
|
|
38107
|
-
|
|
38108
|
-
|
|
38109
|
-
|
|
38110
|
-
|
|
38111
|
-
|
|
38112
|
-
|
|
38113
|
-
|
|
38114
|
-
|
|
38115
|
-
|
|
38116
|
-
|
|
38117
|
-
|
|
38118
|
-
|
|
38119
|
-
|
|
38120
|
-
|
|
38121
|
-
|
|
38122
|
-
state,
|
|
38123
|
-
deps: fileDeps
|
|
38124
|
-
});
|
|
38125
|
-
state = result.state;
|
|
38126
|
-
reportFileOutcome(spinner, slug, state, result.outcome);
|
|
38127
|
-
});
|
|
38128
|
-
};
|
|
39073
|
+
const flushHeldFileOp = (op) => flushHeldFileOpImpl({
|
|
39074
|
+
workspaceSlug: slug,
|
|
39075
|
+
baseUrl: entry2.api_base_url,
|
|
39076
|
+
pat,
|
|
39077
|
+
op,
|
|
39078
|
+
getState: () => state,
|
|
39079
|
+
setState: (s2) => {
|
|
39080
|
+
state = s2;
|
|
39081
|
+
},
|
|
39082
|
+
mutex,
|
|
39083
|
+
fetchImpl,
|
|
39084
|
+
now: deps.now,
|
|
39085
|
+
output: out,
|
|
39086
|
+
spinner,
|
|
39087
|
+
suppressor
|
|
39088
|
+
});
|
|
38129
39089
|
const fileGate = new FileDestructiveGate({
|
|
39090
|
+
onError: (err) => {
|
|
39091
|
+
out.log(`[DESTRUCTIVE_SETTLE_FAILED source=files-watch reason=${categorizeError(err).reason}]`);
|
|
39092
|
+
},
|
|
38130
39093
|
onSettle: async (ops) => {
|
|
38131
39094
|
const batch = dedupeFileOps(ops);
|
|
38132
39095
|
if (batch.length === 0)
|
|
@@ -38140,13 +39103,23 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
38140
39103
|
const ts = (deps.now ?? (() => new Date))();
|
|
38141
39104
|
const batchId = batchIdFromTimestamp(ts);
|
|
38142
39105
|
const pendingOps = batch.map(fileOpToPendingOp);
|
|
38143
|
-
|
|
38144
|
-
|
|
38145
|
-
|
|
38146
|
-
|
|
38147
|
-
|
|
38148
|
-
|
|
38149
|
-
|
|
39106
|
+
let stashPath;
|
|
39107
|
+
try {
|
|
39108
|
+
stashPath = stashPendingBatch({
|
|
39109
|
+
workspaceSlug: slug,
|
|
39110
|
+
batchId,
|
|
39111
|
+
timestamp: ts,
|
|
39112
|
+
tripReason: result.reason,
|
|
39113
|
+
ops: pendingOps
|
|
39114
|
+
});
|
|
39115
|
+
} catch (err) {
|
|
39116
|
+
const msg = err instanceof Error ? err.message : "unknown error";
|
|
39117
|
+
out.log(`[DESTRUCTIVE_HELD_STASH_FAILED source=files-watch reason=${categorizeError(err).reason}]`);
|
|
39118
|
+
spinner.warn(`Could not quarantine ${batch.length} destructive file op${batch.length === 1 ? "" : "s"} (${msg}); they were dropped and stay local-only.`);
|
|
39119
|
+
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
39120
|
+
refreshFrozen();
|
|
39121
|
+
return;
|
|
39122
|
+
}
|
|
38150
39123
|
refreshFrozen();
|
|
38151
39124
|
out.log(`[DESTRUCTIVE_HELD batch=${batchId} ops=${batch.length} reason=${result.reason.kind} source=files-watch]`);
|
|
38152
39125
|
spinner.warn(`Held ${batch.length} destructive file op${batch.length === 1 ? "" : "s"} pending confirm → ${stashPath}. Run \`plinth confirm\` to review.`);
|
|
@@ -38327,9 +39300,76 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
38327
39300
|
loginHint: `plinth login --workspace ${slug}`
|
|
38328
39301
|
});
|
|
38329
39302
|
pullLoop.start();
|
|
39303
|
+
let contractMismatchAnnounced = false;
|
|
39304
|
+
let collisionAnnounced = false;
|
|
39305
|
+
const calendarLoop = new CalendarLoop({
|
|
39306
|
+
runOnce: async () => {
|
|
39307
|
+
const attemptedAt = (deps.now?.() ?? new Date).toISOString();
|
|
39308
|
+
try {
|
|
39309
|
+
const response = await fetchCalendar({
|
|
39310
|
+
baseUrl: entry2.api_base_url,
|
|
39311
|
+
pat,
|
|
39312
|
+
fetchImpl
|
|
39313
|
+
});
|
|
39314
|
+
const result = projectCalendar({
|
|
39315
|
+
workspaceSlug: slug,
|
|
39316
|
+
response,
|
|
39317
|
+
roster: readRoster(slug)
|
|
39318
|
+
});
|
|
39319
|
+
const notRead = response.calendars.filter((c3) => c3.status !== "fetched").length;
|
|
39320
|
+
daemonLog.log(calendarProjectedLine({
|
|
39321
|
+
written: result.written,
|
|
39322
|
+
pruned: result.pruned,
|
|
39323
|
+
calendars: response.calendars.length,
|
|
39324
|
+
notRead
|
|
39325
|
+
}));
|
|
39326
|
+
if (result.collided > 0 && !collisionAnnounced) {
|
|
39327
|
+
collisionAnnounced = true;
|
|
39328
|
+
spinner.warn(`${result.collided} calendar row${result.collided === 1 ? "" : "s"} ` + `shared a file name with another and ${result.collided === 1 ? "was" : "were"} skipped.`);
|
|
39329
|
+
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
39330
|
+
}
|
|
39331
|
+
contractMismatchAnnounced = false;
|
|
39332
|
+
return { ok: true, written: result.written, pruned: result.pruned };
|
|
39333
|
+
} catch (err) {
|
|
39334
|
+
const failure = classifyCalendarFailure(err);
|
|
39335
|
+
let staled = 0;
|
|
39336
|
+
try {
|
|
39337
|
+
staled = markCalendarStale({
|
|
39338
|
+
workspaceSlug: slug,
|
|
39339
|
+
attemptedAt,
|
|
39340
|
+
reason: failure.reason
|
|
39341
|
+
}).rewritten;
|
|
39342
|
+
} catch (staleErr) {
|
|
39343
|
+
out.log(`[CALENDAR_STALE_FAILED ${categorizeError(staleErr).reason}]`);
|
|
39344
|
+
}
|
|
39345
|
+
daemonLog.log(calendarFailedLine({ ...categorizeError(err), staled }));
|
|
39346
|
+
if (err instanceof CalendarContractError && !contractMismatchAnnounced) {
|
|
39347
|
+
contractMismatchAnnounced = true;
|
|
39348
|
+
spinner.warn(`Calendar refresh: the server's response did not match the contract ` + `this daemon was built against. ${err.message}`);
|
|
39349
|
+
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
39350
|
+
}
|
|
39351
|
+
return { ok: false, failure };
|
|
39352
|
+
}
|
|
39353
|
+
},
|
|
39354
|
+
onProjected: (written, pruned) => {
|
|
39355
|
+
const parts = [`${written} calendar file${written === 1 ? "" : "s"}`];
|
|
39356
|
+
if (pruned > 0)
|
|
39357
|
+
parts.push(`${pruned} pruned`);
|
|
39358
|
+
spinner.success(`↻ ${parts.join(", ")}`);
|
|
39359
|
+
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
39360
|
+
},
|
|
39361
|
+
notice: (kind, message) => {
|
|
39362
|
+
daemonLog.log(calendarNoticeLine(kind));
|
|
39363
|
+
spinner.warn(message);
|
|
39364
|
+
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
39365
|
+
},
|
|
39366
|
+
loginHint: `plinth login --workspace ${slug}`
|
|
39367
|
+
});
|
|
39368
|
+
calendarLoop.startNow();
|
|
38330
39369
|
const shutdown = (signal) => {
|
|
38331
39370
|
(async () => {
|
|
38332
39371
|
pullLoop.stop();
|
|
39372
|
+
calendarLoop.stop();
|
|
38333
39373
|
spinner.warn(`Received ${signal}, draining pending pushes…`);
|
|
38334
39374
|
try {
|
|
38335
39375
|
await Promise.race([
|