diff --git a/Modules/AIProjectGenerator/controller.js b/Modules/AIProjectGenerator/controller.js
index c74eba3..2e3d311 100644
--- a/Modules/AIProjectGenerator/controller.js
+++ b/Modules/AIProjectGenerator/controller.js
@@ -492,17 +492,27 @@ exports.execute = async (req, res) => {
plan = normalizePlanColors(reCheck.data);
// Re-sanitize assignee ids against the current company membership in
- // case roster changed since /plan was called.
+ // case roster changed since /plan was called. While the roster is
+ // loaded, resolve the current user's display name from `uid` so every
+ // AI-generated activity-log entry (project / task / estimate) is
+ // attributed to the real person who triggered the run — matching the
+ // manual flow — instead of a generic label.
+ let currentUserName = '';
try {
const members = await loadActiveMembers(companyId);
const allowed = new Set(members.map((m) => String(m.id)));
+ const me = members.find((m) => String(m.id) === String(uid));
+ if (me && me.name) currentUserName = me.name;
const sanitized = sanitizeMemberIds(plan, allowed);
plan = sanitized.plan;
} catch (_e) { /* leave as-is */ }
const userData = {
id: String(uid),
- Employee_Name: (req.body && req.body.userName) || 'AlianHub AI',
+ // Prefer the server-resolved roster name (authoritative current
+ // user); fall back to a client-supplied name, then a generic label
+ // only if the user genuinely can't be resolved.
+ Employee_Name: currentUserName || (req.body && req.body.userName) || 'AlianHub AI',
companyOwnerId: companyId,
};
diff --git a/Modules/AIProjectGenerator/orchestrator.js b/Modules/AIProjectGenerator/orchestrator.js
index bbda08c..397edc3 100644
--- a/Modules/AIProjectGenerator/orchestrator.js
+++ b/Modules/AIProjectGenerator/orchestrator.js
@@ -54,7 +54,7 @@ const sseEmitter = require('./sseEmitter');
// project generates dozens of tasks at once.
const ESTIMATE_CONCURRENCY = 3;
-function fireTaskEstimatesInBackground(companyId, docs) {
+function fireTaskEstimatesInBackground(companyId, docs, userData) {
if (!Array.isArray(docs) || docs.length === 0) return;
let cursor = 0;
const runOne = async () => {
@@ -67,6 +67,9 @@ function fireTaskEstimatesInBackground(companyId, docs) {
companyId,
taskId: String(d._id),
task: d,
+ // Pass the run's actor so the estimate's activity-log entry
+ // is attributed consistently (defaults to "AlianHub AI").
+ userData,
});
} catch (e) {
logger.error(`AI task estimate error (orchestrator): ${e && e.message ? e.message : e}`);
@@ -735,7 +738,7 @@ async function reserveTaskKeyRange({ companyId, projectId, count, taskTypeIncrem
return { newLastTaskId: lastTaskId, startKey: lastTaskId - count + 1 };
}
-async function createTasksForSprint({ companyId, projectDoc, sprintDoc, tasks, statusByName, taskTypeByKey, creatorUid }) {
+async function createTasksForSprint({ companyId, projectDoc, sprintDoc, tasks, statusByName, taskTypeByKey, creatorUid, userData }) {
if (!tasks || tasks.length === 0) return [];
const docs = tasks.map((t) => buildTaskDoc({
@@ -779,10 +782,32 @@ async function createTasksForSprint({ companyId, projectDoc, sprintDoc, tasks, s
try { socketEmitter.emit('insert', { type: 'insert', data: d, module: 'task' }); } catch (_e) { /* ignore */ }
}
+ // Activity-log entry per created task — mirrors the manual task-create
+ // history (key "Task_Created") so each AI-generated task shows a
+ // "created" row in its Activity tab, just like the project does. We log
+ // ONLY task-level history (not project-level) so the project's Activity
+ // feed keeps its single "created project with N sprints and M tasks"
+ // summary instead of being flooded with one row per task. Best-effort:
+ // failures are logged and never block task creation.
+ const historyActor = userData || { id: String(creatorUid || ''), Employee_Name: 'AlianHub AI' };
+ for (const d of docs) {
+ const taskTypeLabel = String(d.TaskType || 'task').replace(/_/g, '-');
+ const historyObj = {
+ message: `${historyActor.Employee_Name || 'AlianHub AI'} has created new ${d.TaskName} ${taskTypeLabel}.`,
+ key: 'Task_Created',
+ sprintId: String(sprintDoc._id),
+ };
+ HandleHistory('task', companyId, String(projectDoc._id), String(d._id), historyObj, historyActor)
+ .catch((e) => {
+ logger.error(`AI task-create history error: ${e && e.message ? e.message : e}`);
+ });
+ }
+
// Estimates run in the background so the orchestrator's progress stream
// is not blocked by the per-task LLM calls. Each estimate persists its
- // own `totalEstimatedTime` update and emits a follow-up socket event.
- fireTaskEstimatesInBackground(companyId, docs);
+ // own `totalEstimatedTime` update, emits a follow-up socket event, and
+ // logs its own estimate activity-log entry.
+ fireTaskEstimatesInBackground(companyId, docs, userData);
return docs;
}
@@ -975,6 +1000,7 @@ async function executePlan({ plan, companyId, uid, userData, jobId }) {
statusByName: taskStatusByName,
taskTypeByKey,
creatorUid: uid,
+ userData,
});
for (const t of created) tracker.tasks.push(t._id.toString());
completed += created.length;
@@ -1051,4 +1077,5 @@ module.exports = {
blocksToText,
wrapDescriptionBlock,
normalizePriority,
+ createTasksForSprint,
};
diff --git a/Modules/EstimatedTime/aiTaskEstimator.js b/Modules/EstimatedTime/aiTaskEstimator.js
index 13be56a..ee295e2 100644
--- a/Modules/EstimatedTime/aiTaskEstimator.js
+++ b/Modules/EstimatedTime/aiTaskEstimator.js
@@ -29,6 +29,22 @@ try {
providerFactory = null;
}
+// Task activity-log + minute-formatting helpers, loaded defensively (same
+// pattern as providerFactory above) so a missing/renamed module can never
+// break the estimator — the activity-log entry just silently no-ops.
+// HandleHistory writes the same HISTORY doc the manual estimate edit does,
+// so the AI estimate shows up in a task's Activity tab identically.
+let taskHistoryHelper = null;
+try {
+ taskHistoryHelper = require('../Tasks/helpers/helper');
+} catch (_e) {
+ taskHistoryHelper = null;
+}
+
+// Default actor for AI-generated estimates when no explicit user is passed.
+// Matches the AIProjectGenerator flow's author name (see controller.js).
+const AI_ACTOR_NAME = 'AlianHub AI';
+
// Bounds chosen so a malformed LLM response can't write nonsense.
// 5 minutes — smallest meaningful "AI does the task end-to-end" unit.
// 7 days — anything larger should be broken into subtasks.
@@ -231,7 +247,8 @@ async function callProvider(task) {
return parseMinutes(result.content);
}
-async function persistEstimate(companyId, taskId, minutes) {
+async function persistEstimate(companyId, taskId, minutes, opts = {}) {
+ const { userData, previousMinutes } = opts;
const updateQuery = {
type: SCHEMA_TYPE.TASKS,
data: [
@@ -250,10 +267,57 @@ async function persistEstimate(companyId, taskId, minutes) {
module: 'task',
});
} catch (_e) { /* socket emit best-effort */ }
+
+ // Activity-log entry — mirrors the manual estimate-edit history
+ // (key "task_total_estimate") so the AI estimate shows in the task's
+ // Activity tab. Best-effort: never throws, never blocks the estimate.
+ logEstimateHistory({ companyId, result, taskId, minutes, userData, previousMinutes });
}
return result;
}
+/**
+ * Write a task activity-log row for an AI-generated estimate. Resolved from
+ * the canonical updated task doc (so ProjectId / sprintId are always right)
+ * and the shared HandleHistory helper used everywhere else. Best-effort:
+ * any failure is logged and swallowed so it can't affect the estimate flow.
+ */
+function logEstimateHistory({ companyId, result, taskId, minutes, userData, previousMinutes }) {
+ try {
+ if (!taskHistoryHelper
+ || typeof taskHistoryHelper.HandleHistory !== 'function'
+ || typeof taskHistoryHelper.convertToDisplayFormat !== 'function') {
+ return;
+ }
+ const projectId = result.ProjectID ? String(result.ProjectID) : null;
+ if (!projectId) return;
+
+ const actorName = (userData && userData.Employee_Name) || AI_ACTOR_NAME;
+ const actorId = (userData && userData.id) ? String(userData.id) : '';
+ // HISTORY.UserId is a required String — Mongoose rejects an empty
+ // value, so a blank actor id would make the save fail validation and
+ // get silently swallowed by the .catch below (no activity-log row).
+ // Skip rather than attempt a doomed write; callers that want the log
+ // pass a real user (orchestrator -> creator, manual trigger -> clicker).
+ if (!actorId) return;
+ const toText = taskHistoryHelper.convertToDisplayFormat(minutes);
+ const hasPrev = typeof previousMinutes === 'number' && previousMinutes > 0;
+ const message = hasPrev
+ ? `${actorName} has updated total estimated time from ${taskHistoryHelper.convertToDisplayFormat(previousMinutes)} to ${toText} using AI.`
+ : `${actorName} has set the total estimated time to ${toText} using AI.`;
+
+ taskHistoryHelper.HandleHistory('task', companyId, projectId, taskId, {
+ key: 'task_total_estimate',
+ message,
+ sprintId: (result.sprintArray && result.sprintArray.id) || result.sprintId || '',
+ }, { id: actorId, Employee_Name: actorName }).catch((e) => {
+ logger.error(`AI estimate history error for task ${taskId}: ${e && e.message ? e.message : e}`);
+ });
+ } catch (e) {
+ logger.error(`AI estimate history build error for task ${taskId}: ${e && e.message ? e.message : e}`);
+ }
+}
+
/**
* Estimate task completion time and persist to `totalEstimatedTime` (minutes).
* Never throws; safe to invoke without `await`.
@@ -265,9 +329,12 @@ async function persistEstimate(companyId, taskId, minutes) {
* @param {boolean} [params.force] When true, bypass the "estimate already set"
* guard. Used by the manual sidebar button
* which is an explicit recalculation request.
+ * @param {object} [params.userData] Optional actor for the activity-log entry
+ * ({ id, Employee_Name }). Defaults to the
+ * "AlianHub AI" actor when omitted.
* @returns {Promise<{status: boolean, minutes?: number, reason?: string}>}
*/
-async function estimateAndPersist({ companyId, taskId, task, force = false } = {}) {
+async function estimateAndPersist({ companyId, taskId, task, force = false, userData } = {}) {
try {
if (!companyId || !taskId || !task) {
return { status: false, reason: 'missing required input' };
@@ -284,11 +351,17 @@ async function estimateAndPersist({ companyId, taskId, task, force = false } = {
&& task.totalEstimatedTime > 0) {
return { status: false, reason: 'estimate already set' };
}
+ // Capture the prior value BEFORE the update so the activity log can
+ // render a "from X to Y" message on a re-estimate (and "set to Y" on
+ // a first estimate, where there is no prior value).
+ const previousMinutes = (typeof task.totalEstimatedTime === 'number')
+ ? task.totalEstimatedTime
+ : null;
const minutes = await callProvider(task);
if (minutes == null) {
return { status: false, reason: 'no estimate returned' };
}
- await persistEstimate(companyId, taskId, minutes);
+ await persistEstimate(companyId, taskId, minutes, { userData, previousMinutes });
return { status: true, minutes };
} catch (error) {
logger.error(`AI task estimator failed for task ${taskId}: ${error && error.message ? error.message : error}`);
diff --git a/Modules/EstimatedTime/controller.js b/Modules/EstimatedTime/controller.js
index 918fb1b..4c1b0a6 100644
--- a/Modules/EstimatedTime/controller.js
+++ b/Modules/EstimatedTime/controller.js
@@ -105,11 +105,22 @@ exports.generateAiEstimate = async (req, res) => {
return res.status(404).json({ status: false, statusText: 'task not found' });
}
+ // Actor for the activity-log entry the estimator writes. The client
+ // sends the logged-in user so the "updated estimated time" history row
+ // is attributed to whoever clicked the AI trigger (and so HISTORY.UserId
+ // — a required String — is never blank). Falls back to undefined when
+ // absent, in which case the estimator skips the log rather than failing.
+ const { userName, userId } = req.body || {};
+ const userData = userId
+ ? { id: String(userId), Employee_Name: userName || 'AlianHub AI' }
+ : undefined;
+
const result = await estimateTaskTimeWithAI({
companyId,
taskId,
task: taskDoc,
force: true,
+ userData,
});
if (!result.status) {
diff --git a/frontend/src/components/organisms/TaskDetailRightSide/TaskDetailRightSide.vue b/frontend/src/components/organisms/TaskDetailRightSide/TaskDetailRightSide.vue
index a40dc87..c2dc263 100644
--- a/frontend/src/components/organisms/TaskDetailRightSide/TaskDetailRightSide.vue
+++ b/frontend/src/components/organisms/TaskDetailRightSide/TaskDetailRightSide.vue
@@ -715,7 +715,14 @@ const generateAiEstimate = async () => {
}
isAiEstimateLoading.value = true;
try {
- const response = await apiRequest('post', `${env.ESTIMATED_TIME}/ai/${taskId}`, {});
+ // Send the logged-in user so the estimator can attribute the
+ // "updated estimated time" activity-log entry to whoever clicked
+ // (and so the required HISTORY.UserId is never blank).
+ const userData = getUserData();
+ const response = await apiRequest('post', `${env.ESTIMATED_TIME}/ai/${taskId}`, {
+ userName: userData.Employee_Name,
+ userId: userData.id,
+ });
if (response && response.data && response.data.status) {
$toast.success('Estimate generated', { position: 'top-right' });
} else {