Files
llm/plugins/1c/parser/scheduled_job.py
T

452 lines
16 KiB
Python

"""Pure decoder and safe tree editor for 1C scheduled-job schedules."""
from __future__ import annotations
import copy
import re
from datetime import datetime
from typing import Any
WRITABLE_SCALAR_FIELDS = {
"begin_date",
"end_date",
"begin_time",
"end_time",
"completion_time",
"completion_interval",
"repeat_period_in_day",
"repeat_pause",
"week_day_in_month",
"day_in_month",
"weeks_period",
"days_repeat_period",
}
WRITABLE_LIST_FIELDS = {"week_days", "months"}
INTEGER_RANGES = {
"completion_interval": (0, 2_147_483_647),
"repeat_period_in_day": (0, 2_147_483_647),
"repeat_pause": (0, 2_147_483_647),
"week_day_in_month": (0, 5),
"day_in_month": (0, 31),
"weeks_period": (0, 2_147_483_647),
"days_repeat_period": (0, 2_147_483_647),
}
def _scalar(node: Any) -> str:
if not isinstance(node, dict):
return str(node or "")
if node.get("type") in {"atom", "string"}:
return str(node.get("value") or "")
return ""
def schedule_datetime(raw: str) -> tuple[str | None, str | None]:
if not re.fullmatch(r"\d{14}", raw):
return None, None
return (
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}",
f"{raw[8:10]}:{raw[10:12]}:{raw[12:14]}",
)
def schedule_layout(tree: Any) -> dict[str, Any]:
items = tree.get("items") if isinstance(tree, dict) and isinstance(tree.get("items"), list) else []
raw = [_scalar(item) for item in items]
if len(raw) < 13:
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload is shorter than the supported format."},
}
week_day_count = int(raw[8]) if re.fullmatch(r"-?\d+", raw[8]) else None
cursor = 9
if week_day_count is None or week_day_count < 0 or cursor + week_day_count > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "Invalid weekday collection in the SQL schedule payload."},
}
week_days_start = cursor
cursor += week_day_count
if cursor + 3 > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload has no month collection header."},
}
week_day_in_month_index = cursor
day_in_month_index = cursor + 1
month_count_index = cursor + 2
month_count = int(raw[month_count_index]) if re.fullmatch(r"-?\d+", raw[month_count_index]) else None
cursor += 3
if month_count is None or month_count < 0 or cursor + month_count > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "Invalid month collection in the SQL schedule payload."},
}
months_start = cursor
cursor += month_count
if cursor + 2 > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload has no repeat-period tail."},
}
return {
"status": "ok",
"raw": raw,
"indexes": {
"begin_date": 0,
"end_date": 1,
"begin_time": 2,
"end_time": 3,
"completion_time": 4,
"completion_interval": 5,
"repeat_period_in_day": 6,
"repeat_pause": 7,
"week_day_count": 8,
"week_days": list(range(week_days_start, week_days_start + week_day_count)),
"week_day_in_month": week_day_in_month_index,
"day_in_month": day_in_month_index,
"month_count": month_count_index,
"months": list(range(months_start, months_start + month_count)),
"weeks_period": cursor,
"days_repeat_period": cursor + 1,
},
"cursor": cursor + 2,
}
def decode_schedule(tree: Any, *, include_storage: bool = False) -> dict[str, Any]:
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
raw = layout["raw"]
indexes = layout["indexes"]
def integer(field: str) -> int | None:
value = raw[indexes[field]]
return int(value) if re.fullmatch(r"-?\d+", value) else None
begin_date, _ = schedule_datetime(raw[indexes["begin_date"]])
end_date, _ = schedule_datetime(raw[indexes["end_date"]])
_, begin_time = schedule_datetime(raw[indexes["begin_time"]])
_, end_time = schedule_datetime(raw[indexes["end_time"]])
_, completion_time = schedule_datetime(raw[indexes["completion_time"]])
result = {
"status": "ok",
"begin_date": begin_date,
"end_date": end_date,
"begin_time": begin_time,
"end_time": end_time,
"completion_time": completion_time,
"completion_interval": integer("completion_interval"),
"repeat_period_in_day": integer("repeat_period_in_day"),
"repeat_pause": integer("repeat_pause"),
"week_days": [int(raw[index]) for index in indexes["week_days"] if raw[index].isdigit()],
"week_day_in_month": integer("week_day_in_month"),
"day_in_month": integer("day_in_month"),
"months": [int(raw[index]) for index in indexes["months"] if raw[index].isdigit()],
"weeks_period": integer("weeks_period"),
"days_repeat_period": integer("days_repeat_period"),
"evidence": "live_sql_config_schedule_decoder",
}
if include_storage:
result["storage"] = {
"format": "scheduled_job_config_suffix_0",
"raw_values": raw,
"trailing_values": raw[int(layout["cursor"]) :],
}
return result
def rebuild_schedule_tree(tree: Any, requested: dict[str, Any]) -> dict[str, Any]:
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
raw = list(layout["raw"])
indexes = layout["indexes"]
current = decode_schedule(tree)
for field in WRITABLE_SCALAR_FIELDS:
if field not in requested:
continue
value = requested[field]
index = int(indexes[field])
if field in {"begin_date", "end_date"}:
raw[index] = str(value).replace("-", "") + (
raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000"
)
elif field in {"begin_time", "end_time", "completion_time"}:
raw[index] = (
raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101"
) + str(value).replace(":", "")
else:
raw[index] = str(value)
week_days = list(requested.get("week_days", current.get("week_days") or []))
months = list(requested.get("months", current.get("months") or []))
rebuilt_raw = [
*raw[:8],
str(len(week_days)),
*(str(value) for value in week_days),
raw[int(indexes["week_day_in_month"])],
raw[int(indexes["day_in_month"])],
str(len(months)),
*(str(value) for value in months),
raw[int(indexes["weeks_period"])],
raw[int(indexes["days_repeat_period"])],
*raw[int(layout["cursor"]) :],
]
rebuilt_tree = copy.deepcopy(tree)
rebuilt_tree["items"] = [{"type": "atom", "value": value} for value in rebuilt_raw]
verification = decode_schedule(rebuilt_tree)
if verification.get("status") != "ok":
return {
"status": "error",
"error": "schedule_rebuild_verification_failed",
"diagnostics": {"message": "The rebuilt scheduled-job tree did not pass the schedule decoder."},
}
for field, expected in requested.items():
if verification.get(field) != expected:
return {
"status": "error",
"error": "schedule_rebuild_verification_failed",
"field": field,
"diagnostics": {
"message": "A named schedule field changed during tree rebuild verification.",
"expected": expected,
"actual": verification.get(field),
},
}
return {
"status": "ok",
"tree": rebuilt_tree,
"schedule": verification,
"old_counts": {
"week_days": len(indexes["week_days"]),
"months": len(indexes["months"]),
},
"new_counts": {
"week_days": len(week_days),
"months": len(months),
},
}
def schedule_write_edits(tree: Any, requested: Any) -> dict[str, Any]:
if not isinstance(requested, dict) or not requested:
return {
"status": "invalid_argument",
"error": "schedule_required",
"diagnostics": {"message": "schedule must be a non-empty JSON object."},
}
unknown = sorted(set(requested) - WRITABLE_SCALAR_FIELDS - WRITABLE_LIST_FIELDS)
if unknown:
return {
"status": "invalid_argument",
"error": "unsupported_schedule_fields",
"diagnostics": {
"message": "The request contains unsupported scheduled-job fields.",
"fields": unknown,
"allowed_fields": sorted(WRITABLE_SCALAR_FIELDS | WRITABLE_LIST_FIELDS),
},
}
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
current = decode_schedule(tree)
raw = layout["raw"]
indexes = layout["indexes"]
normalized: dict[str, Any] = {}
edits: list[dict[str, Any]] = []
resized_collections: list[str] = []
for field, value in requested.items():
if field in {"begin_date", "end_date"}:
if value is None:
compact = "00010101"
elif isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
try:
datetime.fromisoformat(value)
except ValueError:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a real ISO date YYYY-MM-DD."},
}
compact = value.replace("-", "")
else:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be an ISO date YYYY-MM-DD or null."},
}
index = int(indexes[field])
encoded = compact + (raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000")
normalized[field] = "0001-01-01" if value is None else value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
if field in {"begin_time", "end_time", "completion_time"}:
if value is None:
compact = "000000"
elif isinstance(value, str) and re.fullmatch(r"\d{2}:\d{2}:\d{2}", value):
try:
datetime.strptime(value, "%H:%M:%S")
except ValueError:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a real time HH:MM:SS."},
}
compact = value.replace(":", "")
else:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a time HH:MM:SS or null."},
}
index = int(indexes[field])
encoded = (
raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101"
) + compact
normalized[field] = "00:00:00" if value is None else value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
if field in WRITABLE_LIST_FIELDS:
maximum = 7 if field == "week_days" else 12
if (
not isinstance(value, list)
or any(
isinstance(item, bool)
or not isinstance(item, int)
or item < 1
or item > maximum
for item in value
)
or len(set(value)) != len(value)
):
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {
"message": f"{field} must be a JSON array of unique integers from 1 to {maximum}."
},
}
field_indexes = list(indexes[field])
if len(value) != len(field_indexes):
resized_collections.append(field)
normalized[field] = list(value)
continue
normalized[field] = list(value)
for index, item in zip(field_indexes, value):
encoded = str(item)
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
minimum, maximum = INTEGER_RANGES[field]
if isinstance(value, bool) or not isinstance(value, int) or value < minimum or value > maximum:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {
"message": f"{field} must be a JSON integer from {minimum} to {maximum}."
},
}
index = int(indexes[field])
encoded = str(value)
normalized[field] = value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
if resized_collections:
rebuilt = rebuild_schedule_tree(tree, normalized)
if rebuilt.get("status") != "ok":
return rebuilt
edits = [
{
"replace_root": rebuilt["tree"],
"fields": sorted(normalized),
"resized_collections": sorted(resized_collections),
"old_counts": rebuilt["old_counts"],
"new_counts": rebuilt["new_counts"],
}
]
return {
"status": "ok",
"current": current,
"requested": normalized,
"edits": edits,
"counts": {
"requested_fields": len(normalized),
"edits": len(edits),
"resized_collections": len(resized_collections),
},
}
# Compatibility aliases retained by adapter_1c_server and existing clients.
config_schedule_datetime = schedule_datetime
scheduled_job_schedule_layout = schedule_layout
scheduled_job_sql_schedule = decode_schedule
scheduled_job_schedule_rebuild_tree = rebuild_schedule_tree
scheduled_job_schedule_write_edits = schedule_write_edits
SCHEDULED_JOB_WRITABLE_SCALAR_FIELDS = WRITABLE_SCALAR_FIELDS
SCHEDULED_JOB_WRITABLE_LIST_FIELDS = WRITABLE_LIST_FIELDS
SCHEDULED_JOB_INTEGER_RANGES = INTEGER_RANGES
__all__ = [
"INTEGER_RANGES",
"WRITABLE_LIST_FIELDS",
"WRITABLE_SCALAR_FIELDS",
"config_schedule_datetime",
"decode_schedule",
"rebuild_schedule_tree",
"schedule_datetime",
"schedule_layout",
"schedule_write_edits",
"scheduled_job_schedule_layout",
"scheduled_job_schedule_rebuild_tree",
"scheduled_job_schedule_write_edits",
"scheduled_job_sql_schedule",
]