Replaced metadata-driven reflection with self-describing `PersistableTask` types and a new `PersistableTaskRegistry`. Removed `persistable-tasks.json` and `TaskRegistry`, migrating to type-safe DI instantiation and explicit `restore()` methods. Fixed task restoration issues, ensuring `EmojiReactionRoleTask` and its `emojiMapping` now persist and restore correctly. Introduced `typeId()` for stable task identification, updated payload serialization for symmetry, and applied database migration to map FQCNs to logical IDs. Added unit tests for robust type discovery, payload symmetry, and registry behavior, ensuring no breaking changes.
18 KiB
sessionId
| sessionId |
|---|
| session-260925-070147-1a6u |
Requirements
Overview & Goals
The persistable-task system currently requires a hand-maintained resource file, src/main/resources/persistable-tasks.json, that duplicates each task class's fully-qualified name, constructor parameter names and parameter types. Adding a persistable task means editing code and JSON; changing a constructor or moving a package silently breaks restore at runtime with only a log line.
This is not hypothetical — the file is already broken:
| Declared in JSON | Actual code |
|---|---|
me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask |
me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask |
ctor (String, String, String, Map) |
ctor (TaskManager, String, String, String, Map) |
So today the only registered persistable task cannot be restored at all: Class.forName fails on the stale package, and even with the right package getConstructor would miss because the DI refactor prepended TaskManager. On top of that, EmojiTask.emojiMapping is populated only by the "auto loading" constructor, so any mismatch loses the entire emoji→role mapping.
The goal: delete persistable-tasks.json and make persistable tasks self-describing and self-registering, consistent with how BuiltInTask and ScheduledTask are already discovered via ReflectionHelper + ApplicationContext.
Scope
In scope
- Remove
persistable-tasks.jsonandTaskRegistry/TaskFactory's metadata-driven reflection. - Classpath-scan discovery of
PersistableTaskimplementations. - A
restore(JSONObject)contract implemented by each task, replacing reflective constructor matching. - A stable logical type id persisted in
tasks.task_classinstead of the FQCN, with a Flyway migration. - Fix the
EmojiReactionRoleTaskrestore breakage and theemojiMappingdata loss. - Symmetric payload serialization (what is written is what is read back).
Out of scope
- Replacing
org.jsonwith Jackson. - Changing
Task/StepTask/BuiltInTasklifecycles orTaskManagerImplconcurrency behaviour. - Reworking
Repository/TaskRepositoryor the Hibernate setup. - The unrelated issues tracked in
.junie/plans/improve-task-manager.md.
User Stories
- As a bot developer, I want to add a new persistable task by writing one class and nothing else, so I cannot forget to update a mapping file.
- As a bot developer, I want a constructor-signature change or package move to be caught by the compiler or a test, not by a runtime log at startup.
- As a server operator, I want tasks persisted before a restart — including their emoji mappings — to come back intact after a restart.
Functional Requirements
- FR-1
persistable-tasks.jsonis deleted; no resource file is read during persistable-task loading. - FR-2 Persistable task types are discovered by scanning
me.bobert.netneutralityfor concretePersistableTasksubtypes, the same package root already used byReflectionHelper.loadTasks. - FR-3 Each persistable task exposes a stable
typeId()(defaulting to its existinggetName()), unique across all discovered types; a duplicate id fails fast at startup with a clear error. - FR-4 Each persistable task implements
restore(JSONObject payload)to rebuild its own state; instantiation happens through the existing DI path (ReflectionHelper.instantiatewithApplicationContext). - FR-5
toPayload()andrestore()are symmetric: a task serialized and restored yields an equal logical state, includingemojiMapping. - FR-6
tasks.task_classstores thetypeId; existing rows are migrated from FQCN (including the stalefeature.reaction.task.*value) to the new id. - FR-7 A row whose
typeIdis unknown is logged and skipped without aborting the load of remaining tasks — current behaviour is preserved. - FR-8
TaskService.persistTaskrefuses to persist a task whose type is not registered, as it does today.
Non-Functional Requirements
- Classpath scanning happens once at startup (
TaskManagerImpl.init()), not per task row. - No new runtime dependency is introduced;
org.reflectionsandorg.jsonare already in use. - Existing persisted rows remain loadable after deployment — the migration must be the only manual step.
Technical Design
Current Implementation
App.main
└─ TaskManagerImpl(context) // holds ApplicationContext, creates TaskService(this)
└─ init()
├─ loadBuiltInTasks() // ReflectionHelper.loadTasks(BuiltInTask, context, ..) ← scanning + DI
└─ taskService.loadTasks()
├─ TaskRegistry.loadTaskConfig() // reads persistable-tasks.json
└─ for each OTask row:
TaskRegistry.getTaskCreator(oTask.taskClass) // keyed by FQCN
└─ TaskFactory.createTaskInstance(class, params, types, payload)
├─ Class.forName(className)
├─ map "string"/"int"/"map" → Class<?>[]
├─ pull each named value out of the payload
└─ clazz.getConstructor(types).newInstance(args)
Key files:
core/tasks/persistence/TaskRegistry.java— parses the JSON intoMap<String, TaskCreator>.core/tasks/persistence/TaskFactory.java—ParamTypeenum (onlystring/int/map) + reflective ctor call.core/tasks/PersistableTask.java—insert/save/delete/toPayload.core/tasks/impl/AbstractPersistableTask.java—toPayload()=new JSONObject(this)(bean getters).db/service/TaskService.java—loadTasks/persistTask/unpersistTask,validateTaskchecksid/lockId.db/entities/OTask.java—task_id(unique),task_class,payload(@Lob).bootstrap/ReflectionHelper.java—loadClassesFromPackage+instantiate(type, ApplicationContext).
The asymmetry is the root cause: writing uses bean introspection over all getters, reading uses a hand-declared subset of constructor params. Nothing keeps the two in sync.
Key Decisions
- Self-restoring tasks (confirmed with user). A task is created via the normal DI constructor and then told to hydrate itself from the payload. This eliminates parameter metadata entirely and makes the round trip visible in one class.
- Reuse
ReflectionHelper+ApplicationContextrather than a bespoke scanner, so persistable tasks follow the exact same discovery convention asBuiltInTaskandScheduledTask. - Stable
typeIdin the DB (confirmed with user).getName()already returns"emoji-reaction-role", sotypeId()defaults togetName()and no task needs a new literal. Package moves and class renames stop being breaking changes. - Prototype-per-type registry. The scan produces one throwaway instance per type used only to read
typeId(); each restore creates a fresh instance viaReflectionHelper.instantiateso no state leaks between rows. - Keep
org.json. Jackson was considered and rejected — it would touch every task's field mapping for no gain here.
Proposed Changes
A. Contract — core/tasks/PersistableTask.java
public interface PersistableTask extends Task {
/** Stable logical type id stored in tasks.task_class. Defaults to getName(). */
default String typeId() { return getName(); }
/** Rebuild this instance's state from a payload previously produced by toPayload(). */
void restore(JSONObject payload);
void insert(CommandContext context);
void save();
void delete();
String toPayload();
}
B. core/tasks/persistence/PersistableTaskRegistry.java (replaces TaskRegistry, no longer static)
public final class PersistableTaskRegistry {
private final Map<String, Class<? extends PersistableTask>> typesById;
private final ApplicationContext context;
public void scan(); // ReflectionHelper.loadTasks(PersistableTask.class, context, ..)
public boolean isRegistered(String typeId);
public PersistableTask create(String typeId, JSONObject payload); // instantiate + restore
}
scan()skips abstract types (AbstractPersistableTask,EmojiTaskare filtered by the existingModifier.isAbstractcheck inloadClassesFromPackage).- Duplicate
typeId→IllegalStateExceptionat startup, naming both classes. create()callsReflectionHelper.instantiate(clazz, context)thentask.restore(payload).
C. Delete TaskFactory.java (and its ParamType enum) and src/main/resources/persistable-tasks.json.
D. AbstractPersistableTask
toPayload()keepsnew JSONObject(this)but the base class becomes responsible for guaranteeingidandlockIdare present, sinceTaskService.validateTaskreads both.- Add a no-op
restore(JSONObject)default so leaf tasks only override when they carry extra state.
E. EmojiTask / EmojiReactionRoleTask — the actual bug fix
EmojiTask.restore(payload)readsguildId/channelId/messageIdand repopulatesemojiMappingfrompayload.getJSONObject("emojiMapping").- Because
guildId/channelId/messageIdarefinaland set by the ctor, they become non-final assigned-on-restore fields, or the restore path uses a dedicated protected initializer — whichever keepsgetId()(String.format(EmojiTaskContext.ID, guildId, messageId)) correct before the task is handed toTaskManager. - The vestigial "constructor for auto loading" overload in
EmojiReactionRoleTaskis removed; it exists only to serveTaskFactory.
F. db/service/TaskService.java
- Takes the
PersistableTaskRegistry(constructed byTaskManagerImpl, which already owns theApplicationContext). loadTasks()dropsTaskRegistry.loadTaskConfig()and resolves byoTask.getTaskClass()treated as a type id.persistTask()writestask.typeId()instead oftask.getClass().getName(), and guards withregistry.isRegistered(...).validateTaskis unchanged.
G. Migration — src/main/resources/db/migration/V2__task_type_id.sql
UPDATE tasks SET task_class = 'emoji-reaction-role'
WHERE task_class IN (
'me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask',
'me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask'
);
Column stays task_class (renaming it is cosmetic and would churn OTask); a comment documents that it now holds a logical id.
File Structure
| File | Change |
|---|---|
src/main/resources/persistable-tasks.json |
deleted |
core/tasks/persistence/TaskFactory.java |
deleted |
core/tasks/persistence/TaskRegistry.java |
replaced by PersistableTaskRegistry.java |
core/tasks/PersistableTask.java |
+ typeId(), + restore(JSONObject) |
core/tasks/impl/AbstractPersistableTask.java |
default restore, payload guarantees |
core/tasks/impl/EmojiTask.java |
implements restore, hydrates emojiMapping |
tasks/reaction/EmojiReactionRoleTask.java |
drop auto-loading ctor |
core/tasks/TaskManagerImpl.java |
build + scan registry, pass to TaskService |
db/service/TaskService.java |
registry injection, type-id keying |
src/main/resources/db/migration/V2__task_type_id.sql |
new |
docs/tasks.md, docs/persistence.md |
document the new flow, drop JSON references |
Architecture Diagram
graph LR
A[TaskManagerImpl.init] --> B[PersistableTaskRegistry.scan]
B --> C[Reflections: PersistableTask subtypes]
C --> D[typeId to Class map]
A --> E[TaskService.loadTasks]
E --> F[TaskRepository.findAll]
F --> G[registry.create typeId payload]
D --> G
G --> H[ReflectionHelper.instantiate DI]
H --> I[task.restore payload]
I --> J[TaskManager.insertPersistableTask]
Risks
- Final-field hydration in
EmojiTask. Restoring after construction meansguildId/messageIdcannot stayfinal. Mitigation: hydrate insidePersistableTaskRegistry.create()before the instance escapes, and assertgetId()matchesOTask.task_idinvalidateTask(already done today). - Scan picks up unintended subtypes. Any concrete
PersistableTaskinme.bobert.netneutralitybecomes registered. Mitigation: duplicate-id detection plus a test asserting the registered set. - Migration ordering. Flyway runs before
TaskManagerImpl.init()in the existing startup lifecycle, so V2 is applied before any load; worth re-verifying inApp.mainwiring. - DI constructor ambiguity.
ReflectionHelper.instantiatepicks the longest fully-resolvable constructor; removing the auto-loading overload leaves exactly one candidate forEmojiReactionRoleTask, which is the intent.
Testing
Validation Approach
Compile and run the existing suite (TaskManagerImplTest, ReflectionInjectionTest) to confirm no regression, then add focused unit tests around the new registry and the round trip. The key invariant — a task can be serialized and restored without loss — becomes a test rather than a hand-checked JSON file.
Key Scenarios
- Discovery:
PersistableTaskRegistry.scan()overme.bobert.netneutralityregistersemoji-reaction-roleand does not register the abstractAbstractPersistableTask/EmojiTask. - Round trip: build an
EmojiReactionRoleTaskwith a non-emptyemojiMapping, calltoPayload(), feed it toregistry.create("emoji-reaction-role", payload), and assertgetId(),getLockId(),guildId,channelId,messageIdand the fullemojiMappingall match. This is the regression test for the current data-loss bug. - DI: the restored instance has a non-null
taskManagerinjected viaApplicationContext, proving the DI path is used. - Persist keying:
TaskService.persistTaskstoresemoji-reaction-roleinOTask.task_class, not an FQCN. - End-to-end load: a stubbed
TaskRepositoryreturning one row with the new type id results intaskManager.insertPersistableTaskbeing called once.
Edge Cases
- Unknown
typeIdin a row → logged, skipped, remaining rows still load. - Duplicate
typeIdacross two classes → startup fails fast with both class names in the message. - Payload missing
emojiMapping→ restores to an empty map rather than throwing. validateTaskmismatches (id, payloadid, payloadlockId) still reject the row.- No
persistable-tasks.jsonon the classpath → nothing references it; a test asserts the resource is absent so it cannot be reintroduced by accident.
Test Changes
- New
PersistableTaskRegistryTest— discovery, duplicate detection, unknown id. - New
EmojiReactionRoleTaskPersistenceTest— payload round trip including emoji mapping. - Update
TaskManagerImplTestwhere it constructs the task service / registry. - Remove any assertions tied to
TaskFactory.ParamType.
Delivery Steps
✓ Step 1: Introduce the self-describing PersistableTask contract
PersistableTask carries its own type id and restore logic, with a compiling default for existing tasks.
- Add
default String typeId() { return getName(); }tocore/tasks/PersistableTask.java. - Add
void restore(JSONObject payload)to the interface. - Give
AbstractPersistableTaska no-oprestoredefault so only tasks with extra state override it. - Harden
AbstractPersistableTask.toPayload()to guaranteeidandlockIdare always present, sinceTaskService.validateTaskreads both. - Leave
TaskRegistry/TaskFactoryin place at this stage so the build stays green.
✓ Step 2: Add the scanning PersistableTaskRegistry and retire the JSON
Persistable task types are discovered from the classpath and persistable-tasks.json is gone.
- Create
core/tasks/persistence/PersistableTaskRegistrywithscan(),isRegistered(String)andcreate(String typeId, JSONObject payload). - Implement
scan()on top ofReflectionHelper.loadTasks(PersistableTask.class, context, ..), mappingtypeId()to the concrete class and throwing on duplicate ids naming both classes. - Implement
create()asReflectionHelper.instantiate(clazz, context)followed bytask.restore(payload). - Delete
core/tasks/persistence/TaskFactory.java,core/tasks/persistence/TaskRegistry.javaandsrc/main/resources/persistable-tasks.json. - Add
PersistableTaskRegistryTestcovering discovery ofemoji-reaction-role, exclusion of abstract types, and duplicate-id failure.
✓ Step 3: Fix EmojiTask restore and the lost emoji mapping
An EmojiReactionRoleTask survives a persist/restore cycle with its emoji mapping intact.
- Implement
restore(JSONObject)incore/tasks/impl/EmojiTask.java, hydratingguildId,channelId,messageIdand repopulatingemojiMappingfrom the payload. - Adjust the field declarations so restore-after-construction is possible while
getId()stays correct before the instance escapes the registry. - Remove the now-dead "constructor for auto loading" overload from
tasks/reaction/EmojiReactionRoleTask.java. - Handle a missing
emojiMappingkey by restoring an empty map instead of throwing. - Add
EmojiReactionRoleTaskPersistenceTestasserting a full round trip including a non-empty mapping and a DI-injectedtaskManager.
✓ Step 4: Key persistence by stable type id and migrate existing rows
tasks.task_class holds a logical type id, decoupling stored rows from package structure.
- Change
TaskService.persistTaskto writetask.typeId()instead oftask.getClass().getName(), guarded byregistry.isRegistered(...). - Change
TaskService.loadTasksto resolve rows throughPersistableTaskRegistry.create(...)and drop theTaskRegistry.loadTaskConfig()call. - Inject the registry into
TaskServicefromTaskManagerImpl, which already owns theApplicationContext, and triggerscan()duringTaskManagerImpl.init(). - Add
src/main/resources/db/migration/V2__task_type_id.sqlrewriting both the stalefeature.reaction.task.*and currenttasks.reaction.*FQCNs toemoji-reaction-role. - Keep unknown-type rows as logged-and-skipped, and update
TaskManagerImplTestfor the new construction signature.
✓ Step 5: Update the task and persistence documentation
The docs describe the scan-and-restore flow with no references to the deleted resource file.
- Rewrite the persistable-task section of
docs/tasks.mdto describetypeId(),restore()and classpath discovery, replacing theTaskRegistry/TaskFactoryJSON description. - Add a short "how to add a persistable task" recipe showing that a single class is sufficient.
- Update the
TaskServicebullet and theOTaskrow indocs/persistence.mdto state thattask_classstores a logical type id. - Note the
V2__task_type_id.sqlmigration in the schema section ofdocs/persistence.md.