War Room Developer Guide - edmozley/freeitsm GitHub Wiki
How the war room is built, and the handful of decisions that everything else hangs off. For what it does and how to use it, see War Room.
Colour key: βοΈ service Β· π API Β· π₯οΈ page Β· π€ Warbot Β· π¨ CSS/JS Β· ποΈ schema Β· π©Ί health Β· π i18n Β· π docs
| π¨ | File | What it does |
|---|---|---|
| βοΈ | includes/warroom.php |
The whole service layer. Channels, access rules, messages, mentions, attachments, search, presence, retention, and the situation-report prompt. Every page and endpoint comes through here, so the access rule exists once |
| π | api/war-room/poll.php |
The 3-second heartbeat: new messages, presence in, presence out, the channel list with unread and mention counts β one request, four jobs |
| π | api/war-room/send.php |
Post a message, JSON or multipart. Both shapes land here because a message with a screenshot is still one message |
| π | api/war-room/message.php |
Edit (author only) and delete (author or war_room.manage) |
| π | api/war-room/channels.php |
Create / rename / archive a channel, open a DM, list |
| π | api/war-room/search.php |
Search the conversations you can see |
| π | api/war-room/attachment.php |
Serve one file, authorised against its channel |
| π | api/war-room/sitrep.php |
The AI situation report |
| π | api/war-room/alerts.php |
What is waiting for me β called by the header on every page, so it is the cheapest endpoint in the app |
| π€ | includes/warbot/tools.php |
The tool registry. Seven read-only tools: name, description, JSON schema, capability, handler |
| π€ | includes/warbot/warbot.php |
The engine: slash commands (no model), the system prompt, posting as the bot |
| π | api/war-room/warbot.php |
Ask Warbot to answer a message. Re-derives all trust; refuses to answer twice |
| βοΈ | includes/ai_provider.php |
aiProviderChatTools() β the tool-calling loop, added additively so the eight existing AI features are untouched |
| π₯οΈ | war-room/index.php |
The page. Channel list server-rendered for first paint, then refreshed by the poll |
| π₯οΈ |
war-room/settings/index.php + manifest.php
|
Retention and the AI provider. The manifest's setting_keys is what authorises those keys |
| π₯οΈ | war-room/help.php |
The user guide, on the shared help.css house style |
| π₯οΈ | includes/waffle-menu.php |
renderWarRoomAlerts() β the notifications bell. One shared function, so it reaches all 22 module headers with one edit
|
| π¨ | assets/css/war-room.css |
Everything the module looks like |
| π¨ | assets/js/war-room.js |
All of the client side |
| π¨ | assets/css/mobile.css |
LAYER 19 |
| π¨ | assets/css/theme.css |
--war-room-accent and friends, in both palettes |
| ποΈ |
database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php, api/system/db_verify.php
|
Seven tables, their indexes, their foreign keys, and the one-time migration off the original shape |
| π©Ί |
api/system/debug-tools/D008_war_room.php + system/debug-tools/d008/index.php
|
The health check |
| π | lang/en/war-room.php |
|
| π |
CHANGELOG.local.md, README.md, this wiki |
logged as #1008β#1012 |
Every conversation is a row in warroom_channels with a kind, so warroom_messages needs exactly one foreign key instead of a nullable column per kind. What differs is where each kind's identity comes from:
| Kind | Identity | Lifecycle |
|---|---|---|
all |
Fixed. One seeded row | None |
team |
team_id, UNIQUE, CASCADE. Name is not stored β read from teams at display time |
None |
custom |
A stored name | Create, rename, archive |
dm |
dm_key = "<lower id>:<higher id>", UNIQUE |
None |
π The team kind keeps every property that made the original "a channel IS a team" rule attractive β cannot duplicate, cannot orphan, cannot be renamed into a lie β but by constraint rather than by being the whole design.
β οΈ dm_keyUNIQUE is not tidiness. Without it, two people opening a DM with each other simultaneously get one conversation each and neither sees the other's β precisely the failure you would hit mid-incident.
Channels are created on demand at list time (warRoomEnsureChannels), so a team created ten minutes ago has a channel the first time anybody looks. No installer step, no cron.
| Constraint | Rule | Why |
|---|---|---|
fk_warroom_messages_channel |
CASCADE | delete a channel, its conversation goes |
fk_warroom_messages_analyst |
SET NULL | delete an analyst and the conversation SURVIVES β this is the record of an incident |
fk_warroom_messages_deleter |
SET NULL | a tombstone outlives the person who wrote it |
fk_warroom_channels_team |
CASCADE | a team channel goes with its team |
fk_warroom_channels_creator |
SET NULL | a channel outlives whoever opened it |
D008 checks every one of these, because a wrong rule does nothing at all until somebody is deleted β and then it quietly destroys an incident record that nobody looks at again until the review.
One request every 3 seconds carries messages, presence, the channel list, unread and mention counts. Not SSE: Apache + mod_php holds one process per open connection, so thirty analysts on an EventSource would sit on thirty workers during an incident. It stops while the tab is hidden.
β οΈ m.id > since_idcannot see an EDIT or a DELETE. Both change a message the caller already holds, so its id is below the watermark and it never comes back β an edit would stay stale on everyone else's screen.warRoomMessages()therefore also re-sends anything changed in the last 30 seconds.
π And that is why the client must UPSERT, not append. The first version appended, so a deleted message piled up as one tombstone every three seconds β "Message deleted by Sarah Williams", ten times. Fixed by replacing on
data-msg-id.π The general rule: the moment a poll can repeat itself, its consumer has to be idempotent. Re-delivery and blind append cannot coexist.
The client sends no list of ids. That is forced by the composer UX: you pick a name, and may then backspace down to just the first name. Anything the client resolved at pick time is wrong the moment the text is edited, and a hand-typed mention would never resolve at all.
So the body is stored exactly as typed β no @[39] tokens, which would leak into search results and into the transcript the AI reads β and warRoomResolveMentions() matches longest-first, full names before first names. An ambiguous first name notifies every match: two people looking beats nobody looking.
Recipients are filtered to warRoomChannelAudience(), or naming somebody would put a private channel's name and a snippet into their notifications panel.
β οΈ warRoomChannelAudience()iswarRoomCanAccessChannel()turned inside out, and the two must agree. A name that resolves in one but fails the other posts a mention nobody can open.
β οΈ The client's highlighting regex must fall back the same way. It first allowed a surname greedily, so@James hello abccaptured "James hello", matched nobody and highlighted nothing β while the server, falling back to the first word, was notifying James.
Unread is derived from warroom_reads, not a second column β so opening a channel clears its mentions and the two can never disagree.
| Needs the internet? | |
|---|---|
Hands β includes/warbot/tools.php
|
No. Plain SQL |
| Brain β the model | Yes |
The war room exists for the day the internet is down, so Warbot degrades rather than dies: slash commands run the same handlers with no model. A bot that goes silent during the outage it exists for reads as broken, not absent.
The registry is the product. Each tool is declared once, and an MCP server is a second consumer of that same array rather than a rewrite.
β οΈ Read-only, all of them. Anyone in the room can type instructions at Warbot. The limit is in what it can do, not what it is told not to do.β οΈ is_botexists becauseanalyst_id NULLalready means "deleted analyst". Without the flag every Warbot message renders as Former analyst.- The reply is triggered by the browser after the send, so no message waits on a model round trip β which makes the trigger untrusted and repeatable, so
api/war-room/warbot.phpre-derives the channel, the asker and whether Warbot was addressed, and checksreply_to_idbefore answering.
A module gate, for the tools that name one (3.3.0). A tool may carry 'module' => 'projects'; warbotToolAllowed() then needs analystCanAccessModule() as well as the capability, in both warbotToolsFor() (what the model is offered) and warbotRunTool() (what a slash command or a model call may run). The older tools name no module and are unchanged - whether the room is install-wide is still the open question in warbotToolSearchKnowledge(). The Projects tools (list_projects, project_overview, project_raid, project_tasks, project_dates) are thin handlers over includes/projects/assistant.php - the same text the MCP server gives - scoped by activeTenantReadFilter() (the asker's active company, as on the portfolio), without the budget, and a ServiceError (no such project, several match) is returned as the answer rather than as That lookup failed. Commands: /projects, /project <name>, /dates [days].
π The trap in the tool loop, and it is PHP's not the API's. A tool taking no arguments arrives as
"input": {}.json_decode(β¦, true)makes that an empty PHP array, which re-encodes as[]β a JSON array. Anthropic then rejects the echoed assistant turn withtool_use.input: Input should be an object. It fails intermittently, only once the model reaches a parameterless tool, so it looks like a flaky provider.aiProviderChatTools()casts everytool_useinput back to an object.
β οΈ The model answers in markdown unless told not to. We render withtextContent, so asterisks would show literally. Fixed in the prompt β not by rendering HTML from model output, which would undo the rule below.
- Every access check is server-side, on every read and every write β never by rendering a shorter channel list. Anything unexpected fails closed.
-
All message text is inserted with
textContent. NeverinnerHTML, not for bodies, not for filenames, not for the AI report. There is no sanitiser on purpose: not producing HTML is stronger than cleaning it. -
Attachments: stored through
includes/uploads.php(the one home for the rules), served through an authorising endpoint with the Content-Type derived from our own extension map. The directory is denied wholesale by.htaccess+web.config. β οΈ There is nocontent_typecolumn, deliberately β storing the uploader's claim would leave something for a future endpoint to trust by mistake.β οΈ Retention is not the only way a message disappears. Deleting a team cascades channel β messages β attachment rows, and nothing in that chain touches the filesystem.warRoomSweepOrphanFiles()runs on the send path, hourly, regardless of the retention setting β otherwise "keep forever" means the rows go and the bytes stay.
β οΈ When testing access, pair every "cannot do X" with a positive control. The first negative-control pass here showed four clean 403s and proved nothing: the test analyst had no war-room module access, so every refusal was module-level. Grant the module, re-run, then the channel checks mean something.
System β Debug Tools β D008 exists because this is a break-glass feature: every other module tells you it is broken the moment you use it, but this one is opened for the first time during an incident.
It checks the tables, the delete rules above, the all-hands channel, duplicated DM threads, the attachments folder and its guards, orphaned files, retention backlog, and runs one Warbot tool live β a tool whose query names a column that does not exist returns no rows rather than an error, so "registered" proves nothing.
The end-to-end test writes a message, reads it back and records presence inside a transaction that is rolled back, so nothing appears in an open war room. (D007 cannot do this; a full-text search cannot see uncommitted rows.)
β οΈ lastInsertId()returns 0 if anything else touches the connection first.warRoomSend()prunes after inserting; reading the id after the prune gave 0 while the message saved fine. Read the id first.β οΈ Column names: read them out ofinformation_schema, do not remember them. Four were wrong on the first pass of the Warbot tools βstatus_services/status_incidents(notservice_status_*),changes.title,assets.hostname, and CMDBfrom_object_id/to_object_id(notsource_/target_). That last is the dangerous kind: a wrong column returns no rows, not an error, so Warbot reported "no recorded relationships" for every object.β οΈ Phantom tokens. The module usedvar(--war-room-accent, #ea580c)everywhere before those tokens existed intheme.css. The fallback masks it β and means dark mode never adapts. Grep everyvar(--x)againsttheme.css.β οΈ Bump the cache-busters.mobile.css,theme.css,war-room.css,war-room.jsβ andmobile.css/theme.cssare linked from every page, so bump them everywhere.
- User guide: War Room Β· Help page:
war-room/help.php - Related: MobileβFriendly Β· FullβText Search (and why search here does not use it) Β· Theming and Dark Mode
- Changelog: #1008 (first cut) Β· #1009 (channels, DMs, search, attachments, situation report) Β· #1010 (mentions, notifications, edit/delete) Β· #1011 (mention typing) Β· #1012 (Warbot)