- Restructure sidebar: Start → Guide → Operations → Developers → Reference - Fix EN/RU sidebar order (Installation before Quickstart) - Expand troubleshooting with diagnostics commands and task-log triage - Improve quickstart with prerequisites, pitfalls, and contributor links - Expand installation docs with verification commands - Add cyberpunk hero theme to landing page - Add atomicFile utility with tests and stage-runtime script - Harden team provisioning with better error handling and progress output - Add cross-team communication, kanban, and workSync improvements
371 lines
12 KiB
JavaScript
371 lines
12 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { writeJsonFileSync } = require('./atomicFile.js');
|
|
|
|
function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function readJson(filePath, fallbackValue) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
} catch {
|
|
return fallbackValue;
|
|
}
|
|
}
|
|
|
|
function writeJson(filePath, value) {
|
|
writeJsonFileSync(filePath, value);
|
|
}
|
|
|
|
function getInboxPath(paths, memberName) {
|
|
return path.join(paths.teamDir, 'inboxes', `${String(memberName).trim()}.json`);
|
|
}
|
|
|
|
function getSentMessagesPath(paths) {
|
|
return path.join(paths.teamDir, 'sentMessages.json');
|
|
}
|
|
|
|
function normalizeAttachments(attachments) {
|
|
if (!Array.isArray(attachments) || attachments.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const normalized = attachments
|
|
.filter((item) => item && typeof item === 'object')
|
|
.map((item) => ({
|
|
id: String(item.id || '').trim(),
|
|
filename: String(item.filename || '').trim(),
|
|
mimeType: String(item.mimeType || '').trim(),
|
|
size: Number(item.size || 0),
|
|
...(typeof item.filePath === 'string' && item.filePath.trim()
|
|
? { filePath: item.filePath.trim() }
|
|
: {}),
|
|
}))
|
|
.filter((item) => item.id && item.filename && item.mimeType && Number.isFinite(item.size));
|
|
|
|
return normalized.length > 0 ? normalized : undefined;
|
|
}
|
|
|
|
function normalizeTaskRefs(taskRefs) {
|
|
if (!Array.isArray(taskRefs) || taskRefs.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const normalized = taskRefs
|
|
.filter((item) => item && typeof item === 'object')
|
|
.map((item) => ({
|
|
taskId: String(item.taskId || '').trim(),
|
|
displayId: String(item.displayId || '').trim(),
|
|
teamName: String(item.teamName || '').trim(),
|
|
}))
|
|
.filter((item) => item.taskId && item.displayId && item.teamName);
|
|
|
|
return normalized.length > 0 ? normalized : undefined;
|
|
}
|
|
|
|
function normalizeMessageKind(messageKind) {
|
|
return messageKind === 'default' ||
|
|
messageKind === 'slash_command' ||
|
|
messageKind === 'slash_command_result' ||
|
|
messageKind === 'task_comment_notification' ||
|
|
messageKind === 'member_work_sync_nudge'
|
|
? messageKind
|
|
: undefined;
|
|
}
|
|
|
|
function normalizeWorkSyncIntent(workSyncIntent) {
|
|
return workSyncIntent === 'agenda_sync' || workSyncIntent === 'review_pickup'
|
|
? workSyncIntent
|
|
: undefined;
|
|
}
|
|
|
|
function normalizeStringList(value) {
|
|
if (!Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
const items = [...new Set(value.map((item) => String(item || '').trim()).filter(Boolean))];
|
|
return items.length > 0 ? items : undefined;
|
|
}
|
|
|
|
function normalizeSlashCommand(slashCommand) {
|
|
if (!slashCommand || typeof slashCommand !== 'object') {
|
|
return undefined;
|
|
}
|
|
const name = String(slashCommand.name || '').trim();
|
|
const command = String(slashCommand.command || '').trim();
|
|
if (!name || !command) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
name,
|
|
command,
|
|
...(typeof slashCommand.args === 'string' ? { args: slashCommand.args } : {}),
|
|
...(typeof slashCommand.knownDescription === 'string'
|
|
? { knownDescription: slashCommand.knownDescription }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function normalizeCommandOutput(commandOutput) {
|
|
if (!commandOutput || typeof commandOutput !== 'object') {
|
|
return undefined;
|
|
}
|
|
const stream = commandOutput.stream === 'stdout' || commandOutput.stream === 'stderr'
|
|
? commandOutput.stream
|
|
: undefined;
|
|
const commandLabel = String(commandOutput.commandLabel || '').trim();
|
|
if (!stream || !commandLabel) {
|
|
return undefined;
|
|
}
|
|
return { stream, commandLabel };
|
|
}
|
|
|
|
function buildMessage(flags, defaults) {
|
|
const timestamp =
|
|
typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso();
|
|
const messageId =
|
|
typeof flags.messageId === 'string' && flags.messageId.trim()
|
|
? flags.messageId.trim()
|
|
: crypto.randomUUID();
|
|
const attachments = normalizeAttachments(flags.attachments);
|
|
const taskRefs = normalizeTaskRefs(flags.taskRefs);
|
|
const messageKind = normalizeMessageKind(flags.messageKind);
|
|
const workSyncIntent = normalizeWorkSyncIntent(flags.workSyncIntent);
|
|
const workSyncReviewRequestEventIds = normalizeStringList(flags.workSyncReviewRequestEventIds);
|
|
const slashCommand = normalizeSlashCommand(flags.slashCommand);
|
|
const commandOutput = normalizeCommandOutput(flags.commandOutput);
|
|
|
|
return {
|
|
from:
|
|
typeof flags.from === 'string' && flags.from.trim()
|
|
? flags.from.trim()
|
|
: defaults.from || 'user',
|
|
...(defaults.to ? { to: defaults.to } : {}),
|
|
text: String(flags.text || ''),
|
|
timestamp,
|
|
read: defaults.read,
|
|
...(taskRefs ? { taskRefs } : {}),
|
|
...(flags.actionMode === 'do' || flags.actionMode === 'ask' || flags.actionMode === 'delegate'
|
|
? { actionMode: flags.actionMode }
|
|
: {}),
|
|
...(typeof flags.summary === 'string' && flags.summary.trim()
|
|
? { summary: flags.summary.trim() }
|
|
: {}),
|
|
...(typeof flags.commentId === 'string' && flags.commentId.trim()
|
|
? { commentId: flags.commentId.trim() }
|
|
: {}),
|
|
...(typeof flags.relayOfMessageId === 'string' && flags.relayOfMessageId.trim()
|
|
? { relayOfMessageId: flags.relayOfMessageId.trim() }
|
|
: {}),
|
|
...(typeof flags.source === 'string' && flags.source.trim() ? { source: flags.source.trim() } : {}),
|
|
...(typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()
|
|
? { leadSessionId: flags.leadSessionId.trim() }
|
|
: {}),
|
|
...(typeof flags.conversationId === 'string' && flags.conversationId.trim()
|
|
? { conversationId: flags.conversationId.trim() }
|
|
: {}),
|
|
...(typeof flags.replyToConversationId === 'string' && flags.replyToConversationId.trim()
|
|
? { replyToConversationId: flags.replyToConversationId.trim() }
|
|
: {}),
|
|
...(typeof flags.color === 'string' && flags.color.trim() ? { color: flags.color.trim() } : {}),
|
|
...(typeof flags.toolSummary === 'string' && flags.toolSummary.trim()
|
|
? { toolSummary: flags.toolSummary.trim() }
|
|
: {}),
|
|
...(Array.isArray(flags.toolCalls) && flags.toolCalls.length > 0
|
|
? {
|
|
toolCalls: flags.toolCalls
|
|
.filter((item) => item && typeof item === 'object' && typeof item.name === 'string')
|
|
.map((item) => ({
|
|
name: item.name,
|
|
...(typeof item.preview === 'string' ? { preview: item.preview } : {}),
|
|
})),
|
|
}
|
|
: {}),
|
|
...(messageKind ? { messageKind } : {}),
|
|
...(workSyncIntent ? { workSyncIntent } : {}),
|
|
...(typeof flags.workSyncIntentKey === 'string' && flags.workSyncIntentKey.trim()
|
|
? { workSyncIntentKey: flags.workSyncIntentKey.trim() }
|
|
: {}),
|
|
...(workSyncReviewRequestEventIds ? { workSyncReviewRequestEventIds } : {}),
|
|
...(slashCommand ? { slashCommand } : {}),
|
|
...(commandOutput ? { commandOutput } : {}),
|
|
...(attachments ? { attachments } : {}),
|
|
messageId,
|
|
};
|
|
}
|
|
|
|
function appendRow(filePath, row) {
|
|
const current = readJson(filePath, []);
|
|
const list = Array.isArray(current) ? current : [];
|
|
list.push(row);
|
|
writeJson(filePath, list);
|
|
return row;
|
|
}
|
|
|
|
const RUNTIME_DELIVERY_DUPLICATE_NOTICE =
|
|
'Duplicate runtime_delivery ignored. The visible reply is already recorded for this relayOfMessageId; do not call agent-teams_message_send again with the same text unless you have new information.';
|
|
|
|
function normalizeComparableText(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/\r\n/g, '\n')
|
|
.replace(/[ \t]+/g, ' ');
|
|
}
|
|
|
|
function normalizeComparableParticipant(value) {
|
|
return String(value || '').trim().toLowerCase();
|
|
}
|
|
|
|
function getRuntimeDeliveryDuplicate(list, row) {
|
|
if (
|
|
row.source !== 'runtime_delivery' ||
|
|
typeof row.relayOfMessageId !== 'string' ||
|
|
row.relayOfMessageId.trim().length === 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const relayOfMessageId = row.relayOfMessageId.trim();
|
|
const from = normalizeComparableParticipant(row.from);
|
|
const to = normalizeComparableParticipant(row.to);
|
|
const text = normalizeComparableText(row.text);
|
|
if (!from || !to || !text) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
list.find(
|
|
(candidate) =>
|
|
candidate &&
|
|
candidate.source === 'runtime_delivery' &&
|
|
String(candidate.relayOfMessageId || '').trim() === relayOfMessageId &&
|
|
normalizeComparableParticipant(candidate.from) === from &&
|
|
normalizeComparableParticipant(candidate.to) === to &&
|
|
normalizeComparableText(candidate.text) === text
|
|
) || null
|
|
);
|
|
}
|
|
|
|
function appendInboxRow(filePath, row) {
|
|
const current = readJson(filePath, []);
|
|
const list = Array.isArray(current) ? current : [];
|
|
const duplicate = getRuntimeDeliveryDuplicate(list, row);
|
|
if (duplicate) {
|
|
return { row: duplicate, deduplicated: true };
|
|
}
|
|
|
|
list.push(row);
|
|
writeJson(filePath, list);
|
|
return { row, deduplicated: false };
|
|
}
|
|
|
|
function sendInboxMessage(paths, flags) {
|
|
const memberName =
|
|
typeof flags.member === 'string' && flags.member.trim()
|
|
? flags.member.trim()
|
|
: typeof flags.to === 'string' && flags.to.trim()
|
|
? flags.to.trim()
|
|
: '';
|
|
if (!memberName) {
|
|
throw new Error('Missing recipient');
|
|
}
|
|
|
|
const payload = buildMessage(flags, {
|
|
from: 'user',
|
|
to: memberName,
|
|
read: false,
|
|
});
|
|
const appended = appendInboxRow(getInboxPath(paths, memberName), payload);
|
|
return {
|
|
deliveredToInbox: true,
|
|
messageId: appended.row.messageId,
|
|
message: appended.row,
|
|
...(appended.deduplicated
|
|
? {
|
|
deduplicated: true,
|
|
duplicateOfMessageId: appended.row.messageId,
|
|
deduplicationNotice: RUNTIME_DELIVERY_DUPLICATE_NOTICE,
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function appendSentMessage(paths, flags) {
|
|
const payload = buildMessage(flags, {
|
|
from: 'team-lead',
|
|
to: typeof flags.to === 'string' && flags.to.trim() ? flags.to.trim() : undefined,
|
|
read: true,
|
|
});
|
|
appendRow(getSentMessagesPath(paths), payload);
|
|
return payload;
|
|
}
|
|
|
|
/**
|
|
* Exact readonly lookup by messageId across sent messages and all inbox files.
|
|
*
|
|
* Used by task_create_from_message to resolve provenance. Lookup is exact-messageId
|
|
* only and must never resolve by relayOfMessageId, text matching, or active context.
|
|
* Must reject ambiguous matches (same messageId in multiple stores) instead of guessing.
|
|
*
|
|
* Returns { message, store } or throws.
|
|
*/
|
|
function lookupMessage(paths, messageId) {
|
|
const id = typeof messageId === 'string' ? messageId.trim() : '';
|
|
if (!id) {
|
|
throw new Error('Missing messageId');
|
|
}
|
|
|
|
let match = null;
|
|
let matchCount = 0;
|
|
|
|
// 1. Search sentMessages.json
|
|
const sentRows = readJson(getSentMessagesPath(paths), []);
|
|
if (Array.isArray(sentRows)) {
|
|
for (const row of sentRows) {
|
|
if (row && row.messageId === id) {
|
|
match = { message: row, store: 'sent' };
|
|
matchCount++;
|
|
if (matchCount > 1) {
|
|
throw new Error(`Ambiguous messageId: ${id} found in multiple stores`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Search all inbox files (early-exit on ambiguity)
|
|
const inboxDir = path.join(paths.teamDir, 'inboxes');
|
|
let inboxFiles = [];
|
|
try {
|
|
inboxFiles = fs.readdirSync(inboxDir).filter((f) => f.endsWith('.json'));
|
|
} catch {
|
|
// No inboxes directory — that's fine.
|
|
}
|
|
|
|
for (const file of inboxFiles) {
|
|
const rows = readJson(path.join(inboxDir, file), []);
|
|
if (!Array.isArray(rows)) continue;
|
|
for (const row of rows) {
|
|
if (row && row.messageId === id) {
|
|
matchCount++;
|
|
if (matchCount > 1) {
|
|
throw new Error(`Ambiguous messageId: ${id} found in multiple stores`);
|
|
}
|
|
match = { message: row, store: `inbox:${file.replace('.json', '')}` };
|
|
}
|
|
}
|
|
}
|
|
|
|
if (matchCount === 0) {
|
|
throw new Error(`Message not found: ${id}`);
|
|
}
|
|
|
|
return match;
|
|
}
|
|
|
|
module.exports = {
|
|
appendSentMessage,
|
|
lookupMessage,
|
|
sendInboxMessage,
|
|
};
|