oysterun 1.5.3-beta.1 → 1.5.3-beta.2

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.
@@ -6,7 +6,15 @@ import { fileURLToPath } from "url";
6
6
  import { resolveDefaultBrowsePathAsync, resolveDirectoryPathAsync } from "./config.mjs";
7
7
  import { completeSchedulerSessionSetupPayloadForRuntime } from "./scheduler-setup-snapshot-contract.mjs";
8
8
  import { deriveBrowserRootProjectId } from "./browser-root-project-index.mjs";
9
- import { normalizePortableSetupSnapshot } from "./portable-scheduler-definitions.mjs";
9
+ import {
10
+ normalizePortableSchedulerDefinitionsToHostTimezone,
11
+ normalizePortableSetupSnapshot,
12
+ } from "./portable-scheduler-definitions.mjs";
13
+ import {
14
+ computeNextScheduleRunAt,
15
+ getHostSystemTimezone,
16
+ normalizeScheduleRule,
17
+ } from "./scheduler-rule-model.mjs";
10
18
  import { FULL_DISK_ACCESS_SETTINGS_URI as MACOS_FULL_DISK_ACCESS_SETTINGS_URI } from "./macos-permissions.mjs";
11
19
 
12
20
  const DEFAULT_LIMIT = 100;
@@ -92,47 +100,6 @@ function nowIso() {
92
100
  return new Date().toISOString();
93
101
  }
94
102
 
95
- function taipeiDateParts(date) {
96
- const parts = new Intl.DateTimeFormat("en-US", {
97
- timeZone: "Asia/Taipei",
98
- year: "numeric",
99
- month: "2-digit",
100
- day: "2-digit",
101
- hour: "2-digit",
102
- minute: "2-digit",
103
- second: "2-digit",
104
- hour12: false,
105
- }).formatToParts(date);
106
- const values = Object.fromEntries(
107
- parts
108
- .filter((part) => part.type !== "literal")
109
- .map((part) => [part.type, Number(part.value)])
110
- );
111
- return {
112
- year: values.year,
113
- month: values.month,
114
- day: values.day,
115
- };
116
- }
117
-
118
- function computeNextTaipeiDailyRunAt(time = "11:30", now = new Date()) {
119
- const match = String(time || "11:30").match(/^(\d{1,2}):(\d{2})$/);
120
- const hour = match ? Number(match[1]) : 11;
121
- const minute = match ? Number(match[2]) : 30;
122
- const parts = taipeiDateParts(now);
123
- let targetUtc = Date.UTC(
124
- parts.year,
125
- parts.month - 1,
126
- parts.day,
127
- hour - 8,
128
- minute,
129
- 0,
130
- 0
131
- );
132
- if (targetUtc <= now.getTime()) targetUtc += 24 * 60 * 60 * 1000;
133
- return new Date(targetUtc).toISOString();
134
- }
135
-
136
103
  async function normalizeCopiedDemoAgentSchedulers(copiedPath) {
137
104
  const schedulersPath = join(copiedPath, ".oysterun", "schedulers.json");
138
105
  const raw = await readFile(schedulersPath, "utf8");
@@ -145,6 +112,7 @@ async function normalizeCopiedDemoAgentSchedulers(copiedPath) {
145
112
  );
146
113
  }
147
114
  const timestamp = nowIso();
115
+ const hostTimezone = getHostSystemTimezone();
148
116
  const projectId = deriveBrowserRootProjectId(basename(copiedPath));
149
117
  const normalizedSchedulers = payload.schedulers.map((entry) => {
150
118
  const schedule = JSON.parse(JSON.stringify(entry));
@@ -155,17 +123,23 @@ async function normalizeCopiedDemoAgentSchedulers(copiedPath) {
155
123
  "demo_agent_scheduler_invalid"
156
124
  );
157
125
  }
158
- const time =
159
- schedule?.schedule_rule?.timezone === "Asia/Taipei"
160
- ? schedule.schedule_rule.time
161
- : "11:30";
162
126
  const id = randomUUID();
163
127
  schedule.id = id;
164
128
  schedule.agent_id = projectId;
165
129
  schedule.created_by = "oysterun-demo-agent-copy";
166
130
  schedule.enabled = true;
167
131
  schedule.status = "active";
168
- schedule.next_run_at = computeNextTaipeiDailyRunAt(time);
132
+ const hostRule = normalizeScheduleRule(
133
+ { ...schedule.schedule_rule, timezone: hostTimezone },
134
+ { timezone: hostTimezone }
135
+ );
136
+ schedule.next_run_at =
137
+ hostRule.type === "once"
138
+ ? hostRule.at
139
+ : computeNextScheduleRunAt(hostRule, {
140
+ after: timestamp,
141
+ timezone: hostTimezone,
142
+ });
169
143
  schedule.created_at = timestamp;
170
144
  schedule.updated_at = timestamp;
171
145
  const portableSetupSnapshot = normalizePortableSetupSnapshot(
@@ -195,9 +169,23 @@ async function normalizeCopiedDemoAgentSchedulers(copiedPath) {
195
169
  schedulersPath,
196
170
  `${JSON.stringify({ version: 1, schedulers: normalizedSchedulers }, null, 2)}\n`
197
171
  );
172
+ const timezoneNormalization =
173
+ normalizePortableSchedulerDefinitionsToHostTimezone(copiedPath, {
174
+ hostTimezone,
175
+ source: "demo_agent_copy",
176
+ now: timestamp,
177
+ });
178
+ const finalPayload = JSON.parse(await readFile(schedulersPath, "utf8"));
198
179
  return {
199
- scheduler_count: normalizedSchedulers.length,
200
- scheduler_ids: normalizedSchedulers.map((entry) => entry.id),
180
+ scheduler_count: finalPayload.schedulers.length,
181
+ scheduler_ids: finalPayload.schedulers.map((entry) => entry.id),
182
+ timezone_normalization: {
183
+ status: timezoneNormalization.status,
184
+ host_timezone: timezoneNormalization.host_timezone,
185
+ corrected_count: timezoneNormalization.corrected_count,
186
+ },
187
+ timezone_correction_count: timezoneNormalization.corrected_count,
188
+ timezone_corrections: timezoneNormalization.corrections,
201
189
  };
202
190
  }
203
191
 
@@ -523,6 +511,12 @@ export async function copyDemoAgent({
523
511
  copied_path: copiedPath,
524
512
  portable_scheduler_count: schedulerRegistration.scheduler_count,
525
513
  portable_scheduler_ids: schedulerRegistration.scheduler_ids,
514
+ portable_scheduler_timezone_normalization:
515
+ schedulerRegistration.timezone_normalization,
516
+ portable_scheduler_timezone_correction_count:
517
+ schedulerRegistration.timezone_correction_count || 0,
518
+ portable_scheduler_timezone_corrections:
519
+ schedulerRegistration.timezone_corrections || [],
526
520
  start_session_available: true,
527
521
  };
528
522
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oysterun-host-service",
3
- "version": "1.5.3-beta.1",
3
+ "version": "1.5.3-beta.2",
4
4
  "type": "module",
5
5
  "description": "Oysterun host service — runs on Mac mini, manages Claude Code sessions",
6
6
  "scripts": {
@@ -12,6 +12,7 @@ import { completeSchedulerSessionSetupPayloadForRuntime } from "./scheduler-setu
12
12
  import { deriveBrowserRootProjectId } from "./browser-root-project-index.mjs";
13
13
  import {
14
14
  computeNextScheduleRunAt,
15
+ getHostSystemTimezone,
15
16
  normalizeScheduleRule,
16
17
  } from "./scheduler-rule-model.mjs";
17
18
 
@@ -330,6 +331,151 @@ function writePortableFile(filePath, payload) {
330
331
  renameSync(tmpPath, filePath);
331
332
  }
332
333
 
334
+ function hasOwn(value, key) {
335
+ return Object.prototype.hasOwnProperty.call(value, key);
336
+ }
337
+
338
+ function existingTimezoneFields(schedule) {
339
+ const fields = [];
340
+ const add = (container, key, field) => {
341
+ if (!isObjectRecord(container) || !hasOwn(container, key)) return;
342
+ const timezone = normalizeOptionalString(container[key]);
343
+ fields.push({ container, key, field, timezone });
344
+ };
345
+ add(schedule, "timezone", "definition.timezone");
346
+ add(schedule.schedule_rule, "timezone", "definition.schedule_rule.timezone");
347
+ add(schedule.metadata, "timezone", "definition.metadata.timezone");
348
+ add(
349
+ schedule.metadata?.schedule_rule,
350
+ "timezone",
351
+ "definition.metadata.schedule_rule.timezone"
352
+ );
353
+ return fields;
354
+ }
355
+
356
+ function buildPortableTimezoneCorrection({
357
+ agentFolder,
358
+ schedule,
359
+ fields,
360
+ filePath,
361
+ hostTimezone,
362
+ source,
363
+ }) {
364
+ const mismatched = fields.find((field) => field.timezone !== hostTimezone);
365
+ const firstKnown = fields.find((field) => field.timezone);
366
+ const oldTimezone = mismatched?.timezone || firstKnown?.timezone || null;
367
+ const timePreserved =
368
+ normalizeOptionalString(schedule.schedule_rule?.time) ||
369
+ normalizeOptionalString(schedule.metadata?.schedule_rule?.time) ||
370
+ normalizeOptionalString(schedule.schedule_rule?.at) ||
371
+ null;
372
+ return {
373
+ agent_folder: agentFolder,
374
+ schedule_id: normalizeOptionalString(schedule.id) || null,
375
+ old_timezone: oldTimezone,
376
+ host_timezone: hostTimezone,
377
+ time_preserved: timePreserved,
378
+ portable_file_path: filePath,
379
+ source,
380
+ message: `Portable scheduler timezone normalized from ${
381
+ oldTimezone || "unspecified"
382
+ } to ${hostTimezone}; scheduled time preserved as ${
383
+ timePreserved || "absolute timestamp"
384
+ }.`,
385
+ };
386
+ }
387
+
388
+ function recomputePortableNextRunAtForHost(schedule, { hostTimezone, now }) {
389
+ if (!hasOwn(schedule, "next_run_at")) return schedule.next_run_at;
390
+ if (!isObjectRecord(schedule.schedule_rule)) return schedule.next_run_at;
391
+ const hostRule = { ...schedule.schedule_rule, timezone: hostTimezone };
392
+ const normalizedRule = normalizeScheduleRule(hostRule, {
393
+ timezone: hostTimezone,
394
+ });
395
+ if (normalizedRule.type === "once") {
396
+ return normalizeOptionalString(schedule.next_run_at) || normalizedRule.at;
397
+ }
398
+ return computeNextScheduleRunAt(normalizedRule, {
399
+ after: now,
400
+ timezone: hostTimezone,
401
+ });
402
+ }
403
+
404
+ export function normalizePortableSchedulerDefinitionsToHostTimezone(
405
+ agentFolder,
406
+ {
407
+ hostTimezone = getHostSystemTimezone(),
408
+ source = "browser_root_portable_scheduler_import",
409
+ now = null,
410
+ clock = () => new Date(),
411
+ } = {}
412
+ ) {
413
+ const realFolder = realpathSync(
414
+ normalizeRequiredString(agentFolder, "agentFolder")
415
+ );
416
+ const normalizedHostTimezone = normalizeScheduleRule(
417
+ { type: "daily", time: "00:00", timezone: hostTimezone },
418
+ { timezone: hostTimezone }
419
+ ).timezone;
420
+ const filePath = getPortableSchedulersJsonPath(realFolder);
421
+ const payload = readPortableFile(filePath);
422
+ const correctionTime = nowIso(now === null ? clock : () => now);
423
+ let changed = false;
424
+ const corrections = [];
425
+ const schedulers = payload.schedulers.map((entry) => {
426
+ if (!isObjectRecord(entry)) return entry;
427
+ const schedule = deepCloneJson(entry, "portable scheduler definition");
428
+ const fields = existingTimezoneFields(schedule);
429
+ const mismatched = fields.filter(
430
+ (field) => field.timezone !== normalizedHostTimezone
431
+ );
432
+ if (mismatched.length === 0) return schedule;
433
+
434
+ for (const field of fields) {
435
+ if (field.timezone !== normalizedHostTimezone) {
436
+ field.container[field.key] = normalizedHostTimezone;
437
+ }
438
+ }
439
+ if (hasOwn(schedule, "next_run_at")) {
440
+ schedule.next_run_at = recomputePortableNextRunAtForHost(schedule, {
441
+ hostTimezone: normalizedHostTimezone,
442
+ now: correctionTime,
443
+ });
444
+ }
445
+ schedule.updated_at = correctionTime;
446
+ corrections.push(
447
+ buildPortableTimezoneCorrection({
448
+ agentFolder: realFolder,
449
+ schedule,
450
+ fields,
451
+ filePath,
452
+ hostTimezone: normalizedHostTimezone,
453
+ source,
454
+ })
455
+ );
456
+ changed = true;
457
+ return schedule;
458
+ });
459
+ if (changed) {
460
+ writePortableFile(filePath, {
461
+ ...payload,
462
+ schedulers,
463
+ });
464
+ }
465
+ return {
466
+ status: changed
467
+ ? "portable_scheduler_timezone_normalized"
468
+ : "portable_scheduler_timezone_already_host",
469
+ agent_folder: realFolder,
470
+ portable_file_path: filePath,
471
+ host_timezone: normalizedHostTimezone,
472
+ scheduler_count: payload.schedulers.length,
473
+ corrected_count: corrections.length,
474
+ corrections,
475
+ changed,
476
+ };
477
+ }
478
+
333
479
  function normalizeBrowserRootProjectEntry(entry) {
334
480
  if (!isObjectRecord(entry)) {
335
481
  throw new Error("Browser Root project entry must be an object");
@@ -450,6 +596,66 @@ export class PortableSchedulerDefinitionStore {
450
596
  return filePath;
451
597
  }
452
598
 
599
+ normalizeDefinitionsForFolderToHostTimezone(
600
+ agentFolder,
601
+ {
602
+ hostTimezone = getHostSystemTimezone(),
603
+ source = "browser_root_portable_scheduler_import",
604
+ } = {}
605
+ ) {
606
+ const result = normalizePortableSchedulerDefinitionsToHostTimezone(
607
+ agentFolder,
608
+ {
609
+ hostTimezone,
610
+ source,
611
+ clock: this.clock,
612
+ }
613
+ );
614
+ if (result.changed) this.invalidateScheduleCache();
615
+ return result;
616
+ }
617
+
618
+ normalizeBrowserRootPortableSchedulersToHostTimezone({
619
+ hostTimezone = getHostSystemTimezone(),
620
+ source = "browser_root_portable_scheduler_import",
621
+ } = {}) {
622
+ const projects = this.getBrowserRootProjectFolders();
623
+ const results = [];
624
+ const corrections = [];
625
+ let schedulerCount = 0;
626
+ for (const project of projects) {
627
+ const result = this.normalizeDefinitionsForFolderToHostTimezone(
628
+ project.agent_folder,
629
+ { hostTimezone, source }
630
+ );
631
+ schedulerCount += result.scheduler_count;
632
+ corrections.push(...result.corrections);
633
+ results.push({
634
+ project_id: project.project_id,
635
+ agent_folder: project.agent_folder,
636
+ portable_file_path: result.portable_file_path,
637
+ scheduler_count: result.scheduler_count,
638
+ corrected_count: result.corrected_count,
639
+ status: result.status,
640
+ });
641
+ }
642
+ return {
643
+ status:
644
+ corrections.length > 0
645
+ ? "browser_root_portable_scheduler_timezone_normalized"
646
+ : "browser_root_portable_scheduler_timezone_already_host",
647
+ host_timezone: normalizeScheduleRule(
648
+ { type: "daily", time: "00:00", timezone: hostTimezone },
649
+ { timezone: hostTimezone }
650
+ ).timezone,
651
+ project_count: projects.length,
652
+ scheduler_count: schedulerCount,
653
+ corrected_count: corrections.length,
654
+ corrections,
655
+ results,
656
+ };
657
+ }
658
+
453
659
  buildPortableHash(definition) {
454
660
  return hashPortableScheduleDefinition({
455
661
  id: definition.id,
@@ -1120,6 +1120,29 @@ export class SchedulerService {
1120
1120
  .map((schedule) => this.serializeHostScheduleForDashboardUi(schedule));
1121
1121
  }
1122
1122
 
1123
+ normalizeBrowserRootPortableSchedules({ hostTimezone = null } = {}) {
1124
+ this.initialize();
1125
+ const timezoneNormalization =
1126
+ this.portableDefinitionStore.normalizeBrowserRootPortableSchedulersToHostTimezone(
1127
+ {
1128
+ hostTimezone: hostTimezone || getHostSystemTimezone(),
1129
+ source: "browser_root_portable_scheduler_import",
1130
+ }
1131
+ );
1132
+ return {
1133
+ status: timezoneNormalization.status,
1134
+ host_timezone: timezoneNormalization.host_timezone,
1135
+ project_count: timezoneNormalization.project_count,
1136
+ scheduler_count: timezoneNormalization.scheduler_count,
1137
+ timezone_correction_count: timezoneNormalization.corrected_count,
1138
+ timezone_corrections: timezoneNormalization.corrections,
1139
+ timezone_correction_notifications: timezoneNormalization.corrections.map(
1140
+ (correction) => correction.message
1141
+ ),
1142
+ results: timezoneNormalization.results,
1143
+ };
1144
+ }
1145
+
1123
1146
  getHostScheduleForDashboard(scheduleId) {
1124
1147
  return this.serializeHostScheduleForDashboardUi(
1125
1148
  this.requireHostSchedule(scheduleId)
@@ -14811,11 +14811,28 @@ const server = createServer(async (req, res) => {
14811
14811
  reason: "scheduler_owner_refresh",
14812
14812
  rebuildWatchers: true,
14813
14813
  });
14814
+ const timezoneNormalization =
14815
+ schedulerService.normalizeBrowserRootPortableSchedules({
14816
+ hostTimezone: getHostSystemTimezone(),
14817
+ });
14814
14818
  return respond(res, 200, {
14815
14819
  status: "scheduler_browser_root_refreshed",
14816
14820
  browser_root: projectIndex.root,
14817
14821
  project_count: projectIndex.projects.length,
14818
14822
  diagnostic_count: projectIndex.diagnostics.length,
14823
+ timezone_normalization: {
14824
+ status: timezoneNormalization.status,
14825
+ host_timezone: timezoneNormalization.host_timezone,
14826
+ project_count: timezoneNormalization.project_count,
14827
+ scheduler_count: timezoneNormalization.scheduler_count,
14828
+ corrected_count:
14829
+ timezoneNormalization.timezone_correction_count,
14830
+ },
14831
+ timezone_correction_count:
14832
+ timezoneNormalization.timezone_correction_count,
14833
+ timezone_corrections: timezoneNormalization.timezone_corrections,
14834
+ timezone_correction_notifications:
14835
+ timezoneNormalization.timezone_correction_notifications,
14819
14836
  schedules: schedulerService.listHostSchedulesForDashboard(),
14820
14837
  project_registration_issues:
14821
14838
  buildBrowserRootProjectRegistrationIssues(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oysterun",
3
- "version": "1.5.3-beta.1",
3
+ "version": "1.5.3-beta.2",
4
4
  "type": "module",
5
5
  "description": "Oysterun Host installer and local service",
6
6
  "bin": {